Compare commits

..

6 Commits

103 changed files with 2738 additions and 5559 deletions

View File

@@ -1,25 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{go,mod,sum}]
indent_style = tab
indent_size = 4
[Makefile]
indent_style = tab
[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html,md,mdx}]
indent_style = space
indent_size = 2
[*.sh]
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false

15
.gitattributes vendored
View File

@@ -1,15 +0,0 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.gif binary
*.ico binary
*.jpg binary
*.jpeg binary
*.png binary
*.webp binary
*.woff binary
*.woff2 binary
*.gz binary
*.zip binary

View File

@@ -1,14 +1,14 @@
---
name: "Bug 报告"
about: "提交可复现的缺陷报告"
name: "\U0001F41B Bug 报告"
about: "报告一个 Bug帮助我们改进 BackupX"
title: "[Bug] "
labels: ["bug"]
assignees: []
---
## 问题描述
## 描述
<!-- 清晰说明发生了什么,以及它为什么是缺陷。 -->
<!-- 清晰简洁地描述这个 Bug -->
## 复现步骤
@@ -18,39 +18,26 @@ assignees: []
## 期望行为
<!-- 描述正确结果。 -->
<!-- 描述你期望发生什么 -->
## 实际行为
<!-- 描述实际结果,包括稳定复现还是偶发。 -->
<!-- 描述实际发生了什么 -->
## 环境信息
- BackupX 版本或提交:
- 部署方式Docker、裸机、集群
- 操作系统与架构:
- 数据库或备份类型:
- 浏览器版本(仅界面问题):
- 是否使用代理、私有 CA 或堡垒机:
## 最小配置
<!-- 仅保留复现所需字段。删除密码、Token、域名、IP 和存储凭据。 -->
```yaml
```
- **OS**: <!-- 例如 Ubuntu 22.04 -->
- **Go 版本**: <!-- 例如 1.21 -->
- **Node.js 版本**: <!-- 例如 18.17 -->
- **浏览器**: <!-- 例如 Chrome 120 -->
- **BackupX 版本**: <!-- 例如 v1.0.0 -->
## 相关日志
<!-- 附问题发生前后的最小日志片段,并先删除所有敏感信息。 -->
```text
```
<!-- 粘贴相关日志输出 -->
```
## 补充信息
## 截图
<!-- 可附截图、相关 Issue、临时缓解方式或其他上下文。 -->
请勿提交数据库文件、完整配置、备份数据、API Key、Agent Token、安装命令或未脱敏日志。安全漏洞请使用私密漏洞报告入口。
<!-- 如有截图请附上 -->

View File

@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 安全漏洞 / Security vulnerability
url: https://github.com/Awuqing/BackupX/security/advisories/new
about: 请通过私密渠道报告安全问题,不要创建公开 Issue。
- name: 💬 讨论区 / Discussions
url: https://github.com/Awuqing/GoogleDriverBackupEveryDay/discussions
about: 提问、讨论功能想法或分享使用经验

View File

@@ -1,36 +1,23 @@
---
name: "功能请求"
about: "建议一个可落地的新功能或改进"
name: "\U0001F680 功能请求"
about: "建议一个新功能或改进"
title: "[Feature] "
labels: ["enhancement"]
assignees: []
---
## 需求描述
<!-- 清晰简洁地描述你希望的功能 -->
## 使用场景
<!-- 描述在什么环境中遇到了什么问题。 -->
<!-- 描述在什么场景下需要这个功能 -->
## 期望结果
## 建议的解决方案
<!-- 说明用户最终需要完成的任务,不要只描述界面元素。 -->
## 建议方案
<!-- 可选:描述 API、配置、工作流或界面交互。 -->
## 替代方案
<!-- 说明当前绕过方式,以及它为什么不足。 -->
## 兼容性与风险
<!-- 是否影响现有配置、数据格式、部署方式、权限或集群节点。 -->
## 验收标准
- [ ]
- [ ]
<!-- 描述你认为应该如何实现(可选) -->
## 补充信息
<!-- 可附相关文档、日志、截图或同类实现。不要附敏感信息。 -->
<!-- 任何其他相关信息、截图或上下文 -->

View File

@@ -1,48 +1,29 @@
## 变更说明 / Summary
## Pull Request
<!-- 说明做了什么、为什么需要,以及对用户或运维人员的影响。 -->
### 变更类型 / Type of Change
## 变更类型 / Type of Change
- [ ] 🐛 Bug 修复 (非破坏性变更)
- [ ] ✨ 新功能 (非破坏性变更)
- [ ] 💥 破坏性变更 (修复或功能导致现有功能变更)
- [ ] 📝 文档更新
- [ ] ♻️ 代码重构 (不影响功能)
- [ ] ⚡ 性能优化
- [ ] 缺陷修复
- [ ] 新功能
- [ ] 破坏性变更
- [ ] 性能优化
- [ ] 重构或工程化
- [ ] 文档更新
### 描述 / Description
## 相关 Issue / Related Issue
<!-- 描述你做了什么更改以及为什么 -->
<!-- 例如Closes #123。没有关联 Issue 时写“无”。 -->
### 相关 Issue / Related Issue
## 验证 / Validation
<!-- 关联的 Issue 编号,如 Fixes #123 -->
<!-- 只勾选实际执行过的项目,并在下方补充无法执行的原因。 -->
### 测试 / Testing
- [ ] 后端格式、静态检查与测试通过
- [ ] 前端测试与生产构建通过
- [ ] 文档类型检查与中英文生产构建通过
- [ ] 配置、迁移或部署兼容性已验证
- [ ] 已完成与变更范围相符的手动验证
- [ ] 后端测试通过 (`go test ./...`)
- [ ] 前端测试通过 (`npm run test`)
- [ ] 前端构建成功 (`npm run build`)
- [ ] 已在本地环境手动测试
验证命令与结果:
### 截图 / Screenshots
```text
```
## 风险与回滚 / Risk and Rollback
<!-- 说明兼容性风险、数据迁移、配置变化和回滚方法。无风险时写“无”。 -->
## 界面变更 / UI Changes
<!-- 有界面变更时附前后截图;无界面变更时写“无”。 -->
## 提交前检查 / Checklist
- [ ] 提交信息符合 Conventional Commits
- [ ] 未提交密钥、Token、真实配置、数据库或日志
- [ ] 新行为已补充测试和中英文文档
- [ ] 前端未使用 Emoji、渐变、阴影、独立字体或超过 4px 的圆角
- [ ] 没有夹带与本 PR 无关的格式化或重构
<!-- 如有 UI 变更请附上截图 -->

50
.github/SECURITY.md vendored
View File

@@ -1,40 +1,34 @@
# 安全策 / Security Policy
# 安全漏洞披露政策 / Security Policy
## 支持范围 / Supported Versions
## 支持的版本 / Supported Versions
安全修复面向最新稳定版本和 `main` 分支。旧版本不会单独维护安全补丁;升级前请先阅读对应 Release Notes 和升级恢复文档。
| Version | Supported |
|---------|--------------------|
| latest | ✅ |
| < latest | ❌ |
Security fixes target the latest stable release and the `main` branch. Older versions do not receive separate security patches. Review the release notes and upgrade documentation before updating.
## 报告安全漏洞 / Reporting a Vulnerability
| Version | Status |
|---------|--------|
| Latest stable release | Supported |
| `main` | Development support |
| Older releases | Unsupported |
如果您发现了安全漏洞,**请不要通过公开 Issue 报告**。
## 报告漏洞 / Reporting a Vulnerability
请发送邮件至项目维护者,包含以下信息:
请勿通过公开 Issue、Discussion 或 Pull Request 披露安全漏洞。使用 GitHub 的[私密漏洞报告入口](https://github.com/Awuqing/BackupX/security/advisories/new)提交报告。
1. 漏洞描述
2. 复现步骤
3. 受影响的版本
4. 可能的影响范围
Do not disclose vulnerabilities in a public Issue, Discussion, or Pull Request. Submit the report through GitHub's [private vulnerability reporting form](https://github.com/Awuqing/BackupX/security/advisories/new).
我们会在 48 小时内确认收到并开始处理。
报告应包含:
---
1. 受影响版本或提交;
2. 漏洞描述、攻击前提和影响范围;
3. 最小复现步骤或验证代码;
4. 已知缓解措施;
5. 希望使用的署名信息。
If you discover a security vulnerability, **please do NOT open a public issue**.
不要上传真实密钥、Token、备份数据、数据库或包含客户信息的日志。必要时请先脱敏并使用最小化测试数据。
Instead, email the project maintainer with the following details:
Do not upload real credentials, tokens, backup data, databases, or logs containing customer information. Redact sensitive values and use minimal test data.
1. Description of the vulnerability
2. Steps to reproduce
3. Affected versions
4. Potential impact
## 处理流程 / Response Process
- 维护者会尽快确认报告并进行初步分级;
- 修复期间请保持细节私密,避免影响仍未升级的部署;
- 修复发布后会在安全公告或 Release Notes 中说明受影响范围、缓解措施和升级版本;
- 披露时间由报告者与维护者协调确定。
The maintainer will acknowledge and triage the report as soon as practical. Details should remain private until a fix and coordinated disclosure are ready.
We will acknowledge receipt within 48 hours and begin working on a fix.

View File

@@ -1,86 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
time: "03:00"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: gomod
directory: /server
schedule:
interval: weekly
day: monday
time: "03:10"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
groups:
go-minor-and-patch:
patterns:
- "*"
update-types:
- minor
- patch
- package-ecosystem: npm
directory: /web
schedule:
interval: weekly
day: monday
time: "03:20"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
groups:
web-minor-and-patch:
patterns:
- "*"
update-types:
- minor
- patch
- package-ecosystem: npm
directory: /docs-site
schedule:
interval: weekly
day: monday
time: "03:30"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
groups:
docs-minor-and-patch:
patterns:
- "*"
update-types:
- minor
- patch
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
day: monday
time: "03:40"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
groups:
container-images:
patterns:
- "*"

View File

@@ -2,24 +2,18 @@ name: CI
on:
push:
branches: [main]
branches: [main, master]
pull_request:
branches: [main]
workflow_dispatch:
branches: [main, master]
# 最小权限:构建/测试仅需读取仓库内容,显式声明以收敛默认的可写令牌。
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
name: Go Build & Test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
@@ -29,23 +23,6 @@ jobs:
go-version: '1.25'
cache-dependency-path: server/go.sum
- name: Verify modules
working-directory: server
run: go mod verify
- name: Check formatting
working-directory: server
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
printf '%s\n' "$unformatted"
exit 1
fi
- name: Vet
working-directory: server
run: go vet ./...
- name: Build
working-directory: server
run: go build ./...
@@ -57,14 +34,13 @@ jobs:
frontend:
name: React Build & Test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
@@ -72,10 +48,14 @@ jobs:
working-directory: web
run: npm ci
- name: Type Check
working-directory: web
run: npx tsc --noEmit -p tsconfig.json
- name: Test
working-directory: web
run: npm run test
- name: Type Check & Build
- name: Build
working-directory: web
run: npm run build

View File

@@ -1,16 +1,9 @@
name: Deploy Docs
# 触发条件:
# - PR 修改 docs-site/ 时执行类型检查和构建
# - 推送 main 时构建并部署 GitHub Pages
# - 推送 main 时,如果 docs-site/ 或站点相关 README 有变化
# - 手动触发(在 Actions 页面)
on:
pull_request:
branches:
- main
paths:
- 'docs-site/**'
- '.github/workflows/docs.yml'
push:
branches:
- main
@@ -19,20 +12,20 @@ on:
- '.github/workflows/docs.yml'
workflow_dispatch:
# 默认只读Pages 写权限仅授予部署任务。
# 允许写入 Pages用于发布到 github.com/Awuqing/BackupX 的 Pages 站点
permissions:
contents: read
pages: write
id-token: write
# 同一分支只保留最新一次构建,避免旧提交覆盖新结果。
# 同时只保留一个部署任务
concurrency:
group: docs-${{ github.workflow }}-${{ github.ref }}
group: pages-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build Docs
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: docs-site
@@ -43,34 +36,24 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
node-version: '20'
cache: 'npm'
cache-dependency-path: docs-site/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run typecheck
- name: Build site
run: npm run build
- name: Upload artifact
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v3
with:
path: docs-site/build
deploy:
name: Deploy Docs
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}

View File

@@ -27,85 +27,25 @@ on:
type: string
permissions:
contents: read
contents: write
packages: write
# 统一版本号tag 推送取 ref_name手动触发取 inputs.version
env:
VERSION: ${{ github.event.inputs.version || github.ref_name }}
concurrency:
group: release-${{ github.ref }}-${{ github.event.inputs.version || 'tag' }}
cancel-in-progress: false
jobs:
# ─── Job 0: 校验版本与测试 ───
verify:
name: Validate Release
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Validate release input
shell: bash
env:
RELEASE_VERSION: ${{ env.VERSION }}
RELEASE_EVENT: ${{ github.event_name }}
RELEASE_REF: ${{ github.ref }}
run: |
if [[ ! "$RELEASE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then
echo "Version must be a SemVer tag such as v1.2.3 or v1.2.3-rc.1"
exit 1
fi
if [[ "$RELEASE_EVENT" == "workflow_dispatch" && "$RELEASE_REF" != "refs/heads/main" ]]; then
echo "Manual releases must run from the main branch"
exit 1
fi
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache-dependency-path: server/go.sum
- name: Verify backend
working-directory: server
run: |
go mod verify
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
printf '%s\n' "$unformatted"
exit 1
fi
go vet ./...
go test ./...
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Verify frontend
working-directory: web
run: |
npm ci
npm run test
# ─── Job 1: 构建前端 ───
build-web:
name: Build Frontend
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
@@ -127,9 +67,6 @@ jobs:
name: Build ${{ matrix.goarch }}
needs: build-web
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
strategy:
matrix:
include:
@@ -161,13 +98,13 @@ jobs:
run: |
go build \
-trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-ldflags "-s -w -X main.version=${{ env.VERSION }}" \
-o ../backupx \
./cmd/backupx
- name: Package release
run: |
ARCHIVE_NAME="backupx-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}"
ARCHIVE_NAME="backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}"
mkdir -p "${ARCHIVE_NAME}"
cp backupx "${ARCHIVE_NAME}/"
cp -r web/dist "${ARCHIVE_NAME}/web"
@@ -193,7 +130,6 @@ jobs:
backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256
backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256
fail_on_unmatched_files: true
generate_release_notes: true
# ─── Job 3: Docker 多架构 → Docker Hub ───
@@ -201,7 +137,6 @@ jobs:
name: Build & Push Docker
needs: build-web
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4

36
.gitignore vendored
View File

@@ -1,38 +1,4 @@
# Dependencies
web/node_modules/
docs-site/node_modules/
# Build and test artifacts
web/dist/
docs-site/build/
docs-site/.docusaurus/
server/bin/
coverage/
*.out
*.tsbuildinfo
web/vite.config.js
web/vite.config.d.ts
# Runtime data and local configuration
data/
tmp/
temp/
*.db
*.db-shm
*.db-wal
*.log
.env
.env.*
!.env*.example
# Editors, operating systems, and local tools
.idea/
.vscode/
.DS_Store
Thumbs.db
desktop.ini
*.swp
*.swo
*~
.claude/
.codex/
.claude/

View File

@@ -1 +0,0 @@
24

View File

@@ -8,8 +8,8 @@ Thanks for your interest in contributing to BackupX! This guide covers how to se
### 依赖 / Prerequisites
- **Go** 1.25+(见 `server/go.mod`
- **Node.js** 24 LTS`.node-version`CI 与 Docker 使用同一主版本
- **npm** 11+
- **Node.js** 20+CI 与 Docker 使用 Node 20
- **npm** 9+
### 快速开始 / Quick Start
@@ -17,8 +17,6 @@ Thanks for your interest in contributing to BackupX! This guide covers how to se
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
npm --prefix web ci
npm --prefix docs-site ci
# 终端 1 —— 后端(默认 http://localhost:8340
make dev-server
@@ -33,7 +31,6 @@ make dev-web
make build # 同时构建后端与前端
make build-server # 仅后端 → server/bin/backupx
make build-web # 仅前端 → web/dist
make build-docs # 仅文档站 → docs-site/build
make docker # 构建 Docker 镜像
make docker-cn # 国内镜像源加速构建
```
@@ -42,17 +39,15 @@ make docker-cn # 国内镜像源加速构建
## 测试 / Testing
提交前应执行与 CI 一致的完整验证
提交前请确保测试通过
```bash
make verify # 格式、依赖、静态检查、测试与三端构建
make test # 仅后端 + 前端测试
make test # 后端 + 前端全部测试
make test-server # 仅后端cd server && go test ./...
make test-web # 仅前端cd web && npm run testvitest
make check-docs # 文档类型检查 + 中英文站点构建
```
新增功能或修复缺陷时,请补充对应测试。文档变更也必须通过严格断链检查,不能只依赖合并后的 Pages 部署结果。
新增功能或修复缺陷时,请尽量补充对应测试。
## 提交信息规范 / Commit Messages
@@ -87,7 +82,7 @@ docs: 补充 CONTRIBUTING 指南
1. **Fork** 仓库并从最新的 `main` 切出特性分支;
2. **开发**功能或修复,必要时补充测试;
3. **自测**:确保 `make verify` 通过;
3. **自测**:确保 `make test` 通过;
4. **提交**:使用上述 Conventional Commits中文
5. **推送**并对着 `main` 发起 PR。
@@ -96,17 +91,15 @@ docs: 补充 CONTRIBUTING 指南
- 清晰说明本 PR 做了什么;
- 对新功能/修复,补充动机与背景;
- 关联相关 Issue`Closes #62`
- 纯文档 PR 至少应附上 `make check-docs` 的结果
- 纯文档 PR 可不附测试
> 请保持分支基于较新的 `main`:基线过旧的分支容易产生大范围冲突,难以评审与合入。
## 编码规范 / Coding Conventions
- **Go**遵循现有 handler → service → repository 分层;避免把连续业务流程拆成大量无复用价值的独立函数。所有错误必须处理,日志使用 `zap`,禁止 `fmt.Println`提交前执行 `gofmt``go vet`
- **前端组件**复用并组合现有组件,不在页面内复制同类交互逻辑,不擅自改变共享组件的基础样式,不引入新的 CSS 框架或 UI 库。
- **前端视觉**:不使用 Emoji、渐变和 `box-shadow`;图标统一使用项目 SVG 图标组件;圆角为 04px不引入独立字体不用加粗字体制造层级减少 Card不使用数据左侧装饰竖线
- **包管理**`web/``docs-site/` 均使用 npm依赖变更必须同步提交对应的 `package-lock.json`
- **文档**:英文源文件与 `zh-CN` 翻译应同步维护;新增页面必须加入侧边栏并通过两种语言的生产构建。
- **Go**所有错误必须处理(禁止 `_ = err`),日志使用项目已有库(`zap`,禁止 `fmt.Println`提交前执行 `gofmt`
- **前端**遵循项目 ESLint/Prettier/tsconfig 配置,不擅自引入新的 CSS 框架或 UI 库。
- **包管理**`web/` 使用 npm请提交对应的 `package-lock.json`
## License

View File

@@ -10,7 +10,7 @@ ARG USE_CHINA_MIRROR=false
# ---- Stage 1: Build frontend ----
FROM node:24-alpine AS web-builder
FROM node:20-alpine AS web-builder
ARG USE_CHINA_MIRROR
# 国内镜像npm 使用淘宝源

View File

@@ -1,6 +1,4 @@
.PHONY: build build-server build-web build-docs dev-server dev-web \
test test-server test-web check-docs format-check vet-server \
verify verify-server verify-web verify-docs clean docker docker-cn
.PHONY: build dev test clean docker docker-cn
# 自动获取版本号(从 git tag 或 commit hash
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
@@ -14,9 +12,6 @@ build-server:
build-web:
cd web && npm run build
build-docs:
cd docs-site && npm run build
# ── 开发模式(分别在两个终端运行)──
dev-server:
cd server && go run ./cmd/backupx
@@ -33,28 +28,6 @@ test-server:
test-web:
cd web && npm run test
check-docs:
cd docs-site && npm run typecheck && npm run build
format-check:
@unformatted="$$(gofmt -l server)"; \
if [ -n "$$unformatted" ]; then \
printf '%s\n' "$$unformatted"; \
exit 1; \
fi
vet-server:
cd server && go mod verify && go vet ./...
# ── 提交前完整验证 ──
verify: verify-server verify-web verify-docs
verify-server: format-check vet-server test-server build-server
verify-web: test-web build-web
verify-docs: check-docs
# ── Docker 构建 ──
docker:
docker build --build-arg VERSION=$(VERSION) -t backupx:$(VERSION) -t backupx:latest .

View File

@@ -10,8 +10,6 @@
<p align="center">
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
<a href="https://github.com/Awuqing/BackupX/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Awuqing/BackupX/ci.yml?branch=main&style=flat-square" alt="CI"></a>
<a href="https://github.com/Awuqing/BackupX/actions/workflows/docs.yml"><img src="https://img.shields.io/github/actions/workflow/status/Awuqing/BackupX/docs.yml?branch=main&style=flat-square&label=docs" alt="Docs"></a>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go" alt="Go">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
@@ -51,56 +49,35 @@
| **Observability** | Prometheus `/metrics` endpoint + `/health` + `/ready` probes + SLA breach gauge |
| **Audit Webhook** | HMAC-SHA256 signed forwarding to SIEM / WORM storage for compliance (SOC2 / GDPR) |
| **Flow Control** | Per-node bandwidth cap + per-node concurrency limit — tune big/small nodes independently |
| **Deployment** | Single binary + embedded SQLite; no external control-plane database (database backup tools are required on the execution host) |
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
## Quick Start
Docker Compose (recommended):
```bash
git clone --depth 1 https://github.com/Awuqing/BackupX.git
cd BackupX
docker compose up -d
```
# Docker (recommended)
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
Prebuilt archive:
```bash
# Or prebuilt archive
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz.sha256
sha256sum -c backupx-linux-amd64.tar.gz.sha256
tar xzf backupx-linux-amd64.tar.gz
cd backupx-*-linux-amd64
sudo ./install.sh
```
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
Build and install on bare metal:
```bash
git clone https://github.com/Awuqing/BackupX.git
cd BackupX
make build
sudo ./deploy/install.sh
# Or build and install on bare metal without Docker
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build && sudo ./deploy/install.sh
```
For ARM64 hosts, use `backupx-linux-arm64.tar.gz`. The archive contains `backupx`, `web/`, `config.example.yaml`, and `install.sh`; run `install.sh` from the extracted directory.
The Compose quick start defaults to `latest` for evaluation. Pin `BACKUPX_IMAGE` to a release tag or digest and review the security and recovery guides before production.
Open `http://your-server:8340`, choose English or Chinese on the setup screen, create the first administrator account, then follow the [5-minute Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start).
## Documentation
The full docs live at **https://awuqing.github.io/BackupX/** — Getting Started, Deployment, Operations, SAP HANA, Multi-Node Cluster, API reference, and more. Switch to Chinese via the language dropdown in the top-right nav.
The full docs live at **https://awuqing.github.io/BackupX/** — Getting Started, Deployment, SAP HANA, Multi-Node Cluster, API reference, and more. Switch to Chinese via the language dropdown in the top-right nav.
Quick links:
- [Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start) — first backup in five minutes
- [Installation](https://awuqing.github.io/BackupX/docs/getting-started/installation) — Docker / bare metal / source
- [Upgrade & Recovery](https://awuqing.github.io/BackupX/docs/operations/upgrade-recovery) — snapshots, upgrades, rollback, and disaster recovery
- [Security Hardening](https://awuqing.github.io/BackupX/docs/operations/security) — production exposure, roles, and secrets
- [Monitoring & Alerts](https://awuqing.github.io/BackupX/docs/operations/monitoring) — probes, metrics, and initial alerts
- [Troubleshooting](https://awuqing.github.io/BackupX/docs/operations/troubleshooting) — Master, proxy, Agent, and task diagnostics
- [Multi-Node Cluster](https://awuqing.github.io/BackupX/docs/features/multi-node) — deploy the Agent on remote servers
- [SAP HANA Support](https://awuqing.github.io/BackupX/docs/features/sap-hana) — hdbsql Runner and native Backint
- [API Reference](https://awuqing.github.io/BackupX/docs/reference/api) — REST endpoints

View File

@@ -10,8 +10,6 @@
<p align="center">
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
<a href="https://github.com/Awuqing/BackupX/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Awuqing/BackupX/ci.yml?branch=main&style=flat-square" alt="CI"></a>
<a href="https://github.com/Awuqing/BackupX/actions/workflows/docs.yml"><img src="https://img.shields.io/github/actions/workflow/status/Awuqing/BackupX/docs.yml?branch=main&style=flat-square&label=docs" alt="Docs"></a>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go" alt="Go">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
@@ -51,56 +49,35 @@
| **可观测性** | Prometheus `/metrics` 端点 + `/health` + `/ready` 探针 + SLA 违约监控 |
| **审计外输** | HMAC-SHA256 签名 Webhook对接 SIEM / WORM 存储满足 SOC2 / GDPR 合规 |
| **流控** | 节点级带宽限速 + 节点级并发控制,大小节点分别配置,避免小内存 Agent 被挤爆 |
| **部署** | 单二进制 + 内嵌 SQLite无需外部控制面数据库(数据库备份工具需安装在任务执行主机) |
| **部署** | 单二进制 + 内嵌 SQLiteDocker 一键启动,零外部依赖 |
## 快速开始
Docker Compose推荐
```bash
git clone --depth 1 https://github.com/Awuqing/BackupX.git
cd BackupX
docker compose up -d
```
# Docker推荐
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
预编译包
```bash
# 或使用预编译包
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz.sha256
sha256sum -c backupx-linux-amd64.tar.gz.sha256
tar xzf backupx-linux-amd64.tar.gz
cd backupx-*-linux-amd64
sudo ./install.sh
```
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
从源码构建并裸机安装
```bash
git clone https://github.com/Awuqing/BackupX.git
cd BackupX
make build
sudo ./deploy/install.sh
# 或从源码构建并裸机安装(无需 Docker
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build && sudo ./deploy/install.sh
```
ARM64 主机请下载 `backupx-linux-arm64.tar.gz`。预编译包内包含 `backupx``web/``config.example.yaml``install.sh`,请在解压后的目录内执行 `install.sh`
Compose 快速开始为便于体验默认使用 `latest`。生产环境应把 `BACKUPX_IMAGE` 固定到 Release 标签或摘要,并先阅读安全与恢复指南。
打开 `http://your-server:8340`,在初始化页选择中文或 English 并创建首个管理员账户,按 [5 分钟快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) 完成首次备份。
## 文档
完整文档见 **https://awuqing.github.io/BackupX/zh-Hans/** — 快速开始、部署、运维、SAP HANA、多节点集群、API 参考等。
完整文档见 **https://awuqing.github.io/BackupX/zh-Hans/** — 快速开始、部署、SAP HANA、多节点集群、API 参考等。
快捷链接:
- [快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) — 五分钟跑通第一个备份
- [安装](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/installation) — Docker / 裸机 / 源码
- [升级与恢复](https://awuqing.github.io/BackupX/zh-Hans/docs/operations/upgrade-recovery) — 快照、升级、回滚与灾难恢复
- [安全加固](https://awuqing.github.io/BackupX/zh-Hans/docs/operations/security) — 生产暴露、角色与密钥
- [监控与告警](https://awuqing.github.io/BackupX/zh-Hans/docs/operations/monitoring) — 探针、指标与初始告警
- [故障排查](https://awuqing.github.io/BackupX/zh-Hans/docs/operations/troubleshooting) — Master、代理、Agent 与任务诊断
- [多节点集群](https://awuqing.github.io/BackupX/zh-Hans/docs/features/multi-node) — 远程服务器部署 Agent
- [SAP HANA 支持](https://awuqing.github.io/BackupX/zh-Hans/docs/features/sap-hana) — hdbsql Runner 与原生 Backint
- [API 参考](https://awuqing.github.io/BackupX/zh-Hans/docs/reference/api) — REST 端点

View File

@@ -1,28 +1,41 @@
# BackupX documentation site
# Website
The public documentation is a Docusaurus site with English source documents and a complete Simplified Chinese translation.
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
## Local development
## Installation
```bash
npm ci
npm start
yarn
```
Use `npm start -- --locale zh-Hans` to preview the Chinese site. The public Chinese URL remains `/zh-Hans/`; its source files live under `i18n/zh-CN/` through the locale `path` mapping in `docusaurus.config.ts`.
## Verification
## Local Development
```bash
npm run typecheck
npm run build
yarn start
```
The production build renders both locales and fails on broken document links. GitHub Actions publishes `build/` to GitHub Pages after changes reach `main`; do not deploy the site manually from a feature branch.
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
When adding, renaming, or removing a document:
## Build
1. Apply the same change under `docs/` and `i18n/zh-CN/docusaurus-plugin-content-docs/current/`.
2. Update `sidebars.ts` and the translated sidebar labels when a category changes.
3. Use relative links for links between documents so both locale prefixes resolve correctly.
4. Run the full verification commands before opening a pull request.
```bash
yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
## Deployment
Using SSH:
```bash
USE_SSH=true yarn deploy
```
Not using SSH:
```bash
GIT_USER=<Your GitHub username> yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.

View File

@@ -68,9 +68,8 @@ The installed unit:
```ini title="/etc/systemd/system/backupx.service"
[Unit]
Description=BackupX API Service
After=network-online.target
Wants=network-online.target
Description=BackupX backup management service
After=network.target
[Service]
Type=simple
@@ -101,8 +100,6 @@ Open `http://your-server:8340`, switch to English if desired, and create the fir
For production, expose BackupX through HTTPS or restrict port `8340` at the firewall. The installer does not make firewall changes.
Before replacing a release, snapshot `/etc/backupx`, `/opt/backupx/data`, the installed binary, and web assets while the service is stopped. Follow the versioned procedure in [Upgrade and Recovery](../operations/upgrade-recovery); running an older binary against a database already migrated by a newer release is not a safe rollback.
## Password reset
If the admin password is lost:

View File

@@ -1,7 +1,7 @@
---
sidebar_position: 4
title: Configuration Reference
description: All config.yaml server keys with defaults and matching environment variables.
description: All server.yaml configuration keys with defaults and matching environment variables.
---
# Configuration Reference
@@ -32,15 +32,12 @@ security:
backup:
temp_dir: "/tmp/backupx" # BACKUPX_BACKUP_TEMP_DIR
max_concurrent: 2 # BACKUPX_BACKUP_MAX_CONCURRENT
retries: 10 # Per-upload rclone low-level retries
retries: 3 # Per-upload rclone low-level retries
bandwidth_limit: "" # e.g. "10M" to cap transfers at 10 MB/s
log:
level: "info" # debug | info | warn | error
file: "./data/backupx.log"
max_size: 100 # MB per log file
max_backups: 3 # rotated files retained
max_age: 30 # retention in days
```
## Secret generation
@@ -56,17 +53,11 @@ The environment wins when both file and env are set. All dot-paths become unders
| `server.port` | `BACKUPX_SERVER_PORT` |
| `server.external_url` | `BACKUPX_SERVER_EXTERNAL_URL` |
| `server.trusted_proxies` | `BACKUPX_SERVER_TRUSTED_PROXIES` (comma-separated for env) |
| `security.jwt_secret` | `BACKUPX_SECURITY_JWT_SECRET` |
| `security.jwt_expire` | `BACKUPX_SECURITY_JWT_EXPIRE` |
| `security.encryption_key` | `BACKUPX_SECURITY_ENCRYPTION_KEY` |
| `log.level` | `BACKUPX_LOG_LEVEL` |
| `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` |
| `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` |
| `backup.retries` | `BACKUPX_BACKUP_RETRIES` |
| `backup.bandwidth_limit` | `BACKUPX_BACKUP_BANDWIDTH_LIMIT` |
| `log.max_size` | `BACKUPX_LOG_MAX_SIZE` |
| `log.max_backups` | `BACKUPX_LOG_MAX_BACKUPS` |
| `log.max_age` | `BACKUPX_LOG_MAX_AGE` |
## Master external URL
@@ -79,7 +70,7 @@ server:
This value is used when BackupX renders one-click Agent install scripts and docker-compose snippets. It must be reachable from every Agent host. Leave it empty only when `X-Forwarded-Proto` / `X-Forwarded-Host` are reliable and point to the same URL that Agents can access.
The install wizard can set an Agent-specific URL for a proxy or SSH-bastion node. That override is used by both the target-side one-time install URL and the generated Agent runtime configuration, while the browser continues to use the normal public address.
The install wizard can set an Agent-specific runtime URL for a proxy or SSH-bastion node. The public install link continues to use `server.external_url`, while the generated Agent config uses that override.
## Trusted reverse proxies
@@ -93,5 +84,3 @@ server:
```
Do not configure `0.0.0.0/0`: client addresses feed authentication throttling, install-token throttling, and audit records. Set an empty list when BackupX is exposed directly and should trust no forwarded headers.
Back up the complete data directory and configuration before changing security keys or database paths. See [Upgrade and Recovery](../operations/upgrade-recovery) for a tested snapshot and rollback sequence.

View File

@@ -85,7 +85,7 @@ environment:
The image's internal port is fixed at `8340`; change only the published host port with `BACKUPX_PORT`.
## Upgrade prerequisites
## Upgrade and rollback preparation
```bash
docker compose pull
@@ -94,5 +94,3 @@ docker compose ps
```
Wait for `healthy` before switching traffic or removing an old deployment. Before upgrades, stop the Master for a file-level copy or take an atomic snapshot of the entire `backupx-data` volume. Keep exactly one active Master for a data volume; SQLite does not support multiple Master containers sharing `/app/data`.
Use a release tag or digest instead of `latest`, and keep the matching pre-upgrade data snapshot. The complete upgrade, rollback, and disaster-recovery procedure is in [Upgrade and Recovery](../operations/upgrade-recovery).

View File

@@ -29,7 +29,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Connection "";
# Large uploads (restore flow)
client_max_body_size 0;
@@ -37,28 +36,9 @@ server {
# Live log stream uses SSE — buffering must be off
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Compatibility route for installers generated by older releases.
# Current installers use /api/install/ through the API block above.
location /install/ {
proxy_pass http://127.0.0.1:8340/install/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
}
# Keep probes and metrics out of the SPA fallback.
location = /health { proxy_pass http://127.0.0.1:8340/health; }
location = /ready { proxy_pass http://127.0.0.1:8340/ready; }
location = /metrics { proxy_pass http://127.0.0.1:8340/metrics; }
}
```
@@ -66,8 +46,6 @@ server {
If Nginx runs on another host or in another container, add only that proxy IP or subnet to `server.trusted_proxies`. Do not use `0.0.0.0/0`; BackupX uses the trusted client address for login throttling, install-token throttling, and audit records.
`/health`, `/ready`, and `/metrics` do not require BackupX authentication. Allow probe and Prometheus source networks explicitly, or keep these locations on an internal listener instead of exposing them to the Internet.
## HTTPS with certbot
```bash
@@ -78,5 +56,5 @@ sudo certbot --nginx -d backup.example.com
Certbot rewrites the config to listen on 443 with auto-renewal.
:::caution Agent needs a stable URL
If Master is behind HTTPS, remote Agent deployments must use the final HTTPS URL for `--master`; redirects are not followed. For a private CA, pre-provision its PEM certificate and use `--ca-cert /path/to/ca.pem`. Reserve `--insecure-tls` for short-lived testing.
If Master is behind HTTPS, remote Agent deployments must use the public HTTPS URL for `--master`. Self-signed certs require `--insecure-tls` (testing only).
:::

View File

@@ -6,7 +6,7 @@ description: Get a BackupX dev environment running — backend, frontend, tests.
# Development Setup
**Requirements:** Go ≥ 1.25, Node.js 24 LTS, npm 11 or later.
**Requirements:** Go ≥ 1.25, Node.js ≥ 20, npm.
## Clone & install

View File

@@ -10,25 +10,38 @@ BackupX ships as a single static binary. Three ways to install, pick the one tha
## Docker (recommended)
Download the canonical hardened Compose file and start the service:
No cloning required.
```bash
curl -fLO https://raw.githubusercontent.com/Awuqing/BackupX/main/docker-compose.yml
docker compose up -d
docker compose ps
docker run -d --name backupx \
-p 8340:8340 \
-v backupx-data:/app/data \
awuqing/backupx:latest
```
The Compose definition enables init and graceful shutdown, persists `/app/data`, runs the application as an unprivileged user, drops unnecessary capabilities, and checks `/ready`. Images at [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) support `linux/amd64` and `linux/arm64`.
Or use `docker compose`:
For production, create a protected `.env` and pin a release instead of relying on `latest`:
```yaml title="docker-compose.yml"
services:
backupx:
image: awuqing/backupx:latest
container_name: backupx
restart: unless-stopped
ports:
- "8340:8340"
volumes:
- backupx-data:/app/data
# Mount host directories to back up (as needed):
# - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro
environment:
- TZ=Asia/Shanghai
```dotenv
BACKUPX_IMAGE=awuqing/backupx:vX.Y.Z
BACKUPX_BIND_ADDRESS=127.0.0.1
TZ=Asia/Shanghai
volumes:
backupx-data:
```
Use the loopback binding when a reverse proxy runs on the same host. For direct access, choose the intended interface and enforce a firewall. Mount host backup sources read-only or deploy an Agent on the source host. See [Docker Deployment](../deployment/docker) for the full configuration.
Images: [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) — supports `linux/amd64` and `linux/arm64`.
## Prebuilt archive (bare metal)
@@ -51,7 +64,7 @@ The installer:
## From source
Requires Go ≥ 1.25, Node.js 24 LTS, and npm 11 or later.
Requires Go ≥ 1.25 and Node.js ≥ 20.
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX

View File

@@ -57,6 +57,5 @@ Deleting a task also removes remote backup files to prevent orphans, but records
## Next up
- Explore [backup types](/docs/features/backup-types) and [storage backends](/docs/features/storage-backends)
- Before production, review [Security Hardening](/docs/operations/security), [Monitoring and Alerts](/docs/operations/monitoring), and [Upgrade and Recovery](/docs/operations/upgrade-recovery)
- Running SAP HANA? See [SAP HANA Support](/docs/features/sap-hana)
- Managing many servers? See [Multi-Node Cluster](/docs/features/multi-node)

View File

@@ -35,8 +35,6 @@ Tasks routed to the local Master run in-process; tasks assigned to remote nodes
- **New to BackupX?** Read the [Quick Start](/docs/getting-started/quick-start) first.
- **Deploying to production?** See the [Deployment Guide](/docs/deployment/docker).
- **Planning upgrades or recovery?** Follow [Upgrade and Recovery](/docs/operations/upgrade-recovery).
- **Operating production?** Start with [Security Hardening](/docs/operations/security) and [Monitoring and Alerts](/docs/operations/monitoring).
- **SAP HANA operator?** Both `hdbsql` Runner and native Backint are supported — see [SAP HANA](/docs/features/sap-hana).
- **Managing multiple servers?** See [Multi-Node Cluster](/docs/features/multi-node).
- **Integrating programmatically?** See the [API Reference](/docs/reference/api).

View File

@@ -1,149 +0,0 @@
---
sidebar_position: 3
title: Monitoring and Alerts
description: Health probes, Prometheus metrics, initial alert rules, and operational validation.
---
# Monitoring and Alerts
BackupX exposes low-cost health endpoints and a dedicated Prometheus registry. Monitor both the control plane and the outcome of backup, restore, verification, and replication work.
## Probes
| Endpoint | Meaning | Expected response |
| --- | --- | --- |
| `/health` | Liveness: the HTTP process can respond | HTTP 200 with `status: live` |
| `/ready` | Readiness: the process can reach SQLite | HTTP 200 with `status: ready`; HTTP 503 on database failure |
| `/api/health` | API-prefixed alias for liveness | Same as `/health` |
| `/api/ready` | API-prefixed alias for readiness | Same as `/ready` |
| `/metrics` | Prometheus exposition | HTTP 200 when metrics are enabled |
Use `/health` for a liveness probe and `/ready` for readiness or load-balancer traffic decisions. Do not restart a process only because an external storage provider is unavailable; storage health belongs in task and target alerts.
~~~bash
curl -fsS http://127.0.0.1:8340/health
curl -fsS http://127.0.0.1:8340/ready
curl -fsS http://127.0.0.1:8340/metrics | head
~~~
These endpoints are unauthenticated. Restrict them to orchestrator and monitoring networks.
## Prometheus scrape
~~~yaml
scrape_configs:
- job_name: backupx
scheme: https
metrics_path: /metrics
static_configs:
- targets: [backup.example.com]
~~~
When Nginx terminates TLS, allow the Prometheus source address to reach `/metrics` and deny other public clients. The internal collector refreshes storage, node, command-queue, and SLA gauges every 30 seconds.
## BackupX metrics
| Metric | Type | Labels | Purpose |
| --- | --- | --- | --- |
| `backupx_app_info` | gauge | `version` | Running release metadata |
| `backupx_task_run_total` | counter | `status`, `task_type` | Backup outcomes |
| `backupx_task_run_duration_seconds` | histogram | `task_type` | Backup duration distribution |
| `backupx_task_bytes_total` | counter | `task_type` | Produced backup bytes |
| `backupx_task_running` | gauge | none | Current backup concurrency |
| `backupx_storage_used_bytes` | gauge | `target_name`, `target_type` | Recorded usage per target |
| `backupx_node_online` | gauge | `node_name`, `role` | Node online state, 1 or 0 |
| `backupx_agent_command_queue_depth` | gauge | `node_name`, `role` | Pending and dispatched commands |
| `backupx_agent_command_running` | gauge | `node_name`, `role` | Long-running Agent commands |
| `backupx_agent_command_timeout_total` | gauge | `node_name`, `role` | Snapshot of timed-out commands |
| `backupx_verify_run_total` | counter | `status` | Verification outcomes |
| `backupx_restore_run_total` | counter | `status` | Restore outcomes |
| `backupx_replication_run_total` | counter | `status` | Replication outcomes |
| `backupx_sla_breach_tasks` | gauge | none | Enabled tasks outside their configured RPO |
Standard Go runtime and process collectors are registered in the same endpoint.
## Initial alert rules
Tune windows and thresholds to the schedules and RPOs of each environment:
~~~yaml
groups:
- name: backupx
rules:
- alert: BackupXTargetDown
expr: up{job="backupx"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: BackupX metrics endpoint is unreachable
- alert: BackupXNotReady
expr: probe_success{job="backupx-ready"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: BackupX readiness check is failing
- alert: BackupXBackupFailure
expr: sum(increase(backupx_task_run_total{status="failed"}[15m])) > 0
labels:
severity: warning
annotations:
summary: A BackupX backup failed
- alert: BackupXSLABreach
expr: backupx_sla_breach_tasks > 0
for: 5m
labels:
severity: critical
annotations:
summary: One or more backup tasks are outside RPO
- alert: BackupXAgentOffline
expr: backupx_node_online{role="agent"} == 0
for: 2m
labels:
severity: warning
annotations:
summary: BackupX Agent is offline
- alert: BackupXAgentQueueBacklog
expr: backupx_agent_command_queue_depth > 20
for: 10m
labels:
severity: warning
annotations:
summary: BackupX Agent command queue is growing
~~~
The `BackupXNotReady` example assumes a blackbox probe job named `backupx-ready`. If no blackbox exporter is used, alert from the load balancer or orchestrator readiness signal instead.
## Operational dashboard
Track these views together:
- Success and failure rate by task type.
- P50, P95, and maximum run duration relative to the backup window.
- Bytes produced compared with the expected data-change rate.
- Current running tasks versus `backup.max_concurrent`.
- Offline Agents, queue depth, running commands, and timeout-count changes.
- Storage growth, free capacity from the storage provider, and retention cleanup.
- SLA breach count and age of the most recent successful backup for critical tasks.
- Verification, restore, and replication success rates.
Prometheus storage usage is based on BackupX record metadata, not necessarily the provider's billable capacity. Monitor provider quota and filesystem free space separately.
## Post-deployment validation
After installation, upgrade, proxy changes, or recovery:
1. Check liveness and readiness locally and through the public proxy.
2. Confirm Prometheus sees one active Master and the expected version label.
3. Verify every expected Agent reports `backupx_node_online == 1`.
4. Run a small backup and confirm the success counter increases.
5. Run a verification or isolated restore and confirm its counter increases.
6. Trigger a test notification and verify the alert delivery path.
Continue with [Troubleshooting](./troubleshooting) when a probe or metric is abnormal.

View File

@@ -1,102 +0,0 @@
---
sidebar_position: 2
title: Security Hardening
description: Production controls for network exposure, roles, secrets, Agents, containers, and public endpoints.
---
# Security Hardening
BackupX coordinates access to source files, database credentials, storage credentials, and restore destinations. Deploy the Master as a security-sensitive control plane, not as a general public web application.
## Recommended exposure model
| Component | Inbound access | Outbound access |
| --- | --- | --- |
| Master | HTTPS from administrators and Agents; metrics only from monitoring networks | Storage providers, notification endpoints, release checks |
| Agent | No inbound port required | Master HTTPS endpoint and assigned storage targets |
| SQLite data | Local or block-backed filesystem only | None |
Bind Docker to `127.0.0.1` when a reverse proxy runs on the same host:
~~~dotenv
BACKUPX_BIND_ADDRESS=127.0.0.1
~~~
For bare metal, set `server.host` to loopback when only a local proxy should reach BackupX. Otherwise restrict TCP 8340 with the host or network firewall.
## TLS and reverse proxies
- Use HTTPS across every untrusted network segment.
- Set `server.external_url` to the stable URL that Agents can reach.
- Add only the exact proxy IP or subnet to `server.trusted_proxies`. Never trust `0.0.0.0/0`.
- Send the final HTTPS URL to Agents; the Agent does not follow redirects.
- For private PKI, install a PEM CA on the Agent and configure `caCertFile` or `--ca-cert`.
- Use `--insecure-tls` only for temporary testing.
- Keep Nginx request and response buffering disabled for relay uploads and SSE logs.
When an SSH bastion is required, bind tunnels to loopback, verify host keys, use a dedicated account and key, and make the Agent service depend on the tunnel. See [Multi-Node Cluster](../features/multi-node).
## Roles and API keys
| Role | Intended access |
| --- | --- |
| `viewer` | Read dashboards, tasks, records, reports, and audit data; cannot browse node filesystems or mutate resources |
| `operator` | Viewer access plus task, storage, notification, backup, restore, verification, and file-browse operations |
| `admin` | Operator access plus users, API keys, settings, node lifecycle, install tokens, and token rotation |
Create separate named users instead of sharing the initial administrator. Enable two-factor authentication or passkeys for privileged accounts. Review trusted devices and recovery codes periodically.
User JWTs are stateless. Logout removes the client copy but does not revoke a token that was already copied elsewhere. Set `security.jwt_expire` to the shortest practical lifetime, protect Bearer tokens, and rotate the JWT secret when all active sessions must be invalidated.
API keys use the same role checks as interactive users. Their plaintext is shown only once; the database stores a keyed hash. Give automation the lowest role it needs, set an expiry, keep the key in a secret manager, and revoke unused keys. Avoid administrator API keys for monitoring.
## Protect control-plane secrets
- Restrict `/etc/backupx/config.yaml` to `root:backupx` mode `0640` and the data directory to the service account.
- If `jwt_secret` and `encryption_key` are empty, generated values are persisted in the SQLite database. Back up the complete data directory.
- Losing or replacing the encryption key makes saved storage credentials unreadable.
- The database includes password hashes, configuration secrets, Agent tokens, API-key hashes, trusted-device state, and audit data. Encrypt snapshots and control their retention.
- Do not put tokens in shell history, issue text, screenshots, or support bundles.
Each node has an independent long-lived Agent token. The systemd installer stores it in `/etc/backupx-agent/agent.token` with mode `0600`. Rotate a token after personnel changes, host compromise, or accidental disclosure, update the token file during the overlap window, then restart the Agent.
One-time install URLs are valid for 5 minutes to 24 hours and are consumed after use. Treat the URL and the embedded fallback command as secrets: the generated installation material provisions the long-lived node token.
## Container and host permissions
The canonical Compose deployment drops all capabilities and adds back only those needed to repair legacy volume ownership and switch to the unprivileged `backupx` user. Keep `no-new-privileges` enabled and do not mount the Docker socket.
Mount backup sources read-only. Add a separate, narrowly scoped writable mount only when a restore destination requires it. Prefer a host Agent over running the Master container as root for privileged filesystem access.
The systemd Master runs as `backupx`. The Agent normally runs as root because it may back up or restore files belonging to arbitrary system users. Limit who can create tasks and protect the root-owned Agent configuration.
## Public endpoints
The following endpoints intentionally do not use BackupX JWT or API-key authentication:
- `/health` and `/api/health`
- `/ready` and `/api/ready`
- `/metrics`
- one-time `/install/:token` and `/api/install/:token` routes
Health responses expose status, version, uptime, timestamp, and readiness checks; a failed readiness check can include database error detail. `/metrics` also includes node and storage-target labels. Restrict metrics and probes to monitoring networks at the firewall or reverse proxy. Do not cache or log full install-token URLs.
## Backup encryption boundary
Encrypted backup tasks run on the Master because remote Agents never receive the Master's encryption key. Do not work around this boundary by copying the Master key to Agents. For Agent-routed tasks, rely on transport encryption and the destination provider's server-side encryption when required.
Test restores for encrypted backups after every key-management change. A backup whose key is unavailable is not recoverable.
## Audit and incident response
BackupX records privileged actions in the audit log and can forward signed audit events to an external webhook. Send high-value audit records to a separately administered SIEM or append-only store so a compromised Master cannot erase the only copy.
After suspected compromise:
1. Isolate the Master without deleting evidence.
2. Revoke exposed API keys and rotate affected Agent tokens and storage credentials.
3. Replace JWT and encryption keys only with a planned migration; changing the encryption key invalidates saved encrypted configuration.
4. Review user, trusted-device, API-key, node, settings, restore, and deletion events.
5. Recover from a known-good control-plane snapshot when integrity cannot be established.
Use [Upgrade and Recovery](./upgrade-recovery) for the paired application-and-database recovery procedure.

View File

@@ -1,160 +0,0 @@
---
sidebar_position: 4
title: Troubleshooting
description: A safe diagnostic sequence for the Master, reverse proxy, Agents, backup tools, and SQLite.
---
# Troubleshooting
Start with the first failing boundary and preserve evidence. Avoid deleting the database, recreating volumes, rotating every token, or reinstalling until the failure is understood.
## Fast triage
| Symptom | First check | Likely boundary |
| --- | --- | --- |
| Web console unavailable | Local `/health`, then proxy `/health` | Process, listener, firewall, proxy, or static assets |
| `/health` works but `/ready` is 503 | Service logs, database path, disk space, ownership | SQLite or data filesystem |
| Login loops or client IP is wrong | Forwarded headers and `trusted_proxies` | Reverse-proxy trust |
| Live logs stop updating | Nginx response buffering and timeout | SSE proxy path |
| Relay upload stalls or proxy disk fills | Request buffering and body-size limit | Reverse proxy |
| Agent offline | Agent service logs, final Master URL, proxy, DNS, CA | Agent-to-Master path |
| Backup starts but fails | Record log, source path, native database tool | Task runner or permissions |
| Restore fails | Record log, destination mount and write access | Storage read or destination permissions |
## Collect status without secrets
Docker Master:
~~~bash
docker compose ps
docker compose logs --tail=200 backupx
curl -i http://127.0.0.1:8340/health
curl -i http://127.0.0.1:8340/ready
~~~
Bare-metal Master:
~~~bash
sudo systemctl status backupx --no-pager
sudo journalctl -u backupx -n 200 --no-pager
sudo ss -lntp | grep 8340
curl -i http://127.0.0.1:8340/health
curl -i http://127.0.0.1:8340/ready
~~~
Systemd Agent:
~~~bash
sudo systemctl status backupx-agent --no-pager
sudo journalctl -u backupx-agent -n 200 --no-pager
sudo systemctl status backupx-agent-tunnel --no-pager
~~~
The tunnel command is relevant only to bastion deployments. Before sharing output, remove Authorization headers, API keys, Agent tokens, install URLs, database passwords, storage credentials, proxy credentials, and private paths that reveal sensitive topology.
## Web console or first setup
Check the unauthenticated setup endpoint:
~~~bash
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
~~~
If the API works but the browser receives a blank page or JSON:
- Confirm the release contains web assets.
- Bare metal: verify `/opt/backupx/web` is readable and `server.web_root` is correct when explicitly set.
- Docker: confirm the official image is running and no custom mount hides the packaged web directory.
- Nginx static mode: confirm `root /opt/backupx/web` and SPA fallback are present.
- Clear an old service-worker or browser cache after a release change.
For authentication failures, verify system time before diagnosing TOTP or passkeys. Confirm the browser origin matches the final HTTPS host, and inspect the audit log for throttling, disabled users, or revoked trusted devices.
## Reverse proxy
Validate and reload Nginx:
~~~bash
sudo nginx -t
sudo systemctl reload nginx
curl -i https://backup.example.com/health
curl -i https://backup.example.com/ready
~~~
Common corrections:
- HTTP 413: set `client_max_body_size 0` for the API route.
- Relay uploads fill proxy temporary storage: set `proxy_request_buffering off`.
- SSE logs arrive in bursts or disconnect: set `proxy_buffering off`, disable proxy cache, and increase read timeout.
- One-click installer returns HTML: proxy `/api/` and retain the legacy `/install/` route.
- Agent receives a redirect: configure the final HTTPS Master URL instead of an HTTP URL.
- Audit shows the proxy address for every user: add only the real proxy IP or subnet to `server.trusted_proxies`.
Use the complete [Nginx configuration](../deployment/nginx) as the comparison baseline.
## Agent offline
An Agent normally heartbeats every 15 seconds and is marked offline after 45 seconds.
1. Confirm the Agent and optional tunnel services are active.
2. Verify the configured Master URL has no trailing redirect and resolves from the Agent host.
3. Check the explicit `proxyUrl`. Use `socks5h://` when DNS must resolve through an SSH dynamic tunnel.
4. Confirm the private CA path exists and is readable. Do not switch permanently to insecure TLS.
5. Check outbound firewall access to the Master and assigned storage backends.
6. Verify `/etc/backupx-agent/agent.token` exists with mode `0600`.
7. If a token was rotated, install the new value during the overlap window and restart the Agent.
Do not paste the token into a diagnostic command that will be saved in shell history. A 401 in Agent logs usually indicates a missing, expired-overlap, or mismatched node token; repeated connection errors indicate URL, DNS, proxy, tunnel, firewall, or CA problems.
## Backup task failures
Open the backup record and inspect its complete log before changing the task.
- File tasks resolve paths on the selected Master or Agent. Confirm the path exists in that host's namespace.
- Docker sees only mounted paths. Backup mounts should normally be read-only.
- MySQL requires `mysqldump` on the execution host's `PATH`.
- PostgreSQL requires `pg_dump` on the execution host's `PATH`.
- SAP HANA runner mode requires its configured client tools and environment.
- Confirm the service account can read sources and write the temporary directory.
- Test the selected storage target from the console.
- Check DNS, egress policy, provider quota, clock skew, and proxy settings for remote storage.
If multiple targets are configured, inspect the per-target result instead of assuming every copy failed. Preserve successful remote artifacts while correcting the failing target.
## Restore, download, or verification failures
- Confirm the remote artifact still exists and the storage credentials can read it.
- Check that the destination is mounted on the host that performs the restore.
- Use a separate writable restore path; do not make every backup-source mount writable.
- Check free space in the destination and Agent temporary directory.
- For encrypted backups, confirm the original Master encryption key is available.
- For CDC repositories, keep manifests, indexes, and shared packs together; a manifest alone is not a complete backup.
Prefer an isolated restore destination during diagnosis. Do not repeatedly restore over the production source.
## SQLite and readiness failures
When `/health` is 200 but `/ready` is 503:
1. Read the exact database error from service logs.
2. Check free disk space, inode availability, path ownership, and mount state.
3. Confirm only one Master process or container uses the data directory.
4. Keep SQLite on a local or block-backed filesystem, not a shared multi-writer or unreliable network filesystem.
5. Check whether an external backup or antivirus process is holding files for long periods.
BackupX uses a five-second SQLite busy timeout, but that does not make SQLite a clustered database. Do not fix lock errors by starting another Master. For a file-level copy, stop the service and copy the whole data directory.
## Escalation package
When opening an issue, include:
- BackupX version, installation method, operating system, and architecture.
- Whether the failure affects the Master, Agent, proxy, storage target, or one task.
- Redacted service logs covering the first failure.
- HTTP status and response body from `/health` and `/ready`.
- A minimal reproduction and whether it began after an upgrade or configuration change.
- Relevant proxy configuration with hostnames, credentials, and private addresses redacted.
Never attach `backupx.db`, `.env`, full configuration files, Agent token files, API keys, install commands, or storage credentials to a public issue.
If integrity or rollback is involved, stop making destructive changes and follow [Upgrade and Recovery](./upgrade-recovery).

View File

@@ -1,153 +0,0 @@
---
sidebar_position: 1
title: Upgrade and Recovery
description: Back up the control plane, upgrade safely, roll back as a unit, and recover a failed Master.
---
# Upgrade and Recovery
Backup artifacts and the BackupX control plane are different recovery domains. Object storage may still contain every archive while a lost Master database removes users, encrypted storage credentials, schedules, records, node tokens, and audit history. Protect both.
## Non-negotiable rules
1. Run exactly one active Master against a data directory or SQLite database.
2. Snapshot the complete data directory and configuration while the Master is stopped, or use a storage-level atomic snapshot.
3. Keep the old application version and its pre-upgrade data snapshot together. Schema migration happens at startup, so switching only the binary or image back is not a safe rollback.
4. Store control-plane snapshots outside the Master host and test restoring them.
5. Let active backup and restore jobs finish before stopping the Master.
| Deployment | Persistent control-plane data | Configuration and release state |
| --- | --- | --- |
| Docker | `/app/data` in the `backupx-data` volume | Compose file, protected `.env`, pinned image tag or digest |
| Bare metal | `/opt/backupx/data` | `/etc/backupx`, `/opt/backupx/bin`, `/opt/backupx/web`, systemd unit |
The SQLite database contains generated JWT and encryption keys when they are not supplied in configuration. Treat every control-plane snapshot as a secret.
## Change checklist
Before an upgrade, host migration, or security-key change:
- Record the current BackupX version and the exact image digest or release checksum.
- Confirm `/ready` returns HTTP 200 and review recent failures.
- Wait for running backup, restore, verification, and replication work to finish.
- Test at least one storage target and confirm Agents are online.
- Create a full control-plane snapshot and copy it off-host.
- Optionally export task definitions for human review. Task export excludes database passwords and storage credentials, so it is not a replacement for the database snapshot.
- Define the rollback decision and maintenance-window deadline before starting.
## Snapshot a Docker deployment
This example creates a consistent file-level copy without requiring access to Docker's volume directory:
~~~bash
snapshot="backupx-control-plane-$(date -u +%Y%m%dT%H%M%SZ)"
install -d -m 0700 "$snapshot"
docker compose stop backupx
docker cp backupx:/app/data "$snapshot/data"
cp docker-compose.yml "$snapshot/"
if [ -f .env ]; then cp .env "$snapshot/"; fi
docker compose start backupx
tar -czf "$snapshot.tar.gz" "$snapshot"
sha256sum "$snapshot.tar.gz" > "$snapshot.tar.gz.sha256"
curl -fsS http://127.0.0.1:8340/ready
~~~
If copying fails, start the stopped service before investigating. Protect the archive because `.env` and the database can contain credentials. A block-volume or storage-provider snapshot is also valid when it is atomic across the whole volume.
## Snapshot a bare-metal deployment
~~~bash
snapshot="/var/backups/backupx/backupx-control-plane-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"
sudo install -d -m 0700 /var/backups/backupx
sudo systemctl stop backupx
sudo tar --acls --xattrs -C / -czf "$snapshot" \
etc/backupx \
etc/systemd/system/backupx.service \
opt/backupx/bin \
opt/backupx/web \
opt/backupx/data
sudo systemctl start backupx
sudo sha256sum "$snapshot" | sudo tee "$snapshot.sha256"
curl -fsS http://127.0.0.1:8340/ready
~~~
Copy the archive and checksum to protected off-host storage. Do not copy only `backupx.db` while the service is running.
## Upgrade Docker
1. Put a release tag or immutable digest in `BACKUPX_IMAGE`. Do not use `latest` for a controlled production upgrade.
2. Create and verify the pre-upgrade snapshot.
3. Pull and recreate the service:
~~~bash
docker compose pull backupx
docker compose up -d backupx
docker compose ps
docker compose logs --tail=100 backupx
curl -fsS http://127.0.0.1:8340/ready
~~~
4. Sign in, test a storage target, confirm Agent heartbeats, and run one small backup plus a restore or verification drill.
5. Keep the old image reference and snapshot until the observation window ends.
Upgrade Agents after the Master, in small batches. Keep the node-specific proxy, private-CA, token-file, and bastion configuration unchanged unless that configuration is the purpose of the change.
## Upgrade bare metal
Download the target release and checksum, verify them, then extract the archive. The installer preserves an existing `/etc/backupx/config.yaml`, replaces the binary, web assets, and systemd unit, and restarts the service.
~~~bash
sha256sum -c backupx-vX.Y.Z-linux-amd64.tar.gz.sha256
tar xzf backupx-vX.Y.Z-linux-amd64.tar.gz
cd backupx-vX.Y.Z-linux-amd64
sudo ./install.sh
sudo systemctl status backupx --no-pager
curl -fsS http://127.0.0.1:8340/ready
~~~
Create the stopped-service snapshot before running the installer. Use the same post-upgrade application checks as Docker.
## Roll back
Rollback is a paired operation: restore both the previous application release and the snapshot created immediately before the upgrade.
For Docker, preserve the failed volume for analysis and restore the snapshot into a new empty volume. Point Compose at that volume and the previous image tag, then start exactly one Master. For bare metal, stop the service, preserve the failed state, restore the old configuration, binary, web assets, data directory, and unit from the same archive, reload systemd, and start the service.
After rollback:
~~~bash
curl -fsS http://127.0.0.1:8340/health
curl -fsS http://127.0.0.1:8340/ready
~~~
Then verify login, storage access, schedules, Agent heartbeats, a backup, and a non-destructive restore drill. Do not delete the failed state until the incident is understood.
## Recover a lost Master
1. Provision a replacement host with the same architecture and the exact application version recorded with the snapshot.
2. Keep the replacement isolated from production traffic and ensure the old Master cannot start.
3. Restore configuration and the complete data directory with their original permissions.
4. Start one Master and check `/ready` locally.
5. Move the stable DNS name or virtual IP only after local validation.
6. Confirm users, storage targets, tasks, records, notifications, and audit history.
7. Existing Agents reconnect automatically when the restored database contains their matching tokens. Investigate and rotate tokens that may have been exposed.
8. Run a small backup and a restore or verification drill before ending the incident.
External backup artifacts are not recreated by restoring the control plane; they remain on their configured storage targets. Conversely, task JSON export is useful for rebuilding schedules but omits secrets, storage definitions, and some node bindings. Use it only as an additional recovery aid.
## Test the recovery plan
At least quarterly, restore a recent snapshot into an isolated network, start the recorded BackupX version, and verify:
- `/ready` becomes healthy without contacting the production Master.
- An administrator can sign in and encrypted storage configurations can be read.
- Task, node, record, and audit counts are plausible.
- A storage target can be tested without writing production data.
- A selected backup can be verified or restored to an isolated destination.
Record restore duration and the newest recoverable snapshot time. Those measured values are the real control-plane RTO and RPO.

View File

@@ -1,268 +1,135 @@
---
sidebar_position: 1
title: API Reference
description: BackupX REST endpoints, authentication methods, role boundaries, streaming responses, and public probes.
description: REST API endpoints — all under /api with JWT Bearer authentication.
---
# API Reference
The interactive API is rooted at `/api`. Most endpoints accept either a user JWT or an API key; Agent protocol endpoints use a node-specific token. Public probes and one-time installers are listed separately.
All endpoints are prefixed with `/api` and authenticated with a JWT Bearer token, obtained via `POST /api/auth/login`. Agent endpoints use `X-Agent-Token` instead.
## Authentication
### User JWT
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/auth/setup/status` | Check whether admin initialization is needed |
| `POST` | `/api/auth/setup` | Initialize the first admin (only when no user exists) |
| `POST` | `/api/auth/login` | Log in and receive a JWT |
| `POST` | `/api/auth/logout` | Log out (invalidate current token) |
| `GET` | `/api/auth/profile` | Current user profile |
| `PUT` | `/api/auth/password` | Change password |
Obtain a JWT through `POST /api/auth/login` and send it as a Bearer token:
## Backup Tasks
~~~bash
curl -H "Authorization: Bearer $BACKUPX_TOKEN" \
https://backup.example.com/api/backup/tasks
~~~
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/backup/tasks` | List tasks |
| `POST` | `/api/backup/tasks` | Create |
| `GET` | `/api/backup/tasks/:id` | Detail |
| `PUT` | `/api/backup/tasks/:id` | Update |
| `DELETE` | `/api/backup/tasks/:id` | Delete |
| `PUT` | `/api/backup/tasks/:id/toggle` | Enable / disable |
| `POST` | `/api/backup/tasks/:id/run` | Trigger a manual run |
The login flow may require OTP, TOTP, recovery code, a trusted-device token, or WebAuthn depending on account and system settings.
## Backup Records
### API key
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/backup/records` | List records with filters |
| `GET` | `/api/backup/records/:id` | Record detail |
| `GET` | `/api/backup/records/:id/logs/stream` | Live logs (SSE) |
| `GET` | `/api/backup/records/:id/download` | Download the artifact |
| `POST` | `/api/backup/records/:id/restore` | Restore to the original source |
| `DELETE` | `/api/backup/records/:id` | Delete a record |
| `POST` | `/api/backup/records/batch-delete` | Bulk delete |
An administrator creates API keys in the console or through `POST /api/api-keys`. The plaintext `bax_...` value is returned only once.
## Storage Targets
~~~bash
curl -H "X-Api-Key: $BACKUPX_API_KEY" \
https://backup.example.com/api/dashboard/stats
~~~
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/storage-targets` | List |
| `POST` | `/api/storage-targets` | Create |
| `GET` | `/api/storage-targets/:id` | Detail |
| `PUT` | `/api/storage-targets/:id` | Update |
| `DELETE` | `/api/storage-targets/:id` | Delete |
| `POST` | `/api/storage-targets/test` | Test connection with pending config |
| `POST` | `/api/storage-targets/:id/test` | Re-test a saved target |
| `PUT` | `/api/storage-targets/:id/star` | Toggle favourite |
| `GET` | `/api/storage-targets/:id/usage` | Query remote usage (where supported) |
| `GET` | `/api/storage-targets/rclone/backends` | List all available rclone backends |
| `POST` | `/api/storage-targets/google-drive/auth-url` | Start Google Drive OAuth |
| `POST` | `/api/storage-targets/google-drive/complete` | Complete OAuth flow |
`Authorization: Bearer bax_...` is also accepted. API keys carry an `admin`, `operator`, or `viewer` role and can be disabled or given an expiry.
## Nodes (Cluster)
### Agent token
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/nodes` | List nodes |
| `POST` | `/api/nodes` | Create a node and return its token |
| `GET` | `/api/nodes/:id` | Node detail |
| `PUT` | `/api/nodes/:id` | Rename |
| `DELETE` | `/api/nodes/:id` | Delete (rejected if tasks are still attached) |
| `GET` | `/api/nodes/:id/fs/list` | Browse a directory (remote nodes use an async RPC via Agent) |
Agent protocol handlers authenticate the node token supplied in `X-Agent-Token`. This token is not a user credential and must not be used with the interactive resource API.
## Agent Protocol (X-Agent-Token)
### Access labels
Dedicated endpoints for the Agent CLI. Authenticated via the `X-Agent-Token` header instead of JWT.
The tables use these labels:
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/agent/heartbeat` | Report liveness; returns the node ID |
| `POST` | `/api/agent/commands/poll` | Claim one pending command |
| `POST` | `/api/agent/commands/:id/result` | Report command result |
| `GET` | `/api/agent/tasks/:id` | Fetch task spec with decrypted storage configs |
| `POST` | `/api/agent/records/:id` | Append logs / update record status |
| Label | Required access |
| --- | --- |
| Public | No JWT or API key; an install route still requires its one-time token |
| Auth | Any authenticated `viewer`, `operator`, or `admin` |
| Operator | `operator` or `admin` |
| Admin | `admin` only |
| Agent | Valid node-specific Agent token |
## Notifications
Viewers can use read endpoints except node filesystem browsing. Operators can run and mutate backup resources. Administrators additionally manage users, API keys, settings, nodes, install tokens, and node-token rotation. A rejected role returns HTTP 403.
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/notifications` | List |
| `POST` | `/api/notifications` | Create |
| `GET` | `/api/notifications/:id` | Detail |
| `PUT` | `/api/notifications/:id` | Update |
| `DELETE` | `/api/notifications/:id` | Delete |
| `POST` | `/api/notifications/test` | Test with pending config |
| `POST` | `/api/notifications/:id/test` | Re-test a saved notifier |
## Authentication and account security
## Dashboard
| Method | Endpoint | Access | Description |
| --- | --- | --- | --- |
| `GET` | `/api/auth/setup/status` | Public | Check whether first-admin setup is required |
| `POST` | `/api/auth/setup` | Public | Create the first administrator when no user exists |
| `POST` | `/api/auth/login` | Public | Complete password or MFA login and obtain a JWT |
| `POST` | `/api/auth/otp/send` | Public | Send a configured login OTP |
| `POST` | `/api/auth/webauthn/login/options` | Public | Begin passkey login |
| `POST` | `/api/auth/logout` | Auth | Acknowledge logout; the client must discard its stateless JWT |
| `GET` | `/api/auth/profile` | Auth | Read the current account |
| `PUT` | `/api/auth/password` | Auth | Change the current account password |
| `POST` | `/api/auth/2fa/setup` | Auth | Prepare TOTP enrollment |
| `POST` | `/api/auth/2fa/enable` | Auth | Enable TOTP after verification |
| `POST` | `/api/auth/2fa/recovery-codes` | Auth | Regenerate recovery codes |
| `DELETE` | `/api/auth/2fa` | Auth | Disable TOTP |
| `PUT` | `/api/auth/otp/config` | Auth | Update OTP login configuration |
| `POST` | `/api/auth/webauthn/register/options` | Auth | Begin passkey registration |
| `POST` | `/api/auth/webauthn/register/finish` | Auth | Finish passkey registration |
| `GET` | `/api/auth/webauthn/credentials` | Auth | List passkeys |
| `DELETE` | `/api/auth/webauthn/credentials/:id` | Auth | Delete a passkey |
| `GET` | `/api/auth/trusted-devices` | Auth | List trusted devices |
| `DELETE` | `/api/auth/trusted-devices/:id` | Auth | Revoke a trusted device |
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/dashboard/stats` | Overview statistics |
| `GET` | `/api/dashboard/timeline` | Recent activity timeline |
Use an interactive JWT, not an automation API key, for account-security endpoints.
## Audit / System / Settings
## System and storage targets
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/audit-logs` | Audit log list |
| `GET` | `/api/system/info` | System information |
| `GET` | `/api/system/update-check` | Check for a newer release |
| `GET` | `/api/settings` | System-level settings |
| `PUT` | `/api/settings` | Update system settings |
| Method | Endpoint | Access | Description |
| --- | --- | --- | --- |
| `GET` | `/api/system/info` | Auth | Version and system information |
| `GET` | `/api/system/update-check` | Auth | Check available releases |
| `GET` | `/api/storage-targets` | Auth | List storage targets |
| `POST` | `/api/storage-targets` | Operator | Create a target |
| `POST` | `/api/storage-targets/test` | Operator | Test an unsaved configuration |
| `GET` | `/api/storage-targets/rclone/backends` | Auth | List available rclone backends |
| `POST` | `/api/storage-targets/google-drive/auth-url` | Operator | Start Google Drive authorization |
| `POST` | `/api/storage-targets/google-drive/complete` | Operator | Complete Google Drive authorization |
| `GET` | `/api/storage-targets/google-drive/callback` | Auth | Handle the OAuth callback |
| `GET` | `/api/storage-targets/:id` | Auth | Read a target |
| `PUT` | `/api/storage-targets/:id` | Operator | Update a target |
| `DELETE` | `/api/storage-targets/:id` | Operator | Delete a target |
| `PUT` | `/api/storage-targets/:id/star` | Operator | Toggle favorite state |
| `POST` | `/api/storage-targets/:id/test` | Operator | Test a saved target |
| `GET` | `/api/storage-targets/:id/usage` | Auth | Read recorded usage |
| `GET` | `/api/storage-targets/:id/google-drive/profile` | Auth | Read the connected Google Drive profile |
## Response Envelope
## Backup tasks
All successful responses follow the shape:
| Method | Endpoint | Access | Description |
| --- | --- | --- | --- |
| `GET` | `/api/backup/tasks` | Auth | List tasks |
| `GET` | `/api/backup/tasks/tags` | Auth | List task tags |
| `GET` | `/api/backup/tasks/export` | Auth | Download all task definitions, or select them with `?ids=1,2` |
| `POST` | `/api/backup/tasks/import` | Operator | Import task definitions, up to 1 MiB |
| `POST` | `/api/backup/tasks/batch/toggle` | Operator | Enable or disable tasks in bulk |
| `POST` | `/api/backup/tasks/batch/delete` | Operator | Delete tasks in bulk |
| `POST` | `/api/backup/tasks/batch/run` | Operator | Run tasks in bulk |
| `GET` | `/api/backup/tasks/:id` | Auth | Read a task |
| `POST` | `/api/backup/tasks` | Operator | Create a task |
| `PUT` | `/api/backup/tasks/:id` | Operator | Update a task |
| `DELETE` | `/api/backup/tasks/:id` | Operator | Delete a task |
| `PUT` | `/api/backup/tasks/:id/toggle` | Operator | Enable or disable a task |
| `POST` | `/api/backup/tasks/:id/run` | Operator | Trigger a backup |
| `POST` | `/api/backup/tasks/:id/verify` | Operator | Trigger verification from a task |
Task export intentionally excludes database passwords and storage credentials. It is useful for migration and review, not a complete control-plane backup.
## Backup and restore records
| Method | Endpoint | Access | Description |
| --- | --- | --- | --- |
| `GET` | `/api/backup/records` | Auth | List and filter backup records |
| `POST` | `/api/backup/records/batch-delete` | Operator | Delete records in bulk |
| `GET` | `/api/backup/records/:id` | Auth | Read a backup record |
| `GET` | `/api/backup/records/:id/logs/stream` | Auth | Stream logs with server-sent events |
| `GET` | `/api/backup/records/:id/download` | Auth | Download an artifact |
| `GET` | `/api/backup/records/:id/contents` | Auth | Browse artifact contents where supported |
| `POST` | `/api/backup/records/:id/restore` | Operator | Start a restore |
| `POST` | `/api/backup/records/:id/replicate` | Operator | Replicate an existing artifact |
| `POST` | `/api/backup/records/:id/verify` | Operator | Verify an existing artifact |
| `PUT` | `/api/backup/records/:id/lock` | Operator | Set retention lock state |
| `DELETE` | `/api/backup/records/:id` | Operator | Delete a record and its managed artifact |
| `GET` | `/api/restore/records` | Auth | List restore records |
| `GET` | `/api/restore/records/:id` | Auth | Read a restore record |
| `GET` | `/api/restore/records/:id/logs/stream` | Auth | Stream restore logs |
| `GET` | `/api/replication/records` | Auth | List replication records |
| `GET` | `/api/replication/records/:id` | Auth | Read a replication record |
| `GET` | `/api/verify/records` | Auth | List verification records |
| `GET` | `/api/verify/records/:id` | Auth | Read a verification record |
| `GET` | `/api/verify/records/:id/logs/stream` | Auth | Stream verification logs |
## Templates, reports, and dashboard
| Method | Endpoint | Access | Description |
| --- | --- | --- | --- |
| `GET` | `/api/task-templates` | Auth | List task templates |
| `GET` | `/api/task-templates/:id` | Auth | Read a task template |
| `POST` | `/api/task-templates` | Operator | Create a template |
| `PUT` | `/api/task-templates/:id` | Operator | Update a template |
| `DELETE` | `/api/task-templates/:id` | Operator | Delete a template |
| `POST` | `/api/task-templates/:id/apply` | Operator | Create tasks from a template |
| `GET` | `/api/reports/compliance` | Auth | Read compliance evidence |
| `GET` | `/api/reports/compliance/export` | Auth | Export compliance evidence as CSV |
| `GET` | `/api/dashboard/stats` | Auth | Summary statistics |
| `GET` | `/api/dashboard/timeline` | Auth | Recent activity |
| `GET` | `/api/dashboard/sla` | Auth | RPO and SLA status |
| `GET` | `/api/dashboard/cluster` | Auth | Cluster summary |
| `GET` | `/api/dashboard/breakdown` | Auth | Task and record breakdown |
| `GET` | `/api/dashboard/node-performance` | Auth | Per-node performance |
## Notifications, settings, and administration
| Method | Endpoint | Access | Description |
| --- | --- | --- | --- |
| `GET` | `/api/notifications` | Auth | List notification channels |
| `GET` | `/api/notifications/:id` | Auth | Read a channel |
| `POST` | `/api/notifications` | Operator | Create a channel |
| `PUT` | `/api/notifications/:id` | Operator | Update a channel |
| `DELETE` | `/api/notifications/:id` | Operator | Delete a channel |
| `POST` | `/api/notifications/test` | Operator | Test an unsaved configuration |
| `POST` | `/api/notifications/:id/test` | Operator | Test a saved channel |
| `GET` | `/api/settings` | Auth | Read system settings |
| `PUT` | `/api/settings` | Admin | Update system settings |
| `GET` | `/api/users` | Admin | List users |
| `POST` | `/api/users` | Admin | Create a user |
| `PUT` | `/api/users/:id` | Admin | Update a user |
| `POST` | `/api/users/:id/2fa/reset` | Admin | Reset a user's second factor |
| `DELETE` | `/api/users/:id` | Admin | Delete a user |
| `GET` | `/api/api-keys` | Admin | List API keys without plaintext values |
| `POST` | `/api/api-keys` | Admin | Create an API key and return its plaintext once |
| `PUT` | `/api/api-keys/:id/toggle` | Admin | Enable or disable an API key |
| `DELETE` | `/api/api-keys/:id` | Admin | Revoke an API key |
## Audit, events, search, and discovery
| Method | Endpoint | Access | Description |
| --- | --- | --- | --- |
| `GET` | `/api/audit-logs` | Auth | List and filter audit records |
| `GET` | `/api/audit-logs/export` | Auth | Export audit records |
| `GET` | `/api/events/stream` | Auth | Stream real-time application events with SSE |
| `GET` | `/api/search` | Auth | Search supported resources |
| `POST` | `/api/database/discover` | Auth | Discover databases from supplied connection details |
## Nodes
| Method | Endpoint | Access | Description |
| --- | --- | --- | --- |
| `GET` | `/api/nodes` | Auth | List nodes |
| `GET` | `/api/nodes/:id` | Auth | Read a node |
| `GET` | `/api/nodes/:id/fs/list` | Operator | Browse the selected node filesystem |
| `POST` | `/api/nodes` | Admin | Create a node |
| `POST` | `/api/nodes/batch` | Admin | Create up to 50 nodes |
| `PUT` | `/api/nodes/:id` | Admin | Update a node |
| `DELETE` | `/api/nodes/:id` | Admin | Delete an unreferenced node |
| `POST` | `/api/nodes/:id/install-tokens` | Admin | Create a one-time installer |
| `GET` | `/api/nodes/:id/install-script-preview` | Admin | Preview generated install material |
| `POST` | `/api/nodes/:id/rotate-token` | Admin | Rotate the long-lived node token |
## Agent protocol
These routes are for the `backupx agent` process and authenticate inside the handler with the node token.
| Method | Endpoint | Access | Description |
| --- | --- | --- | --- |
| `POST` | `/api/agent/heartbeat` | Agent | Report liveness and node state |
| `POST` | `/api/agent/commands/poll` | Agent | Claim a pending command |
| `POST` | `/api/agent/commands/:id/result` | Agent | Report a command result |
| `GET` | `/api/agent/tasks/:id` | Agent | Fetch a runnable task specification |
| `POST` | `/api/agent/records/:id` | Agent | Append logs or update backup state |
| `PUT` | `/api/agent/records/:id/artifacts/:targetId` | Agent | Stream a relayed artifact to the Master |
| `GET` | `/api/agent/restores/:id/spec` | Agent | Fetch restore instructions |
| `GET` | `/api/agent/restores/:id/artifact` | Agent | Stream a restore artifact |
| `POST` | `/api/agent/restores/:id` | Agent | Update restore state |
| `GET` | `/api/v1/agent/self` | Agent | Validate node identity during installation |
## Public operational and install routes
| Method | Endpoint | Access | Description |
| --- | --- | --- | --- |
| `GET` | `/health` | Public | Liveness |
| `GET` | `/api/health` | Public | API-prefixed liveness alias |
| `GET` | `/ready` | Public | SQLite readiness |
| `GET` | `/api/ready` | Public | API-prefixed readiness alias |
| `GET` | `/metrics` | Public | Prometheus metrics |
| `GET` | `/install/:token` | Public | Consume a one-time Agent installer token |
| `GET` | `/api/install/:token` | Public | API-prefixed installer route |
| `GET` | `/install/:token/compose.yml` | Public | Render a Docker Agent Compose file |
| `GET` | `/api/install/:token/compose.yml` | Public | API-prefixed Docker Compose route |
Restrict probes and metrics to monitoring networks. Install tokens are single-use, time-limited secrets and must not be written to public logs.
## Response formats
Most JSON successes use:
~~~json
```json
{
"code": "OK",
"message": "success",
"data": {}
"message": "",
"data": { /* actual payload */ }
}
~~~
```
Errors use an HTTP 4xx or 5xx status plus a stable application code:
Errors return an HTTP 4xx/5xx plus:
~~~json
```json
{
"code": "BACKUP_TASK_NOT_FOUND",
"message": "备份任务不存在"
"message": "备份任务不存在",
"data": null
}
~~~
Clients should branch on the HTTP status and `code`, not the localized `message`.
Artifact downloads, task JSON export, audit or compliance exports, installer responses, and `/metrics` return their native content types instead of the JSON envelope. Log and event streams use `text/event-stream`; reverse proxies must keep response buffering disabled.
```

View File

@@ -17,17 +17,15 @@ backupx --version
| Flag | Description |
|------|-------------|
| `--config <path>` | Explicit config YAML path; omitted uses the search paths below |
| `--config <path>` | Path to config YAML (default: `./config.yaml`) |
| `--version` | Print version and exit |
When `--config` is omitted, the server searches `./config.yaml`, `./server/config.yaml`, and `/etc/backupx/config.yaml`. `BACKUPX_*` environment variables override matching server configuration keys. See [Configuration Reference](../deployment/configuration).
## `backupx agent`
Run in Agent mode, connecting to a Master. See [Multi-Node Cluster](../features/multi-node).
```bash
backupx agent --master https://backup.example.com --token-file /etc/backupx-agent/agent.token
backupx agent --master http://master:8340 --token <token>
```
| Flag | Description |
@@ -35,15 +33,13 @@ backupx agent --master https://backup.example.com --token-file /etc/backupx-agen
| `--master <url>` | Master URL |
| `--token <token>` | Agent auth token |
| `--token-file <path>` | Read the Agent Token from a file; preferred for services and containers |
| `--config <path>` | Load Agent YAML; when present, environment-based Agent config is not loaded |
| `--temp-dir <path>` | Local temp directory (default `/var/lib/backupx-agent/tmp`) |
| `--config <path>` | YAML config (takes precedence over env) |
| `--temp-dir <path>` | Local temp directory (default `/tmp/backupx-agent`) |
| `--proxy-url <url>` | Explicit HTTP(S) or SOCKS5(H) proxy |
| `--ca-cert <path>` | PEM CA certificate used to verify the Master |
| `--insecure-tls` | Skip TLS verification (testing only) |
Agent precedence is explicit CLI flags over a YAML file. If `--config` is not supplied, Agent settings are loaded from `BACKUPX_AGENT_MASTER`, `BACKUPX_AGENT_TOKEN`, `BACKUPX_AGENT_TOKEN_FILE`, `BACKUPX_AGENT_HEARTBEAT`, `BACKUPX_AGENT_POLL`, `BACKUPX_AGENT_TEMP_DIR`, `BACKUPX_AGENT_PROXY_URL`, `BACKUPX_AGENT_CA_CERT_FILE`, and `BACKUPX_AGENT_INSECURE_TLS`. When no explicit proxy URL is set, the Agent also honors `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`.
`--token` overrides `--token-file`. Keep long-lived tokens in a root-readable file rather than command history. A private CA and `--insecure-tls` cannot be enabled together.
Environment variables: `BACKUPX_AGENT_MASTER`, `BACKUPX_AGENT_TOKEN`, `BACKUPX_AGENT_TOKEN_FILE`, `BACKUPX_AGENT_HEARTBEAT`, `BACKUPX_AGENT_POLL`, `BACKUPX_AGENT_TEMP_DIR`, `BACKUPX_AGENT_PROXY_URL`, `BACKUPX_AGENT_CA_CERT_FILE`, `BACKUPX_AGENT_INSECURE_TLS`. When no explicit proxy URL is set, the Agent also honors `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`.
## `backupx backint`
@@ -61,8 +57,6 @@ backupx backint -f <function> -i <input> -o <output> -p <params>
| `-p <path>` | Parameter file |
| `-u / -c / -l / -v` | Accepted and ignored for SAP compatibility |
The `-p` file must define `STORAGE_TYPE` and either `STORAGE_CONFIG_JSON` or `STORAGE_CONFIG`. Optional keys include `PARALLEL_FACTOR`, `COMPRESS`, `LOG_FILE`, `CATALOG_DB`, and `KEY_PREFIX`.
## `backupx reset-password`
Reset an admin password directly in the SQLite database. No server restart needed.
@@ -76,5 +70,3 @@ backupx reset-password --username admin --password 'newpass123' [--config /path/
| `--username` | Target username (default: `admin`) |
| `--password` | New password (min 8 chars, required) |
| `--config` | Config path (used to locate the database file) |
Run this command on the Master host with access to the configured SQLite path. Avoid placing the new password directly in retained shell history.

View File

@@ -21,10 +21,10 @@ const config: Config = {
deploymentBranch: 'gh-pages',
trailingSlash: false,
onBrokenLinks: 'throw',
onBrokenLinks: 'warn',
markdown: {
hooks: {
onBrokenMarkdownLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
},
},
@@ -33,10 +33,7 @@ const config: Config = {
locales: ['en', 'zh-Hans'],
localeConfigs: {
en: {label: 'English', direction: 'ltr', htmlLang: 'en-US'},
// Keep the published /zh-Hans/ URL while loading the existing zh-CN
// translation tree. Without path, Docusaurus silently falls back to the
// English documents because i18n/zh-Hans does not exist.
'zh-Hans': {label: '简体中文', direction: 'ltr', htmlLang: 'zh-CN', path: 'zh-CN'},
'zh-Hans': {label: '简体中文', direction: 'ltr', htmlLang: 'zh-CN'},
},
},
@@ -47,7 +44,6 @@ const config: Config = {
docs: {
sidebarPath: './sidebars.ts',
editUrl: 'https://github.com/Awuqing/BackupX/edit/main/docs-site/',
editLocalizedFiles: true,
},
blog: false,
theme: {
@@ -76,24 +72,19 @@ const config: Config = {
label: 'Docs',
},
{
to: '/docs/deployment/docker',
label: 'Deployment',
position: 'left',
},
{
to: '/docs/operations/monitoring',
label: 'Operations',
position: 'left',
},
{
to: '/docs/reference/api',
label: 'API',
href: 'https://github.com/Awuqing/BackupX/releases',
label: 'Downloads',
position: 'left',
},
{
to: '/community',
label: 'Community',
position: 'right',
position: 'left',
},
{
to: '/sponsors',
label: 'Sponsors',
position: 'left',
},
{
type: 'localeDropdown',
@@ -110,27 +101,44 @@ const config: Config = {
style: 'dark',
links: [
{
title: 'Documentation',
title: 'Docs',
items: [
{label: 'Introduction', to: '/docs/intro'},
{label: 'Quick Start', to: '/docs/getting-started/quick-start'},
{label: 'Configuration', to: '/docs/deployment/configuration'},
{label: 'Installation', to: '/docs/getting-started/installation'},
],
},
{
title: 'Operations',
title: 'Features',
items: [
{label: 'Monitoring', to: '/docs/operations/monitoring'},
{label: 'Security', to: '/docs/operations/security'},
{label: 'Troubleshooting', to: '/docs/operations/troubleshooting'},
{label: 'SAP HANA', to: '/docs/features/sap-hana'},
{label: 'Multi-Node Cluster', to: '/docs/features/multi-node'},
{label: 'API Reference', to: '/docs/reference/api'},
],
},
{
title: 'Project',
title: 'More',
items: [
{label: 'GitHub', href: 'https://github.com/Awuqing/BackupX'},
{label: 'Releases', href: 'https://github.com/Awuqing/BackupX/releases'},
{label: 'Community', to: '/community'},
{label: 'Docker Hub', href: 'https://hub.docker.com/r/awuqing/backupx'},
{label: 'Issues', href: 'https://github.com/Awuqing/BackupX/issues'},
],
},
{
title: 'Community',
items: [
{label: 'Contributors', href: 'https://github.com/Awuqing/BackupX/graphs/contributors'},
{label: 'Pull Requests', href: 'https://github.com/Awuqing/BackupX/pulls'},
{label: 'Sponsor', to: '/sponsors'},
],
},
{
title: 'Sponsors',
items: [
{label: 'Sponsor BackupX', href: 'https://github.com/sponsors/Awuqing'},
{label: 'Partnership', href: 'https://github.com/Awuqing/BackupX/issues/new/choose'},
{label: 'Sponsor tiers', to: '/sponsors'},
],
},
],

View File

@@ -1,115 +1,160 @@
{
"home.badge": {"message": "BackupX 文档 · v2.2.1"},
"home.title.part1": {"message": "可靠地运维 BackupX"},
"home.title.part2": {"message": "每一步都有依据。"},
"home.tagline": {"message": "从部署控制平面、连接存储与远程 Agent到持续观测和验证恢复一套务实文档覆盖完整运维路径。"},
"home.pageTitle": {"message": "面向自托管服务器的备份编排"},
"home.getStarted": {"message": "使用 Docker 开始"},
"home.viewSource": {"message": "查看源码"},
"home.supported.label": {"message": "支持的运行环境"},
"home.supported.docker": {"message": "Docker"},
"home.supported.linux": {"message": "Linux"},
"home.supported.windows": {"message": "Windows Agent"},
"home.guide.label": {"message": "推荐文档路径"},
"home.guide.kicker": {"message": "从这里开始"},
"home.guide.hint": {"message": "为下一项工作选择指南"},
"home.guide.install.title": {"message": "安装 BackupX"},
"home.guide.install.desc": {"message": "Docker、Compose 或独立二进制"},
"home.guide.cluster.title": {"message": "连接远程节点"},
"home.guide.cluster.desc": {"message": "Agent、代理、私有 CA 与堡垒机"},
"home.guide.security.title": {"message": "加固运维环境"},
"home.guide.security.desc": {"message": "安全控制、监控与审计记录"},
"home.guide.recovery.title": {"message": "准备恢复方案"},
"home.guide.recovery.desc": {"message": "升级、回滚、恢复与故障排查"},
"home.badge": {
"message": "开源备份控制平面 · v2.2.1",
"description": "Version badge on the hero"
},
"home.title.part1": {
"message": "面向自托管服务器的",
"description": "Hero title, first line"
},
"home.title.part2": {
"message": "备份编排平台。",
"description": "Hero title accent second line"
},
"home.tagline": {
"message": "在一个清爽控制台中管理文件、数据库、SAP HANA 和远程节点备份。控制平面自己掌握,存储后端灵活选择。",
"description": "Tagline on the home page"
},
"home.pageTitle": {
"message": "面向自托管服务器的备份编排",
"description": "Page <title> element on the home page"
},
"home.getStarted": {
"message": "快速开始",
"description": "Primary CTA on the home page"
},
"home.metric.backends": {
"message": "存储后端",
"description": "Hero metric label: storage backends"
},
"home.metric.backupTypes": {
"message": "远程执行",
"description": "Hero metric label: backup types"
},
"home.metric.license": {
"message": "开源协议",
"description": "Hero metric label: license"
},
"home.visual.eyebrow": {"message": "BackupX 控制台"},
"home.visual.title": {"message": "运维概览"},
"home.visual.status": {"message": "健康"},
"home.visual.success": {"message": "成功率"},
"home.visual.nodes": {"message": "活跃节点"},
"home.visual.targets": {"message": "存储目标"},
"home.visual.row1.title": {"message": "PostgreSQL 夜间备份"},
"home.visual.row1.desc": {"message": "加密归档已上传至 S3"},
"home.visual.row2.title": {"message": "SAP HANA 快照"},
"home.visual.row2.desc": {"message": "正在 agent-shanghai-02 上运行"},
"home.visual.row3.title": {"message": "保留策略清理"},
"home.visual.row3.desc": {"message": "下一次执行在 4 小时后"},
"home.command.title": {"message": "使用 Docker 启动"},
"section.features.tag": {"message": "文档路径"},
"section.features.title": {"message": "从部署到恢复,不依赖猜测"},
"section.features.subtitle": {"message": "每条路径都把产品能力与生产环境所需的配置、安全和运维决策直接关联。"},
"feat.install.title": {"message": "安装与升级"},
"feat.install.desc": {"message": "选择 Docker、Compose 或独立二进制,并保持部署过程可重复。"},
"feat.types.title": {"message": "保护文件与数据库"},
"feat.types.desc": {"message": "配置文件、MySQL、PostgreSQL、SQLite 和 SAP HANA 备份工作负载。"},
"feat.storage.title": {"message": "连接存储目标"},
"feat.storage.desc": {"message": "通过一致流程使用原生 Provider 或任意受支持的 rclone 后端。"},
"feat.cluster.title": {"message": "构建远程节点集群"},
"feat.cluster.desc": {"message": "通过代理、私有 CA 或 SSH 堡垒机部署仅出站连接的 Agent。"},
"feat.monitor.title": {"message": "监控日常运行"},
"feat.monitor.desc": {"message": "持续查看任务健康、存储容量、通知、日志和服务就绪状态。"},
"feat.recovery.title": {"message": "按验证过的方案恢复"},
"feat.recovery.desc": {"message": "预先准备升级、回滚点、恢复验证和事件故障排查。"},
"section.features.tag": {
"message": "核心能力",
"description": "FEATURES section tag"
},
"section.features.title": {
"message": "该有的都有,多余的没有",
"description": "Features section title"
},
"section.features.subtitle": {
"message": "备份 Runner、存储 Provider、调度、集群 — 每一块都经过打磨。",
"description": "Features section subtitle"
},
"feat.types.title": {"message": "多种备份类型"},
"feat.types.desc": {"message": "文件与目录(支持多源路径),以及 MySQL、PostgreSQL、SQLite、SAP HANA 统一管理。"},
"feat.storage.title": {"message": "70+ 存储后端"},
"feat.storage.desc": {"message": "内置阿里云 OSS、腾讯云 COS、七牛、S3、Google Drive、WebDAV、FTP以及 SFTP、Azure Blob、Dropbox 等 rclone 后端。"},
"feat.scheduling.title": {"message": "调度与保留策略"},
"feat.scheduling.desc": {"message": "基于 Cron 的可视化调度编辑器,支持按天数/份数自动保留和空目录清理。"},
"feat.cluster.title": {"message": "多节点集群"},
"feat.cluster.desc": {"message": "Master-Agent 采用 Agent 主动出站轮询。支持代理、私有 CA 与 SSH 堡垒机,无需反向连通性。"},
"feat.security.title": {"message": "默认安全"},
"feat.security.desc": {"message": "JWT 认证、bcrypt、AES-256-GCM 加密配置、可选备份加密、完整审计日志。"},
"feat.deploy.title": {"message": "部署轻量"},
"feat.deploy.desc": {"message": "单个静态二进制 + 内嵌 SQLite。Docker 一键启动或裸机 — 零外部依赖。"},
"feat.learnMore": {"message": "了解更多"},
"showcase.tag": {"message": "产品界面"},
"showcase.title": {"message": "部署前先了解完整操作流程"},
"showcase.subtitle": {"message": "每张产品截图都连接到对应指南,说明背后的任务、配置和运行模型。"},
"showcase.tabs.label": {"message": "BackupX 产品界面"},
"showcase.preview.label": {"message": "BackupX 控制台"},
"showcase.title": {"message": "精心打磨的控制台,而非 DIY 脚本"},
"showcase.subtitle": {"message": "每个页面都为运维而生 — 可观测优先,可配置次之。"},
"showcase.tab.dashboard": {"message": "仪表盘"},
"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.dashboard.desc": {"message": "备份成功率、存储使用量、最近执行记录即将触发的计划 — 一页实时数据。"},
"showcase.tasks.title": {"message": "可视化任务编辑器"},
"showcase.tasks.desc": {"message": "通过三步向导配置文件、MySQL、PostgreSQL、SQLiteSAP 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.tasks.desc": {"message": "文件、MySQL、PostgreSQL、SQLiteSAP 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.cta": {"message": "开始阅读文档"},
"community.tag": {"message": "社区"},
"community.pageTitle": {"message": "社区、赞助与贡献者"},
"community.pageDescription": {"message": "反馈真实部署约束、完善文档,或为 BackupX 提交可验证的改进。"},
"community.title": {"message": "开放协作让运维知识持续完善"},
"community.subtitle": {"message": "反馈真实部署约束、完善运行手册,或通过可复现验证提交聚焦的改动。"},
"community.path.kicker": {"message": "贡献路径"},
"community.path.guide": {"message": "贡献指南"},
"community.path.issues.title": {"message": "反馈生产环境问题"},
"community.path.issues.desc": {"message": "提交日志、部署拓扑和恢复预期。"},
"community.path.docs.title": {"message": "完善文档与示例"},
"community.path.docs.desc": {"message": "贡献存储、Agent 和数据库部署指南。"},
"community.path.code.title": {"message": "提交聚焦的 Pull Request"},
"community.path.code.desc": {"message": "保持改动可测试、可审查,并贴合现有架构。"},
"community.contributor.kicker": {"message": "贡献者"},
"community.contributor.all": {"message": "查看全部"},
"community.contributor.source": {"message": "通过 GitHub contributors API 加载,并提供本地回退数据。"},
"community.contributor.botRole": {"message": "自动化贡献者"},
"community.contributor.githubRole": {"message": "GitHub 贡献者"},
"community.contributor.contributions": {"message": "{count} 次贡献"},
"community.sponsor.bandTitle": {"message": "支持长期维护"},
"community.sponsor.bandDesc": {"message": "支持兼容性测试、恢复工作和面向运维者的文档。"},
"community.sponsor.learnMore": {"message": "了解赞助方案"},
"community.sponsor.title": {"message": "支持可靠的备份基础设施"},
"community.sponsor.programDesc": {"message": "赞助将用于测试覆盖、恢复可信度、Provider 兼容性,以及运维人员可以直接应用的文档。"},
"community.pageTitle": {"message": "社区、赞助与贡献者"},
"community.pageDescription": {"message": "赞助 BackupX了解贡献者并找到务实的参与方式。"},
"community.title": {"message": "开放协作,面向长期运维"},
"community.subtitle": {"message": "备份软件的信任来自透明发布、真实部署反馈,以及足够务实的贡献路径。"},
"community.sponsor.kicker": {"message": "赞助商"},
"community.sponsor.wallTitle": {"message": "赞助商"},
"community.sponsor.title": {"message": "支持你依赖的备份基础设施"},
"community.sponsor.cta": {"message": "赞助 BackupX"},
"community.sponsor.openSlot": {"message": "赞助席位开放"},
"community.sponsor.logo.project": {"message": "项目赞助"},
"community.sponsor.logo.cloud": {"message": "云服务伙伴"},
"community.sponsor.logo.object": {"message": "对象存储"},
"community.sponsor.logo.cdn": {"message": "CDN 伙伴"},
"community.sponsor.logo.database": {"message": "数据库伙伴"},
"community.sponsor.logo.security": {"message": "安全审计"},
"community.sponsor.logo.agent": {"message": "远程节点实验室"},
"community.sponsor.logo.docs": {"message": "文档赞助"},
"community.sponsor.logo.release": {"message": "发布赞助"},
"community.sponsor.logo.s3": {"message": "S3 兼容"},
"community.sponsor.logo.webdav": {"message": "WebDAV 伙伴"},
"community.sponsor.logo.sftp": {"message": "SFTP 伙伴"},
"community.sponsor.logo.docker": {"message": "容器伙伴"},
"community.sponsor.logo.mirror": {"message": "镜像伙伴"},
"community.sponsor.logo.restore": {"message": "恢复演练"},
"community.sponsor.logo.qa": {"message": "测试实验室"},
"community.sponsor.logo.oss": {"message": "开源支持"},
"community.sponsor.logo.open": {"message": "赞助席位开放"},
"community.sponsor.infrastructure.label": {"message": "基础设施"},
"community.sponsor.infrastructure.title": {"message": "云与存储兼容性"},
"community.sponsor.infrastructure.desc": {"message": "支持对象存储、WebDAV、SFTP 区域云平台的真实验证。"},
"community.sponsor.security.label": {"message": "可靠性"},
"community.sponsor.security.title": {"message": "安全与恢复工作"},
"community.sponsor.security.desc": {"message": "支持加密审查、恢复演练、发布签名和运维检查。"},
"community.sponsor.infrastructure.title": {"message": "云与存储生态伙伴"},
"community.sponsor.infrastructure.desc": {"message": "帮助 BackupX 覆盖对象存储、WebDAV、SFTP 以及区域云平台的真实验证。"},
"community.sponsor.security.label": {"message": "安全"},
"community.sponsor.security.title": {"message": "审计与可靠性支持者"},
"community.sponsor.security.desc": {"message": "支持加密、恢复演练、发布签名和运维检查等强化工作。"},
"community.sponsor.community.label": {"message": "社区"},
"community.sponsor.community.title": {"message": "文档与贡献者支持"},
"community.sponsor.community.desc": {"message": "改善指南、示例、平台测试和贡献者体验。"},
"community.sponsor.tier.title": {"message": "支持方式"},
"community.sponsor.tier.subtitle": {"message": "根据团队依赖 BackupX 的方式选择合适层级。"},
"community.sponsor.community.title": {"message": "开源支持"},
"community.sponsor.community.desc": {"message": "支持文档、示例、平台测试和贡献者引导。"},
"community.sponsor.tier.backer.name": {"message": "Backer"},
"community.sponsor.tier.backer.amount": {"message": "适合个人与小团队"},
"community.sponsor.tier.backer.desc": {"message": "支持文档、Issue 分流、兼容性测试和聚焦的易用性改进。"},
"community.sponsor.tier.backer.desc": {"message": "支持文档、Issue 分流、兼容性测试和小型体验改进。"},
"community.sponsor.tier.partner.name": {"message": "Partner"},
"community.sponsor.tier.partner.amount": {"message": "适合存储与基础设施厂商"},
"community.sponsor.tier.partner.desc": {"message": "支持 Provider 验证、部署示例、基准说明和集成指南。"},
"community.sponsor.tier.enterprise.name": {"message": "Enterprise"},
"community.sponsor.tier.enterprise.amount": {"message": "适合生产环境使用方"},
"community.sponsor.tier.enterprise.desc": {"message": "支持恢复演练、发布加固、审计和长期维护。"},
"sponsors.pageTitle": {"message": "赞助 BackupX"},
"sponsors.pageDescription": {"message": "支持 BackupX 的可靠性、文档、存储兼容性和长期维护。"},
"sponsors.tag": {"message": "赞助"},
"sponsors.title": {"message": "让关键维护工作持续进行"},
"sponsors.subtitle": {"message": "赞助用于真实 Provider 验证、可靠发布、恢复演练和更完善的运维文档。"}
"community.sponsor.tier.enterprise.desc": {"message": "赞助恢复演练、发布加固、审计和长期维护等可靠性工作。"},
"community.contributor.kicker": {"message": "贡献者"},
"community.contributor.all": {"message": "查看全部"},
"community.contributor.source": {"message": "浏览器端通过 GitHub contributors API 获取。"},
"community.contributor.botRole": {"message": "自动化贡献者"},
"community.contributor.githubRole": {"message": "GitHub 贡献者"},
"community.contributor.contributions": {"message": "{count} 次贡献"},
"community.path.kicker": {"message": "贡献路径"},
"community.path.issues.title": {"message": "反馈生产问题"},
"community.path.issues.desc": {"message": "提交日志、部署拓扑和恢复预期。"},
"community.path.docs.title": {"message": "完善文档与示例"},
"community.path.docs.desc": {"message": "贡献存储、Agent 和数据库部署指南。"},
"community.path.code.title": {"message": "提交聚焦的 PR"},
"community.path.code.desc": {"message": "保持改动小而可测,并贴合现有架构。"},
"sponsors.pageTitle": {"message": "赞助商"},
"sponsors.pageDescription": {"message": "赞助 BackupX 的可靠性、文档、存储兼容性和长期维护。"},
"sponsors.tag": {"message": "赞助商"},
"sponsors.title": {"message": "赞助 BackupX 生态"},
"sponsors.subtitle": {"message": "赞助帮助 BackupX 更贴近真实运维:经过验证的存储 Provider、可靠发布、恢复信心和更完善的文档。"}
}

View File

@@ -2,7 +2,6 @@
"version.label": {"message": "Next"},
"sidebar.docs.category.Getting Started": {"message": "快速开始"},
"sidebar.docs.category.Deployment": {"message": "部署"},
"sidebar.docs.category.Operations": {"message": "运维"},
"sidebar.docs.category.Features": {"message": "功能特性"},
"sidebar.docs.category.Reference": {"message": "参考"},
"sidebar.docs.category.Development": {"message": "开发"}

View File

@@ -68,9 +68,8 @@ sudo ./deploy/install.sh
```ini title="/etc/systemd/system/backupx.service"
[Unit]
Description=BackupX API Service
After=network-online.target
Wants=network-online.target
Description=BackupX backup management service
After=network.target
[Service]
Type=simple
@@ -101,8 +100,6 @@ curl -fsS http://127.0.0.1:8340/api/auth/setup/status
生产环境应通过 HTTPS 暴露 BackupX或在防火墙限制 `8340` 端口。安装器不会自动修改防火墙。
替换版本前,应在服务停止时同时快照 `/etc/backupx`、`/opt/backupx/data`、已安装二进制和前端文件。请按[升级与恢复](../operations/upgrade-recovery)中的版本化流程操作;让旧版本二进制直接读取已由新版本迁移的数据库并不是安全回滚。
## 密码重置
忘记管理员密码时:

View File

@@ -1,7 +1,7 @@
---
sidebar_position: 4
title: 配置参考
description: config.yaml 全部服务端配置项及对应的环境变量。
description: server.yaml 所有配置项及对应的环境变量。
---
# 配置参考
@@ -32,15 +32,12 @@ security:
backup:
temp_dir: "/tmp/backupx" # BACKUPX_BACKUP_TEMP_DIR
max_concurrent: 2 # BACKUPX_BACKUP_MAX_CONCURRENT
retries: 10 # 单次上传的 rclone 底层重试次数
retries: 3 # 单次上传的 rclone 底层重试次数
bandwidth_limit: "" # 例如 "10M" 表示限速 10 MB/s
log:
level: "info" # debug | info | warn | error
file: "./data/backupx.log"
max_size: 100 # 单个日志文件上限,单位 MB
max_backups: 3 # 保留的轮转文件数
max_age: 30 # 保留天数
```
## 密钥生成
@@ -56,17 +53,11 @@ log:
| `server.port` | `BACKUPX_SERVER_PORT` |
| `server.external_url` | `BACKUPX_SERVER_EXTERNAL_URL` |
| `server.trusted_proxies` | `BACKUPX_SERVER_TRUSTED_PROXIES`(环境变量使用逗号分隔) |
| `security.jwt_secret` | `BACKUPX_SECURITY_JWT_SECRET` |
| `security.jwt_expire` | `BACKUPX_SECURITY_JWT_EXPIRE` |
| `security.encryption_key` | `BACKUPX_SECURITY_ENCRYPTION_KEY` |
| `log.level` | `BACKUPX_LOG_LEVEL` |
| `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` |
| `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` |
| `backup.retries` | `BACKUPX_BACKUP_RETRIES` |
| `backup.bandwidth_limit` | `BACKUPX_BACKUP_BANDWIDTH_LIMIT` |
| `log.max_size` | `BACKUPX_LOG_MAX_SIZE` |
| `log.max_backups` | `BACKUPX_LOG_MAX_BACKUPS` |
| `log.max_age` | `BACKUPX_LOG_MAX_AGE` |
## Master 对外 URL
@@ -79,7 +70,7 @@ server:
BackupX 会用这个地址渲染一键 Agent 安装脚本和 docker-compose 片段。该地址必须能被所有 Agent 主机访问。只有在 `X-Forwarded-Proto` / `X-Forwarded-Host` 可靠且正好指向 Agent 可访问地址时,才建议留空。
代理或 SSH 堡垒机场景可在安装向导中为单个 Agent 设置覆盖地址。目标侧的一次性安装链接与生成的 Agent 运行配置都会使用这个地址,浏览器仍使用正常的公开地址。
代理或 SSH 堡垒机场景可在安装向导中为单个 Agent 设置运行地址。公开安装链接仍使用 `server.external_url`,生成的 Agent 配置则使用该覆盖地址。
## 可信反向代理
@@ -93,5 +84,3 @@ server:
```
不要配置 `0.0.0.0/0`因为登录限流、安装令牌限流和审计日志都依赖客户端地址。BackupX 直接暴露且不应信任任何转发头时可设置空列表。
修改安全密钥或数据库路径前,应同时备份完整数据目录和配置文件。经过验证的快照与回滚流程见[升级与恢复](../operations/upgrade-recovery)。

View File

@@ -85,7 +85,7 @@ environment:
镜像内部端口固定为 `8340`,只通过 `BACKUPX_PORT` 修改宿主机发布端口。
## 升级前提
## 升级与回退准备
```bash
docker compose pull
@@ -94,5 +94,3 @@ docker compose ps
```
等待状态变为 `healthy` 后再切换流量或移除旧部署。升级前应停止 Master 后做文件级复制,或对整个 `backupx-data` 卷创建原子快照。同一个数据卷必须只运行一个活动 MasterSQLite 不支持多个 Master 容器共享 `/app/data`。
生产环境应使用发布标签或镜像摘要而不是 `latest`,并保留与旧版本匹配的升级前数据快照。完整的升级、回滚和灾难恢复流程见[升级与恢复](../operations/upgrade-recovery)。

View File

@@ -29,7 +29,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Connection "";
# 大文件上传(用于恢复流程)
client_max_body_size 0;
@@ -37,27 +36,9 @@ server {
# 实时日志使用 SSE必须关闭缓冲
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# 兼容旧版本生成的安装地址;新版本通过上面的 /api/install/ 访问。
location /install/ {
proxy_pass http://127.0.0.1:8340/install/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
}
# 避免探针和指标请求落入 SPA fallback。
location = /health { proxy_pass http://127.0.0.1:8340/health; }
location = /ready { proxy_pass http://127.0.0.1:8340/ready; }
location = /metrics { proxy_pass http://127.0.0.1:8340/metrics; }
}
```
@@ -65,8 +46,6 @@ server {
如果 Nginx 运行在另一台主机或另一个容器,只把该代理的 IP 或网段加入 `server.trusted_proxies`,不要配置 `0.0.0.0/0`。登录限流、安装令牌限流和审计日志都依赖可信的客户端地址。
`/health`、`/ready` 和 `/metrics` 不需要 BackupX 认证。应只放行探针与 Prometheus 来源网段,或把这些 location 放在内部监听端口,避免直接暴露到互联网。
## certbot 配置 HTTPS
```bash
@@ -77,5 +56,5 @@ sudo certbot --nginx -d backup.example.com
certbot 会自动改写配置监听 443 并设置续期。
:::caution Agent 需要稳定的 URL
如果 Master 部署在 HTTPS 后面,远程 Agent 的 `--master` 必须使用最终 HTTPS 地址Agent 不会跟随重定向。私有 CA 应预先下发 PEM 证书并使用 `--ca-cert /path/to/ca.pem``--insecure-tls` 只用于短期测试。
如果 Master 部署在 HTTPS 后面,远程 Agent 的 `--master` 必须使用公网 HTTPS 地址。自签名证书需加 `--insecure-tls`(仅供测试
:::

View File

@@ -6,7 +6,7 @@ description: 搭建 BackupX 本地开发环境 — 后端、前端、测试。
# 开发环境
**环境要求:** Go ≥ 1.25Node.js 24 LTS、npm 11 或更高版本
**环境要求:** Go ≥ 1.25Node.js ≥ 20npm
## 克隆与依赖

View File

@@ -10,25 +10,38 @@ BackupX 以单个静态二进制发布。三种安装方式,按实际环境选
## Docker推荐
下载仓库中的正式加固 Compose 文件并启动
无需克隆仓库
```bash
curl -fLO https://raw.githubusercontent.com/Awuqing/BackupX/main/docker-compose.yml
docker compose up -d
docker compose ps
docker run -d --name backupx \
-p 8340:8340 \
-v backupx-data:/app/data \
awuqing/backupx:latest
```
该 Compose 配置启用 init 与优雅停止,持久化 `/app/data`,以非特权用户运行应用,删除不必要能力,并通过 `/ready` 检查健康。[`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) 镜像支持 `linux/amd64``linux/arm64`
或使用 `docker compose`
生产环境应创建受保护的 `.env`,固定 Release 而不是依赖 `latest`
```yaml title="docker-compose.yml"
services:
backupx:
image: awuqing/backupx:latest
container_name: backupx
restart: unless-stopped
ports:
- "8340:8340"
volumes:
- backupx-data:/app/data
# 挂载需要备份的宿主机目录(按需添加):
# - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro
environment:
- TZ=Asia/Shanghai
```dotenv
BACKUPX_IMAGE=awuqing/backupx:vX.Y.Z
BACKUPX_BIND_ADDRESS=127.0.0.1
TZ=Asia/Shanghai
volumes:
backupx-data:
```
反向代理位于同一主机时使用回环绑定;需要直接访问时,应选择明确的监听接口并配置防火墙。宿主机备份源应只读挂载,或在源主机部署 Agent。完整配置见 [Docker 部署](../deployment/docker)
Docker Hub[`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx),支持 linux/amd64 和 linux/arm64
## 预编译包(裸机)
@@ -51,7 +64,7 @@ sudo ./install.sh # 创建系统用户、安装到 /opt/backupx、配置
## 从源码构建
依赖Go ≥ 1.25Node.js 24 LTS、npm 11 或更高版本
依赖Go ≥ 1.25Node.js ≥ 20
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX

View File

@@ -57,6 +57,5 @@ description: 部署 BackupX、添加存储目标、创建第一个备份任务
## 继续阅读
- 了解 [备份类型](/docs/features/backup-types) 和 [存储后端](/docs/features/storage-backends)
- 上线生产前阅读[安全加固](/docs/operations/security)、[监控与告警](/docs/operations/monitoring)和[升级与恢复](/docs/operations/upgrade-recovery)
- 使用 SAP HANA参考 [SAP HANA 支持](/docs/features/sap-hana)
- 管理多台服务器?参考 [多节点集群](/docs/features/multi-node)

View File

@@ -35,8 +35,6 @@ description: BackupX——自托管服务器备份管理平台概览。
- **第一次使用 BackupX** 先看 [快速开始](/docs/getting-started/quick-start)
- **生产部署?** 参考 [部署指南](/docs/deployment/docker)
- **规划升级或灾备?** 按[升级与恢复](/docs/operations/upgrade-recovery)执行
- **生产运维?** 先阅读[安全加固](/docs/operations/security)与[监控和告警](/docs/operations/monitoring)
- **SAP HANA 用户?** 支持 `hdbsql` Runner 和原生 Backint 两种模式 — 详见 [SAP HANA](/docs/features/sap-hana)
- **管理多台服务器?** 参考 [多节点集群](/docs/features/multi-node)
- **程序化集成?** 参考 [API 参考](/docs/reference/api)

View File

@@ -1,149 +0,0 @@
---
sidebar_position: 3
title: 监控与告警
description: 健康探针、Prometheus 指标、初始告警规则和运维验证。
---
# 监控与告警
BackupX 提供低开销健康端点和独立 Prometheus Registry。监控既要覆盖控制面也要覆盖备份、恢复、验证和复制的实际结果。
## 探针
| 端点 | 含义 | 预期响应 |
| --- | --- | --- |
| `/health` | 存活HTTP 进程可响应 | HTTP 200`status: live` |
| `/ready` | 就绪:进程可访问 SQLite | 正常为 HTTP 200 与 `status: ready`;数据库故障为 HTTP 503 |
| `/api/health` | 带 API 前缀的存活别名 | 与 `/health` 相同 |
| `/api/ready` | 带 API 前缀的就绪别名 | 与 `/ready` 相同 |
| `/metrics` | Prometheus 指标 | 指标启用时为 HTTP 200 |
`/health` 用作 liveness`/ready` 用作 readiness 或负载均衡流量判断。外部存储暂时不可用不应直接触发进程重启,应通过任务和存储目标告警处理。
~~~bash
curl -fsS http://127.0.0.1:8340/health
curl -fsS http://127.0.0.1:8340/ready
curl -fsS http://127.0.0.1:8340/metrics | head
~~~
这些端点不需要认证,只允许编排器和监控网段访问。
## Prometheus 抓取
~~~yaml
scrape_configs:
- job_name: backupx
scheme: https
metrics_path: /metrics
static_configs:
- targets: [backup.example.com]
~~~
Nginx 终止 TLS 时,应只放行 Prometheus 源地址访问 `/metrics`。内部采集器每 30 秒刷新存储、节点、命令队列和 SLA Gauge。
## BackupX 指标
| 指标 | 类型 | 标签 | 用途 |
| --- | --- | --- | --- |
| `backupx_app_info` | gauge | `version` | 当前版本元数据 |
| `backupx_task_run_total` | counter | `status``task_type` | 备份结果 |
| `backupx_task_run_duration_seconds` | histogram | `task_type` | 备份耗时分布 |
| `backupx_task_bytes_total` | counter | `task_type` | 备份产出字节数 |
| `backupx_task_running` | gauge | 无 | 当前备份并发 |
| `backupx_storage_used_bytes` | gauge | `target_name``target_type` | 按目标记录的使用量 |
| `backupx_node_online` | gauge | `node_name``role` | 节点在线状态1 或 0 |
| `backupx_agent_command_queue_depth` | gauge | `node_name``role` | 待处理与已派发命令 |
| `backupx_agent_command_running` | gauge | `node_name``role` | Agent 长任务数 |
| `backupx_agent_command_timeout_total` | gauge | `node_name``role` | 超时命令数快照 |
| `backupx_verify_run_total` | counter | `status` | 验证结果 |
| `backupx_restore_run_total` | counter | `status` | 恢复结果 |
| `backupx_replication_run_total` | counter | `status` | 复制结果 |
| `backupx_sla_breach_tasks` | gauge | 无 | 超出已配置 RPO 的启用任务数 |
同一端点还注册了标准 Go Runtime 与进程指标。
## 初始告警规则
应根据各环境计划与 RPO 调整窗口和阈值:
~~~yaml
groups:
- name: backupx
rules:
- alert: BackupXTargetDown
expr: up{job="backupx"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: BackupX metrics endpoint is unreachable
- alert: BackupXNotReady
expr: probe_success{job="backupx-ready"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: BackupX readiness check is failing
- alert: BackupXBackupFailure
expr: sum(increase(backupx_task_run_total{status="failed"}[15m])) > 0
labels:
severity: warning
annotations:
summary: A BackupX backup failed
- alert: BackupXSLABreach
expr: backupx_sla_breach_tasks > 0
for: 5m
labels:
severity: critical
annotations:
summary: One or more backup tasks are outside RPO
- alert: BackupXAgentOffline
expr: backupx_node_online{role="agent"} == 0
for: 2m
labels:
severity: warning
annotations:
summary: BackupX Agent is offline
- alert: BackupXAgentQueueBacklog
expr: backupx_agent_command_queue_depth > 20
for: 10m
labels:
severity: warning
annotations:
summary: BackupX Agent command queue is growing
~~~
`BackupXNotReady` 示例假定存在名为 `backupx-ready` 的 Blackbox 探针任务。未部署 Blackbox Exporter 时,应改用负载均衡或编排器的 readiness 信号。
## 运维仪表盘
建议同时展示:
- 按任务类型统计成功率与失败率。
- P50、P95、最大执行时长及其与备份窗口的关系。
- 产出字节数与预期数据变化率。
- 当前任务数与 `backup.max_concurrent`
- 离线 Agent、队列深度、运行命令和超时数变化。
- 存储增长、提供商剩余容量和保留策略清理。
- SLA 违约数及关键任务最近成功备份时间。
- 验证、恢复和复制成功率。
Prometheus 存储使用量来自 BackupX 记录元数据,不一定等同于提供商计费容量,应另行监控提供商配额和文件系统剩余空间。
## 部署后验证
安装、升级、代理变更或恢复后:
1. 分别从本机和公开代理检查存活与就绪。
2. 确认 Prometheus 只看到一个活动 Master并带有预期版本标签。
3. 确认所有预期 Agent 的 `backupx_node_online == 1`
4. 执行小型备份并确认成功 Counter 增长。
5. 执行验证或隔离恢复并确认对应 Counter 增长。
6. 触发测试通知并验证告警投递链路。
探针或指标异常时继续参考[故障排查](./troubleshooting)。

View File

@@ -1,102 +0,0 @@
---
sidebar_position: 2
title: 安全加固
description: 生产环境的网络暴露、角色、密钥、Agent、容器和公开端点控制。
---
# 安全加固
BackupX 统一接触源文件、数据库凭据、存储凭据和恢复目标,应把 Master 作为安全敏感的控制面部署,而不是普通的公开 Web 应用。
## 推荐暴露模型
| 组件 | 入站访问 | 出站访问 |
| --- | --- | --- |
| Master | 管理员与 Agent 的 HTTPS指标只对监控网段开放 | 存储提供商、通知端点、版本检查 |
| Agent | 不需要入站端口 | Master HTTPS 与分配的存储目标 |
| SQLite 数据 | 仅本地或块存储文件系统 | 无 |
反向代理与 Docker 位于同一主机时,把 Docker 绑定到 `127.0.0.1`
~~~dotenv
BACKUPX_BIND_ADDRESS=127.0.0.1
~~~
裸机仅允许本机代理访问时,把 `server.host` 设置为回环地址;其他情况应使用主机或网络防火墙限制 TCP 8340。
## TLS 与反向代理
- 所有不可信网络段都使用 HTTPS。
- `server.external_url` 设置为 Agent 可访问的稳定地址。
- `server.trusted_proxies` 只加入准确代理 IP 或网段,禁止信任 `0.0.0.0/0`
- Agent 使用最终 HTTPS 地址,不要依赖重定向。
- 私有 PKI 应向 Agent 下发 PEM CA并配置 `caCertFile``--ca-cert`
- `--insecure-tls` 只用于临时测试。
- Master 中转上传和 SSE 日志需要关闭 Nginx 请求与响应缓冲。
必须经过 SSH 堡垒机时,将隧道绑定到回环地址,严格校验主机密钥,使用专用账号与密钥,并让 Agent 服务依赖隧道。详见[多节点集群](../features/multi-node)。
## 角色与 API Key
| 角色 | 预期权限 |
| --- | --- |
| `viewer` | 读取仪表盘、任务、记录、报表与审计数据;不能浏览节点文件系统或修改资源 |
| `operator` | viewer 权限,加上任务、存储、通知、备份、恢复、验证和文件浏览操作 |
| `admin` | operator 权限加上用户、API Key、设置、节点生命周期、安装令牌和 Token 轮换 |
为每位人员创建独立命名账号,不共享初始管理员。特权账号应启用双因素认证或通行密钥,并定期检查可信设备与恢复码。
用户 JWT 是无状态令牌。登出只会删除客户端副本,无法撤销已被复制到其他位置的 Token。应把 `security.jwt_expire` 设置为可接受的最短时长,保护 Bearer Token必须使全部会话失效时轮换 JWT 密钥。
API Key 与交互式用户使用相同的角色检查。明文只在创建时显示一次,数据库只保存带密钥哈希。自动化应使用最低必要角色、设置有效期、保存在密钥管理系统,并及时撤销闲置 Key。监控不应使用管理员 Key。
## 保护控制面密钥
- `/etc/backupx/config.yaml` 应为 `root:backupx`、模式 `0640`,数据目录只允许服务账号访问。
- `jwt_secret``encryption_key` 留空时,自动生成值会写入 SQLite 数据库,因此必须备份完整数据目录。
- 加密密钥丢失或替换后,已有存储凭据将无法解密。
- 数据库包含密码哈希、配置密钥、Agent Token、API Key 哈希、可信设备状态和审计数据。快照应加密并设置保留策略。
- 不要把 Token 写入 shell 历史、Issue、截图或支持包。
每个节点有独立的长期 Agent Token。systemd 安装器把它保存到 `/etc/backupx-agent/agent.token`,模式为 `0600`。人员变更、主机入侵或意外泄露后应轮换 Token在重叠窗口内更新文件并重启 Agent。
一次性安装 URL 有效期为 5 分钟至 24 小时使用后立即失效。URL 与内嵌备用命令都应视为秘密,因为生成的安装材料会配置长期节点 Token。
## 容器与主机权限
正式 Compose 会删除全部能力,只添加旧数据卷所有权迁移与切换到非特权 `backupx` 用户所需的能力。保留 `no-new-privileges`,不要挂载 Docker Socket。
备份源应只读挂载;只有恢复目标确实需要时才添加独立、范围明确的可写挂载。需要高权限文件访问时,优先部署宿主机 Agent而不是让 Master 容器以 root 运行。
systemd Master 以 `backupx` 运行。Agent 通常以 root 运行,因为它可能备份或恢复属于任意系统用户的文件。应限制任务创建权限并保护 root 所有的 Agent 配置。
## 公开端点
以下端点有意不使用 BackupX JWT 或 API Key 认证:
- `/health``/api/health`
- `/ready``/api/ready`
- `/metrics`
- 一次性 `/install/:token``/api/install/:token` 路由
健康响应包含状态、版本、运行时间、时间戳和就绪检查;就绪失败时可能带有数据库错误细节。`/metrics` 还会包含节点与存储目标标签。应在防火墙或反向代理只允许监控网段访问探针与指标,不要缓存或记录完整安装令牌 URL。
## 备份加密边界
加密备份任务只能在 Master 执行,因为远程 Agent 不会收到 Master 加密密钥。不要通过复制 Master 密钥到 Agent 来绕过这个边界。Agent 任务需要加密时,应根据要求使用传输层加密和存储提供商的服务端加密。
每次调整密钥管理后都应验证加密备份恢复。缺少密钥的备份不可恢复。
## 审计与事故响应
BackupX 会记录特权操作,并可把签名审计事件转发到外部 Webhook。高价值审计记录应发送到独立管理的 SIEM 或追加写存储,避免受损 Master 删除唯一副本。
怀疑入侵时:
1. 隔离 Master但不要删除证据。
2. 撤销泄露的 API Key轮换受影响的 Agent Token 与存储凭据。
3. JWT 与加密密钥只能按计划迁移;直接更换加密密钥会使已保存加密配置失效。
4. 审查用户、可信设备、API Key、节点、设置、恢复与删除事件。
5. 无法确认完整性时,从已知可信的控制面快照恢复。
应用与数据库配套恢复流程见[升级与恢复](./upgrade-recovery)。

View File

@@ -1,160 +0,0 @@
---
sidebar_position: 4
title: 故障排查
description: Master、反向代理、Agent、备份工具和 SQLite 的安全诊断顺序。
---
# 故障排查
从最先失败的边界开始并保留证据。在确认原因前,不要删除数据库、重建卷、一次性轮换所有 Token 或重新安装。
## 快速分流
| 现象 | 首项检查 | 可能边界 |
| --- | --- | --- |
| Web 控制台不可用 | 本机 `/health`,再检查代理 `/health` | 进程、监听、防火墙、代理或静态文件 |
| `/health` 正常但 `/ready` 为 503 | 服务日志、数据库路径、磁盘、权限 | SQLite 或数据文件系统 |
| 登录循环或客户端 IP 错误 | 转发头与 `trusted_proxies` | 反向代理信任 |
| 实时日志停止更新 | Nginx 响应缓冲与超时 | SSE 代理路径 |
| 中转上传停滞或代理磁盘占满 | 请求缓冲与 Body 上限 | 反向代理 |
| Agent 离线 | Agent 日志、最终 Master URL、代理、DNS、CA | Agent 到 Master 网络 |
| 备份启动后失败 | 记录日志、源路径、数据库原生工具 | Runner 或权限 |
| 恢复失败 | 记录日志、目标挂载与写权限 | 存储读取或目标权限 |
## 无敏感信息的状态采集
Docker Master
~~~bash
docker compose ps
docker compose logs --tail=200 backupx
curl -i http://127.0.0.1:8340/health
curl -i http://127.0.0.1:8340/ready
~~~
裸机 Master
~~~bash
sudo systemctl status backupx --no-pager
sudo journalctl -u backupx -n 200 --no-pager
sudo ss -lntp | grep 8340
curl -i http://127.0.0.1:8340/health
curl -i http://127.0.0.1:8340/ready
~~~
systemd Agent
~~~bash
sudo systemctl status backupx-agent --no-pager
sudo journalctl -u backupx-agent -n 200 --no-pager
sudo systemctl status backupx-agent-tunnel --no-pager
~~~
最后一条只适用于堡垒机部署。共享输出前,移除 Authorization 头、API Key、Agent Token、安装 URL、数据库密码、存储凭据、代理凭据以及会暴露敏感拓扑的私有路径。
## Web 控制台或首次初始化
检查无需认证的初始化端点:
~~~bash
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
~~~
API 正常但浏览器出现空白页或 JSON 时:
- 确认 Release 包含前端文件。
- 裸机检查 `/opt/backupx/web` 可读;显式配置时确认 `server.web_root` 正确。
- Docker 确认运行正式镜像,且自定义挂载未覆盖镜像内前端目录。
- Nginx 静态模式确认 `root /opt/backupx/web` 和 SPA fallback 存在。
- 版本变更后清理旧 Service Worker 或浏览器缓存。
认证失败先校验系统时间,再排查 TOTP 或通行密钥。确认浏览器 Origin 与最终 HTTPS 主机一致,并从审计日志检查限流、禁用用户或已撤销可信设备。
## 反向代理
验证并重载 Nginx
~~~bash
sudo nginx -t
sudo systemctl reload nginx
curl -i https://backup.example.com/health
curl -i https://backup.example.com/ready
~~~
常见修正:
- HTTP 413API 路由设置 `client_max_body_size 0`
- 中转上传占满代理临时目录:设置 `proxy_request_buffering off`
- SSE 日志批量到达或断开:设置 `proxy_buffering off`、关闭代理缓存并增加读取超时。
- 一键安装返回 HTML代理 `/api/` 并保留旧版 `/install/` 路由。
- Agent 收到重定向:配置最终 HTTPS Master URL不使用 HTTP 地址。
- 审计中所有用户都是代理 IP只把真实代理 IP 或网段加入 `server.trusted_proxies`
以完整的 [Nginx 配置](../deployment/nginx)作为对照基线。
## Agent 离线
Agent 通常每 15 秒发送一次心跳45 秒无心跳后会被标记离线。
1. 确认 Agent 与可选隧道服务运行。
2. 确认 Master URL 没有重定向,且可在 Agent 主机解析。
3. 检查显式 `proxyUrl`DNS 必须经过 SSH 动态隧道时使用 `socks5h://`
4. 确认私有 CA 路径存在且可读,不要长期改为跳过 TLS。
5. 检查到 Master 与分配存储后端的出站防火墙。
6. 确认 `/etc/backupx-agent/agent.token` 存在且模式为 `0600`
7. Token 已轮换时,在重叠窗口内写入新值并重启 Agent。
不要把 Token 直接放进会保存到 shell 历史的诊断命令。Agent 日志中的 401 通常表示 Token 缺失、重叠期已结束或节点不匹配;连续连接错误通常来自 URL、DNS、代理、隧道、防火墙或 CA。
## 备份任务失败
修改任务前先打开备份记录并阅读完整日志。
- 文件任务路径在所选 Master 或 Agent 上解析,确认路径存在于该主机命名空间。
- Docker 只能看到已挂载路径,备份源通常应只读。
- MySQL 要求执行主机 `PATH` 中存在 `mysqldump`
- PostgreSQL 要求执行主机 `PATH` 中存在 `pg_dump`
- SAP HANA Runner 模式要求对应客户端工具与环境。
- 确认服务账号可读源路径并可写临时目录。
- 从控制台测试所选存储目标。
- 远端存储应检查 DNS、出站策略、提供商配额、时钟偏差和代理。
配置多个目标时,应查看逐目标结果,不要假定所有副本都失败。修复失败目标时保留已经成功的远端产物。
## 恢复、下载或验证失败
- 确认远端产物仍存在且存储凭据可读取。
- 确认执行恢复的主机挂载了目标路径。
- 使用独立可写恢复目录,不要把所有备份源都改为可写。
- 检查目标与 Agent 临时目录剩余空间。
- 加密备份必须能取得原 Master 加密密钥。
- CDC 仓库的 Manifest、索引和共享 Pack 必须一起保留,单独 Manifest 不是完整备份。
诊断时优先恢复到隔离目录,不要反复覆盖生产源。
## SQLite 与就绪故障
`/health` 为 200 而 `/ready` 为 503 时:
1. 从服务日志读取准确数据库错误。
2. 检查磁盘空间、inode、路径所有权和挂载状态。
3. 确认数据目录只被一个 Master 进程或容器使用。
4. SQLite 应位于本地或块存储文件系统,不放在共享多写或不可靠网络文件系统。
5. 检查外部备份或防病毒进程是否长期占用文件。
BackupX 使用 5 秒 SQLite busy timeout但这不会把 SQLite 变成集群数据库。不能通过启动另一个 Master 解决锁冲突。文件级复制应先停服,再复制整个数据目录。
## 升级问题材料
提交 Issue 时提供:
- BackupX 版本、安装方式、操作系统和架构。
- 故障影响 Master、Agent、代理、存储目标还是单个任务。
- 覆盖首次失败时段的脱敏日志。
- `/health``/ready` 的 HTTP 状态和响应体。
- 最小复现步骤,以及是否始于升级或配置变更。
- 脱敏后的相关代理配置。
不要向公开 Issue 附加 `backupx.db``.env`、完整配置、Agent Token 文件、API Key、安装命令或存储凭据。
涉及完整性或回滚时,应停止破坏性变更并参考[升级与恢复](./upgrade-recovery)。

View File

@@ -1,153 +0,0 @@
---
sidebar_position: 1
title: 升级与恢复
description: 备份控制面、安全升级、配套回滚并恢复故障 Master。
---
# 升级与恢复
备份产物与 BackupX 控制面属于两个不同的恢复域。对象存储中可能仍保留全部归档,但 Master 数据库丢失会同时丢失用户、加密后的存储凭据、计划、记录、节点 Token 和审计历史,因此两者都必须保护。
## 必须遵守的规则
1. 同一个数据目录或 SQLite 数据库只能运行一个活动 Master。
2. 停止 Master 后快照完整数据目录与配置,或使用覆盖整个存储卷的原子快照。
3. 旧应用版本必须与其升级前数据快照配套保留。启动时会执行数据库迁移,只切回旧二进制或旧镜像不是安全回滚。
4. 控制面快照应保存到 Master 主机之外,并定期验证恢复。
5. 停止 Master 前,先等待正在运行的备份、恢复、验证和复制任务结束。
| 部署方式 | 持久化控制面数据 | 配置与版本状态 |
| --- | --- | --- |
| Docker | `backupx-data` 卷中的 `/app/data` | Compose 文件、受保护的 `.env`、固定的镜像标签或摘要 |
| 裸机 | `/opt/backupx/data` | `/etc/backupx``/opt/backupx/bin``/opt/backupx/web`、systemd unit |
配置未显式提供 JWT 与加密密钥时,自动生成的值保存在 SQLite 数据库中。所有控制面快照都应按敏感数据管理。
## 变更前检查
升级、迁移主机或修改安全密钥前:
- 记录当前 BackupX 版本以及准确的镜像摘要或 Release 校验和。
- 确认 `/ready` 返回 HTTP 200并检查近期失败记录。
- 等待正在运行的备份、恢复、验证和复制结束。
- 测试至少一个存储目标,并确认 Agent 在线。
- 创建完整控制面快照,校验后复制到异机。
- 可额外导出任务定义供人工审阅。任务导出不包含数据库密码与存储凭据,不能代替数据库快照。
- 开始前确定回滚条件与维护窗口截止时间。
## 快照 Docker 部署
下面的示例无需直接访问 Docker 卷目录,即可生成一致的文件级副本:
~~~bash
snapshot="backupx-control-plane-$(date -u +%Y%m%dT%H%M%SZ)"
install -d -m 0700 "$snapshot"
docker compose stop backupx
docker cp backupx:/app/data "$snapshot/data"
cp docker-compose.yml "$snapshot/"
if [ -f .env ]; then cp .env "$snapshot/"; fi
docker compose start backupx
tar -czf "$snapshot.tar.gz" "$snapshot"
sha256sum "$snapshot.tar.gz" > "$snapshot.tar.gz.sha256"
curl -fsS http://127.0.0.1:8340/ready
~~~
复制失败时,应先恢复已停止的服务,再继续排查。归档中的 `.env` 和数据库可能包含凭据,必须限制访问。如果块存储或云平台快照能原子覆盖整个卷,也可以直接使用。
## 快照裸机部署
~~~bash
snapshot="/var/backups/backupx/backupx-control-plane-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"
sudo install -d -m 0700 /var/backups/backupx
sudo systemctl stop backupx
sudo tar --acls --xattrs -C / -czf "$snapshot" \
etc/backupx \
etc/systemd/system/backupx.service \
opt/backupx/bin \
opt/backupx/web \
opt/backupx/data
sudo systemctl start backupx
sudo sha256sum "$snapshot" | sudo tee "$snapshot.sha256"
curl -fsS http://127.0.0.1:8340/ready
~~~
把归档及校验和复制到受保护的异机存储。不要在服务运行时只复制 `backupx.db`
## 升级 Docker
1.`BACKUPX_IMAGE` 中使用 Release 标签或不可变摘要,受控生产升级不要使用 `latest`
2. 创建并验证升级前快照。
3. 拉取并重建服务:
~~~bash
docker compose pull backupx
docker compose up -d backupx
docker compose ps
docker compose logs --tail=100 backupx
curl -fsS http://127.0.0.1:8340/ready
~~~
4. 登录后测试存储目标,确认 Agent 心跳,并执行一个小型备份以及一次恢复或验证演练。
5. 观察窗口结束前保留旧镜像引用与快照。
Master 完成后再小批量升级 Agent。除非变更目标就是网络配置否则不要改动节点专用代理、私有 CA、Token 文件和堡垒机参数。
## 升级裸机
下载目标 Release 与校验和,完成校验后解压。安装器会保留已有的 `/etc/backupx/config.yaml`,替换二进制、前端文件和 systemd unit并重启服务。
~~~bash
sha256sum -c backupx-vX.Y.Z-linux-amd64.tar.gz.sha256
tar xzf backupx-vX.Y.Z-linux-amd64.tar.gz
cd backupx-vX.Y.Z-linux-amd64
sudo ./install.sh
sudo systemctl status backupx --no-pager
curl -fsS http://127.0.0.1:8340/ready
~~~
运行安装器前必须先创建停服快照。升级后的业务检查与 Docker 相同。
## 回滚
回滚是配套操作:必须同时恢复旧应用版本和紧邻升级前创建的数据快照。
Docker 应保留故障卷用于分析把快照恢复到新的空卷Compose 同时指向该卷与旧镜像标签,然后只启动一个 Master。裸机应停止服务并保留故障现场从同一归档恢复旧配置、二进制、前端、数据目录和 unit重新加载 systemd 后启动。
回滚后检查:
~~~bash
curl -fsS http://127.0.0.1:8340/health
curl -fsS http://127.0.0.1:8340/ready
~~~
随后验证登录、存储访问、计划、Agent 心跳、一次备份和一次非破坏性恢复演练。在事故原因明确前不要删除故障现场。
## 恢复丢失的 Master
1. 按快照记录准备同架构主机与完全相同的应用版本。
2. 替代主机先与生产流量隔离,并确保旧 Master 无法再次启动。
3. 按原权限恢复配置和完整数据目录。
4. 只启动一个 Master在本机检查 `/ready`
5. 本地验证完成后再切换稳定 DNS 名称或虚拟 IP。
6. 检查用户、存储目标、任务、记录、通知和审计历史。
7. 数据库内 Token 与节点一致时,已有 Agent 会自动重连;可能泄露的 Token 必须调查并轮换。
8. 执行小型备份及恢复或验证演练后再结束事故处理。
恢复控制面不会重新生成外部备份产物,它们仍位于原存储目标。反过来,任务 JSON 导出只适合辅助重建计划,不包含密钥、存储定义和部分节点绑定,不能作为完整灾备。
## 验证恢复计划
至少每季度把近期快照恢复到隔离网络,启动快照记录的 BackupX 版本并验证:
- 不接触生产 Master 时,`/ready` 能恢复正常。
- 管理员可登录,已加密的存储配置可读取。
- 任务、节点、记录和审计数量合理。
- 可以测试一个存储目标而不写入生产数据。
- 选定备份可验证,或可恢复到隔离目录。
记录恢复耗时和最新可恢复快照时间,这两个实测值才是控制面的真实 RTO 与 RPO。

View File

@@ -1,268 +1,135 @@
---
sidebar_position: 1
title: API 参考
description: BackupX REST 端点、认证方式、角色边界、流式响应和公开探针
description: REST API 端点 — 统一以 /api 为前缀,使用 JWT Bearer 认证
---
# API 参考
交互式 API `/api`根路径。大多数端点接受用户 JWT 或 API KeyAgent 协议使用节点专用 Token。公开探针和一次性安装器在文末单列
所有端点都`/api`前缀,使用 JWT Bearer 令牌认证(通过 `POST /api/auth/login` 获取。Agent 专用端点使用 `X-Agent-Token` 头认证
## 认证
### 用户 JWT
通过 `POST /api/auth/login` 获取 JWT并作为 Bearer Token 发送:
~~~bash
curl -H "Authorization: Bearer $BACKUPX_TOKEN" \
https://backup.example.com/api/backup/tasks
~~~
根据账号和系统设置,登录过程还可能要求邮件或短信 OTP、TOTP、恢复码、可信设备 Token 或 WebAuthn。
### API Key
管理员可在控制台或通过 `POST /api/api-keys` 创建 API Key。明文 `bax_...` 只返回一次。
~~~bash
curl -H "X-Api-Key: $BACKUPX_API_KEY" \
https://backup.example.com/api/dashboard/stats
~~~
也支持 `Authorization: Bearer bax_...`。API Key 带有 `admin``operator``viewer` 角色,可禁用并可设置有效期。
### Agent Token
Agent 协议 Handler 从 `X-Agent-Token` 验证节点 Token。它不是用户凭据不能用于交互式资源 API。
### 权限标记
下表使用这些标记:
| 标记 | 所需权限 |
| --- | --- |
| 公开 | 不需要 JWT 或 API Key安装路由仍要求一次性 Token |
| 已认证 | 任意 `viewer``operator``admin` |
| 运维 | `operator``admin` |
| 管理员 | 仅 `admin` |
| Agent | 有效的节点专用 Agent Token |
viewer 可使用读取端点但不能浏览节点文件系统operator 可以执行和修改备份资源admin 还可管理用户、API Key、设置、节点、安装令牌和节点 Token 轮换。角色不满足时返回 HTTP 403。
## 认证与账号安全
| 方法 | 端点 | 权限 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/auth/setup/status` | 公开 | 查询是否需要创建首个管理员 |
| `POST` | `/api/auth/setup` | 公开 | 系统无用户时创建首个管理员 |
| `POST` | `/api/auth/login` | 公开 | 完成密码或 MFA 登录并获取 JWT |
| `POST` | `/api/auth/otp/send` | 公开 | 发送已配置的登录 OTP |
| `POST` | `/api/auth/webauthn/login/options` | 公开 | 开始通行密钥登录 |
| `POST` | `/api/auth/logout` | 已认证 | 确认登出;客户端必须丢弃无状态 JWT |
| `GET` | `/api/auth/profile` | 已认证 | 读取当前账号 |
| `PUT` | `/api/auth/password` | 已认证 | 修改当前账号密码 |
| `POST` | `/api/auth/2fa/setup` | 已认证 | 准备 TOTP 注册 |
| `POST` | `/api/auth/2fa/enable` | 已认证 | 验证后启用 TOTP |
| `POST` | `/api/auth/2fa/recovery-codes` | 已认证 | 重新生成恢复码 |
| `DELETE` | `/api/auth/2fa` | 已认证 | 停用 TOTP |
| `PUT` | `/api/auth/otp/config` | 已认证 | 更新 OTP 登录配置 |
| `POST` | `/api/auth/webauthn/register/options` | 已认证 | 开始注册通行密钥 |
| `POST` | `/api/auth/webauthn/register/finish` | 已认证 | 完成通行密钥注册 |
| `GET` | `/api/auth/webauthn/credentials` | 已认证 | 列出通行密钥 |
| `DELETE` | `/api/auth/webauthn/credentials/:id` | 已认证 | 删除通行密钥 |
| `GET` | `/api/auth/trusted-devices` | 已认证 | 列出可信设备 |
| `DELETE` | `/api/auth/trusted-devices/:id` | 已认证 | 撤销可信设备 |
账号安全端点应使用交互式 JWT不应使用自动化 API Key。
## 系统与存储目标
| 方法 | 端点 | 权限 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/system/info` | 已认证 | 版本与系统信息 |
| `GET` | `/api/system/update-check` | 已认证 | 检查可用 Release |
| `GET` | `/api/storage-targets` | 已认证 | 存储目标列表 |
| `POST` | `/api/storage-targets` | 运维 | 创建目标 |
| `POST` | `/api/storage-targets/test` | 运维 | 测试未保存配置 |
| `GET` | `/api/storage-targets/rclone/backends` | 已认证 | 可用 rclone 后端 |
| `POST` | `/api/storage-targets/google-drive/auth-url` | 运维 | 开始 Google Drive 授权 |
| `POST` | `/api/storage-targets/google-drive/complete` | 运维 | 完成 Google Drive 授权 |
| `GET` | `/api/storage-targets/google-drive/callback` | 已认证 | 处理 OAuth 回调 |
| `GET` | `/api/storage-targets/:id` | 已认证 | 读取目标 |
| `PUT` | `/api/storage-targets/:id` | 运维 | 更新目标 |
| `DELETE` | `/api/storage-targets/:id` | 运维 | 删除目标 |
| `PUT` | `/api/storage-targets/:id/star` | 运维 | 切换收藏 |
| `POST` | `/api/storage-targets/:id/test` | 运维 | 测试已保存目标 |
| `GET` | `/api/storage-targets/:id/usage` | 已认证 | 读取已记录用量 |
| `GET` | `/api/storage-targets/:id/google-drive/profile` | 已认证 | 读取已连接 Google Drive 账号 |
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/auth/setup/status` | 查询是否需要初始化管理员 |
| `POST` | `/api/auth/setup` | 初始化首个管理员(仅当系统无任何用户时) |
| `POST` | `/api/auth/login` | 登录,返回 JWT |
| `POST` | `/api/auth/logout` | 登出(使当前 Token 失效) |
| `GET` | `/api/auth/profile` | 当前用户信息 |
| `PUT` | `/api/auth/password` | 修改密码 |
## 备份任务
| 方法 | 端点 | 权限 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/backup/tasks` | 已认证 | 任务列表 |
| `GET` | `/api/backup/tasks/tags` | 已认证 | 任务标签 |
| `GET` | `/api/backup/tasks/export` | 已认证 | 下载全部任务 JSON或用 `?ids=1,2` 选择任务 |
| `POST` | `/api/backup/tasks/import` | 运维 | 导入任务,最大 1 MiB |
| `POST` | `/api/backup/tasks/batch/toggle` | 运维 | 批量启用或停用 |
| `POST` | `/api/backup/tasks/batch/delete` | 运维 | 批量删除 |
| `POST` | `/api/backup/tasks/batch/run` | 运维 | 批量执行 |
| `GET` | `/api/backup/tasks/:id` | 已认证 | 读取任务 |
| `POST` | `/api/backup/tasks` | 运维 | 创建任务 |
| `PUT` | `/api/backup/tasks/:id` | 运维 | 更新任务 |
| `DELETE` | `/api/backup/tasks/:id` | 运维 | 删除任务 |
| `PUT` | `/api/backup/tasks/:id/toggle` | 运维 | 启用或停用 |
| `POST` | `/api/backup/tasks/:id/run` | 运维 | 触发备份 |
| `POST` | `/api/backup/tasks/:id/verify` | 运维 | 从任务触发验证 |
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/backup/tasks` | 列表 |
| `POST` | `/api/backup/tasks` | 创建 |
| `GET` | `/api/backup/tasks/:id` | 详情 |
| `PUT` | `/api/backup/tasks/:id` | 更新 |
| `DELETE` | `/api/backup/tasks/:id` | 删除 |
| `PUT` | `/api/backup/tasks/:id/toggle` | 启用 / 禁用 |
| `POST` | `/api/backup/tasks/:id/run` | 手动触发一次执行 |
任务导出会主动排除数据库密码与存储凭据,适合迁移和审阅,不是完整控制面备份。
## 备份记录
## 备份与恢复记录
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/backup/records` | 列表(支持筛选) |
| `GET` | `/api/backup/records/:id` | 记录详情 |
| `GET` | `/api/backup/records/:id/logs/stream` | 实时日志SSE |
| `GET` | `/api/backup/records/:id/download` | 下载备份产物 |
| `POST` | `/api/backup/records/:id/restore` | 恢复到原始源 |
| `DELETE` | `/api/backup/records/:id` | 删除记录 |
| `POST` | `/api/backup/records/batch-delete` | 批量删除 |
| 方法 | 端点 | 权限 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/backup/records` | 已认证 | 列出并筛选备份记录 |
| `POST` | `/api/backup/records/batch-delete` | 运维 | 批量删除记录 |
| `GET` | `/api/backup/records/:id` | 已认证 | 读取备份记录 |
| `GET` | `/api/backup/records/:id/logs/stream` | 已认证 | 通过 SSE 输出日志 |
| `GET` | `/api/backup/records/:id/download` | 已认证 | 下载产物 |
| `GET` | `/api/backup/records/:id/contents` | 已认证 | 浏览支持类型的产物内容 |
| `POST` | `/api/backup/records/:id/restore` | 运维 | 启动恢复 |
| `POST` | `/api/backup/records/:id/replicate` | 运维 | 复制已有产物 |
| `POST` | `/api/backup/records/:id/verify` | 运维 | 验证已有产物 |
| `PUT` | `/api/backup/records/:id/lock` | 运维 | 设置保留锁 |
| `DELETE` | `/api/backup/records/:id` | 运维 | 删除记录及受管产物 |
| `GET` | `/api/restore/records` | 已认证 | 恢复记录列表 |
| `GET` | `/api/restore/records/:id` | 已认证 | 恢复记录详情 |
| `GET` | `/api/restore/records/:id/logs/stream` | 已认证 | 恢复日志 SSE |
| `GET` | `/api/replication/records` | 已认证 | 复制记录列表 |
| `GET` | `/api/replication/records/:id` | 已认证 | 复制记录详情 |
| `GET` | `/api/verify/records` | 已认证 | 验证记录列表 |
| `GET` | `/api/verify/records/:id` | 已认证 | 验证记录详情 |
| `GET` | `/api/verify/records/:id/logs/stream` | 已认证 | 验证日志 SSE |
## 存储目标
## 模板、报表与仪表盘
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/storage-targets` | 列表 |
| `POST` | `/api/storage-targets` | 创建 |
| `GET` | `/api/storage-targets/:id` | 详情 |
| `PUT` | `/api/storage-targets/:id` | 更新 |
| `DELETE` | `/api/storage-targets/:id` | 删除 |
| `POST` | `/api/storage-targets/test` | 用待审核配置测试连接 |
| `POST` | `/api/storage-targets/:id/test` | 重测已保存的目标 |
| `PUT` | `/api/storage-targets/:id/star` | 切换收藏状态 |
| `GET` | `/api/storage-targets/:id/usage` | 查询远端存储用量(支持此能力的后端) |
| `GET` | `/api/storage-targets/rclone/backends` | 列出可用的 rclone 后端 |
| `POST` | `/api/storage-targets/google-drive/auth-url` | 启动 Google Drive OAuth |
| `POST` | `/api/storage-targets/google-drive/complete` | 完成 OAuth 流程 |
| 方法 | 端点 | 权限 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/task-templates` | 已认证 | 任务模板列表 |
| `GET` | `/api/task-templates/:id` | 已认证 | 读取任务模板 |
| `POST` | `/api/task-templates` | 运维 | 创建模板 |
| `PUT` | `/api/task-templates/:id` | 运维 | 更新模板 |
| `DELETE` | `/api/task-templates/:id` | 运维 | 删除模板 |
| `POST` | `/api/task-templates/:id/apply` | 运维 | 从模板创建任务 |
| `GET` | `/api/reports/compliance` | 已认证 | 合规证据 |
| `GET` | `/api/reports/compliance/export` | 已认证 | 导出合规 CSV |
| `GET` | `/api/dashboard/stats` | 已认证 | 汇总统计 |
| `GET` | `/api/dashboard/timeline` | 已认证 | 最近活动 |
| `GET` | `/api/dashboard/sla` | 已认证 | RPO 与 SLA 状态 |
| `GET` | `/api/dashboard/cluster` | 已认证 | 集群概览 |
| `GET` | `/api/dashboard/breakdown` | 已认证 | 任务与记录分布 |
| `GET` | `/api/dashboard/node-performance` | 已认证 | 节点性能 |
## 节点(集群)
## 通知、设置与管理
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/nodes` | 节点列表 |
| `POST` | `/api/nodes` | 创建节点并返回 Token |
| `GET` | `/api/nodes/:id` | 节点详情 |
| `PUT` | `/api/nodes/:id` | 重命名 |
| `DELETE` | `/api/nodes/:id` | 删除(有关联任务时会被拒绝) |
| `GET` | `/api/nodes/:id/fs/list` | 浏览目录(远程节点走 Agent 异步 RPC |
| 方法 | 端点 | 权限 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/notifications` | 已认证 | 通知渠道列表 |
| `GET` | `/api/notifications/:id` | 已认证 | 读取渠道 |
| `POST` | `/api/notifications` | 运维 | 创建渠道 |
| `PUT` | `/api/notifications/:id` | 运维 | 更新渠道 |
| `DELETE` | `/api/notifications/:id` | 运维 | 删除渠道 |
| `POST` | `/api/notifications/test` | 运维 | 测试未保存配置 |
| `POST` | `/api/notifications/:id/test` | 运维 | 测试已保存渠道 |
| `GET` | `/api/settings` | 已认证 | 读取系统设置 |
| `PUT` | `/api/settings` | 管理员 | 更新系统设置 |
| `GET` | `/api/users` | 管理员 | 用户列表 |
| `POST` | `/api/users` | 管理员 | 创建用户 |
| `PUT` | `/api/users/:id` | 管理员 | 更新用户 |
| `POST` | `/api/users/:id/2fa/reset` | 管理员 | 重置用户第二因素 |
| `DELETE` | `/api/users/:id` | 管理员 | 删除用户 |
| `GET` | `/api/api-keys` | 管理员 | API Key 列表,不返回明文 |
| `POST` | `/api/api-keys` | 管理员 | 创建 API Key明文仅返回一次 |
| `PUT` | `/api/api-keys/:id/toggle` | 管理员 | 启用或停用 API Key |
| `DELETE` | `/api/api-keys/:id` | 管理员 | 撤销 API Key |
## Agent 协议X-Agent-Token
## 审计、事件、搜索与发现
Agent CLI 专用端点,通过 `X-Agent-Token` 头认证而非 JWT。
| 方法 | 端点 | 权限 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/audit-logs` | 已认证 | 列出并筛选审计记录 |
| `GET` | `/api/audit-logs/export` | 已认证 | 导出审计记录 |
| `GET` | `/api/events/stream` | 已认证 | 通过 SSE 输出实时应用事件 |
| `GET` | `/api/search` | 已认证 | 搜索支持的资源 |
| `POST` | `/api/database/discover` | 已认证 | 按提供的连接信息发现数据库 |
| 方法 | 端点 | 说明 |
|------|------|------|
| `POST` | `/api/agent/heartbeat` | 上报心跳(返回节点 ID |
| `POST` | `/api/agent/commands/poll` | 领取一条待执行命令 |
| `POST` | `/api/agent/commands/:id/result` | 上报命令结果 |
| `GET` | `/api/agent/tasks/:id` | 拉取任务规格(含解密后的存储配置) |
| `POST` | `/api/agent/records/:id` | 追加日志 / 更新记录状态 |
## 节点
## 通知
| 方法 | 端点 | 权限 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/nodes` | 已认证 | 节点列表 |
| `GET` | `/api/nodes/:id` | 已认证 | 节点详情 |
| `GET` | `/api/nodes/:id/fs/list` | 运维 | 浏览所选节点文件系统 |
| `POST` | `/api/nodes` | 管理员 | 创建节点 |
| `POST` | `/api/nodes/batch` | 管理员 | 批量创建最多 50 个节点 |
| `PUT` | `/api/nodes/:id` | 管理员 | 更新节点 |
| `DELETE` | `/api/nodes/:id` | 管理员 | 删除未被引用的节点 |
| `POST` | `/api/nodes/:id/install-tokens` | 管理员 | 创建一次性安装器 |
| `GET` | `/api/nodes/:id/install-script-preview` | 管理员 | 预览安装材料 |
| `POST` | `/api/nodes/:id/rotate-token` | 管理员 | 轮换长期节点 Token |
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/notifications` | 列表 |
| `POST` | `/api/notifications` | 创建 |
| `GET` | `/api/notifications/:id` | 详情 |
| `PUT` | `/api/notifications/:id` | 更新 |
| `DELETE` | `/api/notifications/:id` | 删除 |
| `POST` | `/api/notifications/test` | 用待审核配置测试 |
| `POST` | `/api/notifications/:id/test` | 重测已保存的通知器 |
## Agent 协议
## 仪表盘
这些路由供 `backupx agent` 使用Handler 内部通过节点 Token 认证。
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/dashboard/stats` | 概览统计 |
| `GET` | `/api/dashboard/timeline` | 最近活动时间线 |
| 方法 | 端点 | 权限 | 说明 |
| --- | --- | --- | --- |
| `POST` | `/api/agent/heartbeat` | Agent | 上报心跳与节点状态 |
| `POST` | `/api/agent/commands/poll` | Agent | 领取待执行命令 |
| `POST` | `/api/agent/commands/:id/result` | Agent | 上报命令结果 |
| `GET` | `/api/agent/tasks/:id` | Agent | 获取可执行任务规格 |
| `POST` | `/api/agent/records/:id` | Agent | 追加日志或更新备份状态 |
| `PUT` | `/api/agent/records/:id/artifacts/:targetId` | Agent | 向 Master 流式中转产物 |
| `GET` | `/api/agent/restores/:id/spec` | Agent | 获取恢复指令 |
| `GET` | `/api/agent/restores/:id/artifact` | Agent | 流式读取恢复产物 |
| `POST` | `/api/agent/restores/:id` | Agent | 更新恢复状态 |
| `GET` | `/api/v1/agent/self` | Agent | 安装时校验节点身份 |
## 审计 / 系统 / 设置
## 公开运维与安装路由
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/audit-logs` | 审计日志 |
| `GET` | `/api/system/info` | 系统信息 |
| `GET` | `/api/system/update-check` | 检查新版本 |
| `GET` | `/api/settings` | 系统级设置 |
| `PUT` | `/api/settings` | 更新系统设置 |
| 方法 | 端点 | 权限 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/health` | 公开 | 存活检查 |
| `GET` | `/api/health` | 公开 | 带 API 前缀的存活别名 |
| `GET` | `/ready` | 公开 | SQLite 就绪检查 |
| `GET` | `/api/ready` | 公开 | 带 API 前缀的就绪别名 |
| `GET` | `/metrics` | 公开 | Prometheus 指标 |
| `GET` | `/install/:token` | 公开 | 消费一次性 Agent 安装令牌 |
| `GET` | `/api/install/:token` | 公开 | 带 API 前缀的安装路由 |
| `GET` | `/install/:token/compose.yml` | 公开 | 生成 Docker Agent Compose |
| `GET` | `/api/install/:token/compose.yml` | 公开 | 带 API 前缀的 Docker Compose 路由 |
## 响应结构
探针与指标应只对监控网段开放。安装 Token 是单次、限时秘密,不能写入公开日志。
成功响应统一为:
## 响应格式
大多数 JSON 成功响应为:
~~~json
```json
{
"code": "OK",
"message": "success",
"data": {}
"message": "",
"data": { /* */ }
}
~~~
```
错误使用 HTTP 4xx5xx并带稳定业务码
错误返回 HTTP 4xx/5xx并带
~~~json
```json
{
"code": "BACKUP_TASK_NOT_FOUND",
"message": "备份任务不存在"
"message": "备份任务不存在",
"data": null
}
~~~
客户端应按 HTTP 状态和 `code` 分支,不要依赖本地化的 `message`
产物下载、任务 JSON 导出、审计或合规导出、安装器响应和 `/metrics` 使用各自原生 Content-Type不使用 JSON Envelope。日志与事件流使用 `text/event-stream`,反向代理必须关闭响应缓冲。
```

View File

@@ -17,17 +17,15 @@ backupx --version
| 参数 | 说明 |
|------|------|
| `--config <path>` | 显式配置文件路径;省略时使用下方查找路径 |
| `--config <path>` | 配置文件路径(默认 `./config.yaml` |
| `--version` | 打印版本后退出 |
未提供 `--config` 时,服务端依次查找 `./config.yaml``./server/config.yaml``/etc/backupx/config.yaml``BACKUPX_*` 环境变量会覆盖对应服务端配置项,详见[配置参考](../deployment/configuration)。
## `backupx agent`
以 Agent 模式运行,连接到 Master。详见 [多节点集群](../features/multi-node)。
```bash
backupx agent --master https://backup.example.com --token-file /etc/backupx-agent/agent.token
backupx agent --master http://master:8340 --token <token>
```
| 参数 | 说明 |
@@ -35,15 +33,13 @@ backupx agent --master https://backup.example.com --token-file /etc/backupx-agen
| `--master <url>` | Master URL |
| `--token <token>` | Agent 认证令牌 |
| `--token-file <path>` | 从文件读取 Agent Token服务与容器部署推荐使用 |
| `--config <path>` | 加载 Agent YAML提供后不再加载基于环境变量的 Agent 配置 |
| `--temp-dir <path>` | 本地临时目录(默认 `/var/lib/backupx-agent/tmp` |
| `--config <path>` | YAML 配置文件(优先级高于环境变量) |
| `--temp-dir <path>` | 本地临时目录(默认 `/tmp/backupx-agent` |
| `--proxy-url <url>` | 显式 HTTP(S) 或 SOCKS5(H) 代理 |
| `--ca-cert <path>` | 用于校验 Master 的 PEM CA 证书 |
| `--insecure-tls` | 跳过 TLS 校验(仅测试用) |
Agent 配置优先级为显式 CLI 参数高于 YAML。未提供 `--config` 时,配置从 `BACKUPX_AGENT_MASTER``BACKUPX_AGENT_TOKEN``BACKUPX_AGENT_TOKEN_FILE``BACKUPX_AGENT_HEARTBEAT``BACKUPX_AGENT_POLL``BACKUPX_AGENT_TEMP_DIR``BACKUPX_AGENT_PROXY_URL``BACKUPX_AGENT_CA_CERT_FILE``BACKUPX_AGENT_INSECURE_TLS` 加载。未设置显式代理时Agent 还会遵循 `HTTP_PROXY``HTTPS_PROXY``NO_PROXY`
`--token` 优先于 `--token-file`。长期 Token 应放在仅 root 可读的文件中,不要进入命令历史。私有 CA 与 `--insecure-tls` 不能同时启用。
环境变量:`BACKUPX_AGENT_MASTER``BACKUPX_AGENT_TOKEN``BACKUPX_AGENT_TOKEN_FILE``BACKUPX_AGENT_HEARTBEAT``BACKUPX_AGENT_POLL``BACKUPX_AGENT_TEMP_DIR``BACKUPX_AGENT_PROXY_URL``BACKUPX_AGENT_CA_CERT_FILE``BACKUPX_AGENT_INSECURE_TLS`。未设置显式代理时Agent 同样遵循 `HTTP_PROXY``HTTPS_PROXY``NO_PROXY`
## `backupx backint`
@@ -61,8 +57,6 @@ backupx backint -f <function> -i <input> -o <output> -p <params>
| `-p <path>` | 参数文件 |
| `-u / -c / -l / -v` | 接收但忽略(兼容 SAP 约定) |
`-p` 参数文件必须定义 `STORAGE_TYPE`,并提供 `STORAGE_CONFIG_JSON``STORAGE_CONFIG`。可选项包括 `PARALLEL_FACTOR``COMPRESS``LOG_FILE``CATALOG_DB``KEY_PREFIX`
## `backupx reset-password`
直接在 SQLite 中重置管理员密码,无需重启服务。
@@ -76,5 +70,3 @@ backupx reset-password --username admin --password 'newpass123' [--config /path/
| `--username` | 目标用户名(默认 `admin` |
| `--password` | 新密码(最少 8 字符,必填) |
| `--config` | 配置文件路径(用于定位数据库文件) |
该命令应在可访问配置中 SQLite 路径的 Master 主机执行。不要把新密码直接写入长期保留的 shell 历史。

View File

@@ -1,14 +1,23 @@
{
"link.title.Documentation": {"message": "文档"},
"link.title.Operations": {"message": "运维"},
"link.title.Project": {"message": "项目"},
"link.title.Docs": {"message": "文档"},
"link.title.Features": {"message": "功能"},
"link.title.More": {"message": "更多"},
"link.title.Community": {"message": "社区"},
"link.title.Sponsors": {"message": "赞助商"},
"link.item.label.Introduction": {"message": "简介"},
"link.item.label.Quick Start": {"message": "快速开始"},
"link.item.label.Configuration": {"message": "配置"},
"link.item.label.Monitoring": {"message": "监控"},
"link.item.label.Security": {"message": "安全"},
"link.item.label.Troubleshooting": {"message": "故障排查"},
"link.item.label.Installation": {"message": "安装"},
"link.item.label.SAP HANA": {"message": "SAP HANA"},
"link.item.label.Multi-Node Cluster": {"message": "多节点集群"},
"link.item.label.API Reference": {"message": "API 参考"},
"link.item.label.GitHub": {"message": "GitHub"},
"link.item.label.Releases": {"message": "发布版本"},
"link.item.label.Community": {"message": "社区"}
"link.item.label.Releases": {"message": "Releases"},
"link.item.label.Docker Hub": {"message": "Docker Hub"},
"link.item.label.Issues": {"message": "Issues"},
"link.item.label.Contributors": {"message": "贡献者"},
"link.item.label.Pull Requests": {"message": "Pull Requests"},
"link.item.label.Sponsor": {"message": "赞助"},
"link.item.label.Sponsor BackupX": {"message": "赞助 BackupX"},
"link.item.label.Partnership": {"message": "合作伙伴"},
"link.item.label.Sponsor tiers": {"message": "赞助层级"}
}

View File

@@ -1,8 +1,22 @@
{
"item.label.Docs": {"message": "文档"},
"item.label.Deployment": {"message": "部署"},
"item.label.Operations": {"message": "运维"},
"item.label.API": {"message": "API"},
"item.label.Community": {"message": "社区"},
"item.label.GitHub": {"message": "GitHub"}
"item.label.Docs": {
"message": "文档",
"description": "Navbar item: Docs"
},
"item.label.Downloads": {
"message": "下载",
"description": "Navbar item: Downloads"
},
"item.label.Community": {
"message": "社区",
"description": "Navbar item: Community"
},
"item.label.Sponsors": {
"message": "赞助商",
"description": "Navbar item: Sponsors"
},
"item.label.GitHub": {
"message": "GitHub",
"description": "Navbar item: GitHub"
}
}

View File

@@ -25,7 +25,7 @@
"typescript": "~6.0.2"
},
"engines": {
"node": ">=24.0"
"node": ">=20.0"
}
},
"node_modules/@11ty/gray-matter": {

View File

@@ -44,6 +44,6 @@
]
},
"engines": {
"node": ">=24.0"
"node": ">=20.0"
}
}

View File

@@ -22,16 +22,6 @@ const sidebars: SidebarsConfig = {
'deployment/configuration',
],
},
{
type: 'category',
label: 'Operations',
items: [
'operations/upgrade-recovery',
'operations/security',
'operations/monitoring',
'operations/troubleshooting',
],
},
{
type: 'category',
label: 'Features',

View File

@@ -1,172 +0,0 @@
import type {ReactNode} from 'react';
export type DocIconName =
| 'arrowRight'
| 'bookOpen'
| 'box'
| 'check'
| 'clock'
| 'cloud'
| 'database'
| 'download'
| 'external'
| 'github'
| 'heart'
| 'monitor'
| 'network'
| 'restore'
| 'server'
| 'shield'
| 'storage'
| 'terminal'
| 'users'
| 'wrench';
type DocIconProps = {
name: DocIconName;
size?: number;
className?: string;
};
const ICON_PATHS: Record<Exclude<DocIconName, 'github'>, ReactNode> = {
arrowRight: (
<>
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</>
),
bookOpen: (
<>
<path d="M3 5.5A3.5 3.5 0 0 1 6.5 2H11v17H6.5A3.5 3.5 0 0 0 3 22Z" />
<path d="M21 5.5A3.5 3.5 0 0 0 17.5 2H13v17h4.5A3.5 3.5 0 0 1 21 22Z" />
</>
),
box: (
<>
<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z" />
<path d="m4.4 7.7 7.6 4.4 7.6-4.4" />
<path d="M12 12.1V21" />
</>
),
check: <path d="m5 12 4 4L19 6" />,
clock: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</>
),
cloud: <path d="M17.5 19H7a5 5 0 0 1-.6-9.96A6.5 6.5 0 0 1 18.7 8.2 5.5 5.5 0 0 1 17.5 19Z" />,
database: (
<>
<ellipse cx="12" cy="5" rx="8" ry="3" />
<path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5" />
<path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
</>
),
download: (
<>
<path d="M12 3v12" />
<path d="m7 10 5 5 5-5" />
<path d="M4 20h16" />
</>
),
external: (
<>
<path d="M14 4h6v6" />
<path d="m20 4-9 9" />
<path d="M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6" />
</>
),
heart: <path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z" />,
monitor: (
<>
<rect x="3" y="4" width="18" height="13" rx="1" />
<path d="M8 21h8" />
<path d="M12 17v4" />
</>
),
network: (
<>
<rect x="9" y="3" width="6" height="5" rx="1" />
<rect x="3" y="16" width="6" height="5" rx="1" />
<rect x="15" y="16" width="6" height="5" rx="1" />
<path d="M12 8v4M6 16v-4h12v4" />
</>
),
restore: (
<>
<path d="M4 7v5h5" />
<path d="M5.6 17a8 8 0 1 0 .4-10L4 9" />
<path d="M12 8v4l3 2" />
</>
),
server: (
<>
<rect x="3" y="4" width="18" height="6" rx="1" />
<rect x="3" y="14" width="18" height="6" rx="1" />
<path d="M7 7h.01M7 17h.01M11 7h7M11 17h7" />
</>
),
shield: (
<>
<path d="M12 3 20 6v6c0 5-3.3 8.2-8 9-4.7-.8-8-4-8-9V6Z" />
<path d="m8.5 12 2.2 2.2 4.8-5" />
</>
),
storage: (
<>
<path d="M4 6h16v12H4z" />
<path d="M8 10h8M8 14h5" />
</>
),
terminal: (
<>
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="m7 9 3 3-3 3M13 15h4" />
</>
),
users: (
<>
<circle cx="9" cy="8" r="3" />
<path d="M3 20a6 6 0 0 1 12 0" />
<path d="M16 5.2a3 3 0 0 1 0 5.6M17 14a5 5 0 0 1 4 4.9" />
</>
),
wrench: (
<>
<path d="M14.7 6.3a4 4 0 0 0-5-5L12 3.6 8.4 7.2 6.1 4.9a4 4 0 0 0 5 5L19 17.8a1.6 1.6 0 0 1-2.2 2.2l-7.9-7.9" />
</>
),
};
export default function DocIcon({name, size = 20, className}: DocIconProps): ReactNode {
if (name === 'github') {
return (
<svg
aria-hidden="true"
className={className}
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor">
<path d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49v-1.92c-2.78.62-3.37-1.21-3.37-1.21-.45-1.18-1.11-1.49-1.11-1.49-.91-.64.07-.63.07-.63 1 .08 1.53 1.06 1.53 1.06.89 1.57 2.34 1.11 2.91.85.09-.67.35-1.11.63-1.37-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05A9.3 9.3 0 0 1 12 6.95a9.3 9.3 0 0 1 2.5.35c1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.8-4.57 5.06.36.32.68.94.68 1.9v2.82c0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z" />
</svg>
);
}
return (
<svg
aria-hidden="true"
className={className}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round">
{ICON_PATHS[name]}
</svg>
);
}

View File

@@ -3,9 +3,14 @@ import {useEffect, useState} from 'react';
import Heading from '@theme/Heading';
import Translate from '@docusaurus/Translate';
import Link from '@docusaurus/Link';
import DocIcon, {type DocIconName} from '@site/src/components/DocIcon';
import styles from './styles.module.css';
type SponsorSlot = {
brand: ReactNode;
name: ReactNode;
href?: string;
};
type Contributor = {
login: string;
avatarUrl?: string;
@@ -23,26 +28,86 @@ type GitHubContributor = {
};
type CommunityPath = {
icon: DocIconName;
title: ReactNode;
description: ReactNode;
href: string;
};
type SponsorFocus = {
id: string;
icon: DocIconName;
label: ReactNode;
title: ReactNode;
description: ReactNode;
};
type SponsorTier = {
id: string;
name: ReactNode;
audience: ReactNode;
description: ReactNode;
};
const SPONSOR_SLOTS: SponsorSlot[] = [
{
brand: 'BackupX',
name: <Translate id="community.sponsor.logo.project">Project backer</Translate>,
href: 'https://github.com/sponsors/Awuqing',
},
{
brand: 'Cloud',
name: <Translate id="community.sponsor.logo.cloud">Cloud partner</Translate>,
},
{
brand: 'Object',
name: <Translate id="community.sponsor.logo.object">Object storage</Translate>,
},
{
brand: 'CDN',
name: <Translate id="community.sponsor.logo.cdn">CDN partner</Translate>,
},
{
brand: 'DB',
name: <Translate id="community.sponsor.logo.database">Database partner</Translate>,
},
{
brand: 'Security',
name: <Translate id="community.sponsor.logo.security">Security audit</Translate>,
},
{
brand: 'Agent',
name: <Translate id="community.sponsor.logo.agent">Remote node lab</Translate>,
},
{
brand: 'Docs',
name: <Translate id="community.sponsor.logo.docs">Docs sponsor</Translate>,
},
{
brand: 'Release',
name: <Translate id="community.sponsor.logo.release">Release sponsor</Translate>,
},
{
brand: 'S3',
name: <Translate id="community.sponsor.logo.s3">S3 compatible</Translate>,
},
{
brand: 'WebDAV',
name: <Translate id="community.sponsor.logo.webdav">WebDAV partner</Translate>,
},
{
brand: 'SFTP',
name: <Translate id="community.sponsor.logo.sftp">SFTP partner</Translate>,
},
{
brand: 'Docker',
name: <Translate id="community.sponsor.logo.docker">Container partner</Translate>,
},
{
brand: 'Mirror',
name: <Translate id="community.sponsor.logo.mirror">Mirror partner</Translate>,
},
{
brand: 'Restore',
name: <Translate id="community.sponsor.logo.restore">Restore drill</Translate>,
},
{
brand: 'QA',
name: <Translate id="community.sponsor.logo.qa">Test lab</Translate>,
},
{
brand: 'OSS',
name: <Translate id="community.sponsor.logo.oss">Open source</Translate>,
},
{
brand: 'Open Slot',
name: <Translate id="community.sponsor.logo.open">Sponsor slot open</Translate>,
},
];
const FALLBACK_CONTRIBUTORS: Contributor[] = [
{
@@ -61,69 +126,30 @@ const FALLBACK_CONTRIBUTORS: Contributor[] = [
const COMMUNITY_PATHS: CommunityPath[] = [
{
icon: 'wrench',
title: <Translate id="community.path.issues.title">Report production issues</Translate>,
description: <Translate id="community.path.issues.desc">Share logs, deployment topology, and restore expectations.</Translate>,
description: <Translate id="community.path.issues.desc">Share logs, deployment topology and restore expectations.</Translate>,
href: 'https://github.com/Awuqing/BackupX/issues',
},
{
icon: 'bookOpen',
title: <Translate id="community.path.docs.title">Improve docs and examples</Translate>,
description: <Translate id="community.path.docs.desc">Contribute deployment guides for storage, agents, and databases.</Translate>,
description: <Translate id="community.path.docs.desc">Contribute deployment guides for storage, agents and databases.</Translate>,
href: '/docs/development/contributing',
},
{
icon: 'github',
title: <Translate id="community.path.code.title">Ship focused pull requests</Translate>,
description: <Translate id="community.path.code.desc">Keep changes tested, reviewable, and aligned with the existing architecture.</Translate>,
title: <Translate id="community.path.code.title">Ship focused PRs</Translate>,
description: <Translate id="community.path.code.desc">Keep changes small, tested and aligned with the existing architecture.</Translate>,
href: 'https://github.com/Awuqing/BackupX/pulls',
},
];
const SPONSOR_FOCUS: SponsorFocus[] = [
{
id: 'infrastructure',
icon: 'cloud',
label: <Translate id="community.sponsor.infrastructure.label">Infrastructure</Translate>,
title: <Translate id="community.sponsor.infrastructure.title">Cloud and storage compatibility</Translate>,
description: <Translate id="community.sponsor.infrastructure.desc">Support validation across object storage, WebDAV, SFTP, and regional cloud providers.</Translate>,
},
{
id: 'reliability',
icon: 'shield',
label: <Translate id="community.sponsor.security.label">Reliability</Translate>,
title: <Translate id="community.sponsor.security.title">Security and recovery work</Translate>,
description: <Translate id="community.sponsor.security.desc">Fund encryption reviews, restore drills, release signing, and operational checks.</Translate>,
},
{
id: 'community',
icon: 'users',
label: <Translate id="community.sponsor.community.label">Community</Translate>,
title: <Translate id="community.sponsor.community.title">Documentation and contributor support</Translate>,
description: <Translate id="community.sponsor.community.desc">Improve guides, examples, platform testing, and the contributor experience.</Translate>,
},
];
const SPONSOR_TIERS: SponsorTier[] = [
{
id: 'backer',
name: <Translate id="community.sponsor.tier.backer.name">Backer</Translate>,
audience: <Translate id="community.sponsor.tier.backer.amount">For individuals and small teams</Translate>,
description: <Translate id="community.sponsor.tier.backer.desc">Supports documentation, issue triage, compatibility testing, and focused usability work.</Translate>,
},
{
id: 'partner',
name: <Translate id="community.sponsor.tier.partner.name">Partner</Translate>,
audience: <Translate id="community.sponsor.tier.partner.amount">For storage and infrastructure vendors</Translate>,
description: <Translate id="community.sponsor.tier.partner.desc">Supports provider validation, deployment examples, benchmarks, and integration guides.</Translate>,
},
{
id: 'enterprise',
name: <Translate id="community.sponsor.tier.enterprise.name">Enterprise</Translate>,
audience: <Translate id="community.sponsor.tier.enterprise.amount">For production BackupX operators</Translate>,
description: <Translate id="community.sponsor.tier.enterprise.desc">Funds recovery drills, release hardening, audits, and long-term maintenance.</Translate>,
},
];
function SponsorLogoCard({brand, name, href}: SponsorSlot) {
return (
<Link className={styles.sponsorLogoTile} to={href ?? 'https://github.com/sponsors/Awuqing'}>
<span className={styles.sponsorLogoMark}>{brand}</span>
<span className={styles.sponsorLogoName}>{name}</span>
</Link>
);
}
function getInitials(login: string): string {
return login
@@ -154,9 +180,11 @@ function useGitHubContributors(): Contributor[] {
useEffect(() => {
const controller = new AbortController();
fetch('https://api.github.com/repos/Awuqing/BackupX/contributors?per_page=8', {
fetch('https://api.github.com/repos/Awuqing/BackupX/contributors?per_page=12', {
signal: controller.signal,
headers: {Accept: 'application/vnd.github+json'},
headers: {
Accept: 'application/vnd.github+json',
},
})
.then(response => {
if (!response.ok) {
@@ -185,28 +213,28 @@ function useGitHubContributors(): Contributor[] {
return contributors;
}
function ContributorRow({login, avatarUrl, contributions, type, href}: Contributor): ReactNode {
function ContributorCard({login, avatarUrl, contributions, type, href}: Contributor) {
return (
<Link className={styles.contributorRow} to={href}>
<Link className={styles.contributorCard} to={href}>
{avatarUrl ? (
<img className={styles.avatarImage} src={avatarUrl} alt="" loading="lazy" />
) : (
<span className={styles.avatar} aria-hidden="true">{getInitials(login)}</span>
)}
<span className={styles.contributorBody}>
<span className={styles.contributorName}>{login}</span>
<span className={styles.contributorRole}>
<strong>{login}</strong>
<span>
{type === 'Bot' ? (
<Translate id="community.contributor.botRole">Automation contributor</Translate>
) : (
<Translate id="community.contributor.githubRole">GitHub contributor</Translate>
)}
</span>
</span>
<span className={styles.contributionCount}>
<Translate id="community.contributor.contributions" values={{count: contributions}}>
{'{count} contributions'}
</Translate>
<em>
<Translate id="community.contributor.contributions" values={{count: contributions}}>
{'{count} contributions'}
</Translate>
</em>
</span>
</Link>
);
@@ -214,54 +242,22 @@ function ContributorRow({login, avatarUrl, contributions, type, href}: Contribut
export function HomepageSponsors(): ReactNode {
return (
<div className={styles.sponsorProgram}>
<div className={styles.sponsorProgramHeader}>
<span className={styles.sponsorProgramIcon}><DocIcon name="heart" size={23} /></span>
<div className={styles.sponsorProgramCopy}>
<Heading as="h2" className={styles.sponsorProgramTitle}>
<Translate id="community.sponsor.title">Support reliable backup infrastructure</Translate>
</Heading>
<p>
<Translate id="community.sponsor.programDesc">
Sponsorship is directed toward test coverage, restore confidence, provider compatibility, and documentation that operators can apply directly.
</Translate>
</p>
</div>
<Link className={styles.sponsorAction} to="https://github.com/sponsors/Awuqing">
<DocIcon name="github" size={17} />
<div className={styles.sponsorWall}>
<div className={styles.sponsorWallHeader}>
<Heading as="h3" className={styles.sponsorWallTitle}>
<Translate id="community.sponsor.wallTitle">Sponsors</Translate>
</Heading>
<Link className={styles.sponsorWallAction} to="https://github.com/sponsors/Awuqing">
<Translate id="community.sponsor.cta">Sponsor BackupX</Translate>
<DocIcon name="external" size={15} />
<span aria-hidden="true">-&gt;</span>
</Link>
</div>
<div className={styles.sponsorFocusList}>
{SPONSOR_FOCUS.map(focus => (
<div key={focus.id} className={styles.sponsorFocusItem}>
<span className={styles.sponsorFocusIcon}><DocIcon name={focus.icon} size={20} /></span>
<span className={styles.sponsorFocusBody}>
<span className={styles.sponsorFocusLabel}>{focus.label}</span>
<span className={styles.sponsorFocusTitle}>{focus.title}</span>
<span className={styles.sponsorFocusDescription}>{focus.description}</span>
</span>
</div>
<div className={styles.sponsorLogoGrid}>
{SPONSOR_SLOTS.map((slot, index) => (
<SponsorLogoCard key={index} {...slot} />
))}
</div>
<div className={styles.tierSection}>
<div className={styles.tierSectionHeader}>
<Heading as="h3"><Translate id="community.sponsor.tier.title">Ways to support</Translate></Heading>
<p><Translate id="community.sponsor.tier.subtitle">Choose a level that matches how your team depends on BackupX.</Translate></p>
</div>
<div className={styles.tierGrid}>
{SPONSOR_TIERS.map(tier => (
<div key={tier.id} className={styles.tierItem}>
<span className={styles.tierName}>{tier.name}</span>
<span className={styles.tierAudience}>{tier.audience}</span>
<span className={styles.tierDescription}>{tier.description}</span>
</div>
))}
</div>
</div>
</div>
);
}
@@ -273,74 +269,59 @@ export default function HomepageCommunity(): ReactNode {
<section id="community" className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div>
<div className={styles.sectionTag}>
<Translate id="community.tag">Community</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="community.title">Operational knowledge improves in the open</Translate>
</Heading>
<div className={styles.sectionTag}>
<Translate id="community.tag">COMMUNITY</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="community.title">Built in the open, ready for long-term operators</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="community.subtitle">
Report real deployment constraints, improve the runbooks, or contribute a focused change with reproducible validation.
Backup software earns trust through transparent releases, real deployment feedback and a contributor path that stays practical.
</Translate>
</p>
</div>
<div className={styles.communityShell}>
<div className={styles.pathPanel}>
<HomepageSponsors />
<div className={styles.communityGrid}>
<div className={styles.panel}>
<div className={styles.panelHeader}>
<span><Translate id="community.path.kicker">Contribution paths</Translate></span>
<Link to="/docs/development/contributing">
<Translate id="community.path.guide">Contribution guide</Translate>
<DocIcon name="arrowRight" size={15} />
<span>
<Translate id="community.contributor.kicker">Contributors</Translate>
</span>
<Link to="https://github.com/Awuqing/BackupX/graphs/contributors">
<Translate id="community.contributor.all">View all</Translate>
</Link>
</div>
<div className={styles.panelNote}>
<Translate id="community.contributor.source">Loaded from GitHub contributors API in the browser.</Translate>
</div>
<div className={styles.contributorList}>
{contributors.map(contributor => (
<ContributorCard key={contributor.login} {...contributor} />
))}
</div>
</div>
<div className={styles.panel}>
<div className={styles.panelHeader}>
<span>
<Translate id="community.path.kicker">Contributor paths</Translate>
</span>
</div>
<div className={styles.pathList}>
{COMMUNITY_PATHS.map((path, index) => (
<Link key={path.href} className={styles.pathItem} to={path.href}>
<Link key={index} className={styles.pathItem} to={path.href}>
<span className={styles.pathIndex}>{String(index + 1).padStart(2, '0')}</span>
<span className={styles.pathIcon}><DocIcon name={path.icon} size={19} /></span>
<span className={styles.pathBody}>
<span className={styles.pathTitle}>{path.title}</span>
<span className={styles.pathDescription}>{path.description}</span>
<span>
<strong>{path.title}</strong>
<em>{path.description}</em>
</span>
<DocIcon name="arrowRight" size={17} className={styles.rowArrow} />
</Link>
))}
</div>
</div>
<div className={styles.contributorPanel}>
<div className={styles.panelHeader}>
<span><Translate id="community.contributor.kicker">Contributors</Translate></span>
<Link to="https://github.com/Awuqing/BackupX/graphs/contributors">
<Translate id="community.contributor.all">View all</Translate>
<DocIcon name="external" size={14} />
</Link>
</div>
<p className={styles.panelNote}>
<Translate id="community.contributor.source">Loaded from the GitHub contributors API with a local fallback.</Translate>
</p>
<div className={styles.contributorList}>
{contributors.slice(0, 5).map(contributor => (
<ContributorRow key={contributor.login} {...contributor} />
))}
</div>
</div>
<div className={styles.sponsorBand}>
<span className={styles.sponsorBandIcon}><DocIcon name="heart" size={20} /></span>
<span className={styles.sponsorBandBody}>
<span className={styles.sponsorBandTitle}><Translate id="community.sponsor.bandTitle">Support long-term maintenance</Translate></span>
<span className={styles.sponsorBandDescription}><Translate id="community.sponsor.bandDesc">Fund compatibility testing, recovery work, and operator-focused documentation.</Translate></span>
</span>
<Link to="/sponsors">
<Translate id="community.sponsor.learnMore">Sponsorship details</Translate>
<DocIcon name="arrowRight" size={16} />
</Link>
</div>
</div>
</div>
</section>

View File

@@ -1,436 +1,367 @@
.section {
background: var(--ifm-background-color);
padding: 5rem 0 5.5rem;
padding: 5.5rem 0 6rem;
background:
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.86) 100%),
var(--ifm-background-color);
}
[data-theme='dark'] .section {
background:
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
var(--ifm-background-color);
}
.sectionHead {
align-items: end;
display: grid;
gap: 3rem;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.7fr);
margin-bottom: 2.25rem;
max-width: 760px;
margin: 0 auto 2.5rem;
text-align: center;
}
.sectionTag {
color: var(--ifm-color-primary);
display: inline-flex;
align-items: center;
min-height: 28px;
margin-bottom: 1rem;
padding: 4px 10px;
color: #00a870;
background: rgba(0, 180, 42, 0.1);
border: 1px solid rgba(0, 180, 42, 0.18);
border-radius: 8px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.06em;
margin-bottom: 0.75rem;
text-transform: uppercase;
font-weight: 750;
letter-spacing: 0;
}
.sectionTitle {
margin: 0 0 1rem;
color: var(--ifm-heading-color);
font-size: clamp(1.9rem, 4vw, 2.55rem);
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1.18;
margin: 0;
max-width: 720px;
font-size: 2.35rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.2;
}
.sectionSubtitle {
margin: 0;
color: var(--ifm-color-content-secondary);
font-size: 1rem;
font-size: 1.04rem;
line-height: 1.7;
margin: 0;
}
.communityShell,
.sponsorProgram {
border: 1px solid var(--bx-border);
border-radius: 4px;
.sponsorWall {
overflow: hidden;
margin-bottom: 1rem;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
}
.communityShell {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(310px, 0.8fr);
[data-theme='dark'] .sponsorWall {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.pathPanel,
.contributorPanel {
min-width: 0;
}
.pathPanel {
border-right: 1px solid var(--bx-border);
}
.panelHeader {
align-items: center;
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
.sponsorWallHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
justify-content: space-between;
min-height: 50px;
padding: 10px 14px;
min-height: 60px;
padding: 0 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.panelHeader > span {
[data-theme='dark'] .sponsorWallHeader {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.sponsorWallTitle {
position: relative;
margin: 0;
padding-left: 14px;
color: var(--ifm-heading-color);
font-size: 13px;
font-weight: 500;
font-size: 1.05rem;
font-weight: 750;
letter-spacing: 0;
}
.panelHeader a {
align-items: center;
color: var(--ifm-color-primary);
display: inline-flex;
font-size: 12px;
gap: 5px;
text-decoration: none !important;
}
.pathList,
.contributorList {
display: grid;
}
.pathItem {
align-items: center;
border-bottom: 1px solid var(--bx-border);
color: inherit;
display: grid;
gap: 11px;
grid-template-columns: auto auto minmax(0, 1fr) auto;
min-height: 100px;
padding: 13px 14px;
text-decoration: none !important;
}
.pathItem:last-child {
border-bottom: 0;
}
.pathItem:hover,
.pathItem:focus-visible,
.contributorRow:hover,
.contributorRow:focus-visible {
background: var(--bx-surface-selected);
color: inherit;
}
.pathIndex {
color: var(--ifm-color-content-secondary);
font-family: var(--ifm-font-family-monospace);
font-size: 11px;
}
.pathIcon,
.sponsorBandIcon,
.sponsorProgramIcon,
.sponsorFocusIcon {
align-items: center;
background: var(--bx-surface-selected);
border: 1px solid color-mix(in srgb, var(--ifm-color-primary) 25%, var(--bx-border));
.sponsorWallTitle::before {
position: absolute;
top: 50%;
left: 0;
width: 3px;
height: 18px;
content: "";
background: #52c41a;
border-radius: 3px;
color: var(--ifm-color-primary);
transform: translateY(-50%);
}
.sponsorWallAction {
display: inline-flex;
justify-content: center;
}
.pathIcon {
height: 36px;
width: 36px;
}
.pathBody,
.contributorBody,
.sponsorBandBody,
.sponsorFocusBody {
display: grid;
min-width: 0;
}
.pathBody {
gap: 3px;
}
.pathTitle,
.contributorName,
.sponsorBandTitle,
.sponsorFocusTitle,
.tierName {
color: var(--ifm-heading-color);
font-weight: 500;
}
.pathTitle {
font-size: 14px;
}
.pathDescription {
color: var(--ifm-color-content-secondary);
font-size: 12px;
line-height: 1.55;
}
.rowArrow {
color: var(--ifm-color-content-secondary);
}
.pathItem:hover .rowArrow,
.pathItem:focus-visible .rowArrow {
color: var(--ifm-color-primary);
}
.panelNote {
border-bottom: 1px solid var(--bx-border);
color: var(--ifm-color-content-secondary);
font-size: 12px;
line-height: 1.5;
margin: 0;
padding: 10px 14px;
}
.contributorRow {
align-items: center;
border-bottom: 1px solid var(--bx-border);
color: inherit;
display: grid;
gap: 10px;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 62px;
padding: 9px 14px;
text-decoration: none !important;
}
.contributorRow:last-child {
border-bottom: 0;
}
.avatar,
.avatarImage {
border-radius: 4px;
height: 34px;
width: 34px;
}
.avatar {
align-items: center;
background: var(--ifm-color-primary);
color: #ffffff;
display: inline-flex;
font-size: 11px;
justify-content: center;
}
.avatarImage {
border: 1px solid var(--bx-border);
object-fit: cover;
}
.contributorBody {
gap: 1px;
}
.contributorName {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contributorRole,
.contributionCount {
color: var(--ifm-color-content-secondary);
font-size: 11px;
}
.contributionCount {
white-space: nowrap;
}
.sponsorBand {
align-items: center;
background: var(--bx-surface-subtle);
border-top: 1px solid var(--bx-border);
display: grid;
gap: 12px;
grid-column: 1 / -1;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 82px;
padding: 13px 14px;
}
.sponsorBandIcon {
height: 38px;
width: 38px;
}
.sponsorBandBody {
gap: 2px;
}
.sponsorBandTitle {
font-size: 14px;
}
.sponsorBandDescription {
color: var(--ifm-color-content-secondary);
font-size: 12px;
line-height: 1.5;
}
.sponsorBand > a,
.sponsorAction {
align-items: center;
border: 1px solid var(--bx-border-strong);
border-radius: 4px;
color: var(--ifm-color-primary);
display: inline-flex;
font-size: 13px;
gap: 6px;
min-height: 38px;
padding: 0 11px;
min-height: 36px;
padding: 0 12px;
color: #52c41a;
background: rgba(82, 196, 26, 0.08);
border: 1px solid rgba(82, 196, 26, 0.2);
border-radius: 8px;
font-size: 13px;
font-weight: 700;
text-decoration: none !important;
white-space: nowrap;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.sponsorBand > a:hover,
.sponsorBand > a:focus-visible,
.sponsorAction:hover,
.sponsorAction:focus-visible {
background: var(--bx-surface-selected);
border-color: var(--ifm-color-primary);
color: var(--ifm-color-primary);
.sponsorWallAction:hover,
.sponsorWallAction:focus-visible {
color: #389e0d;
background: rgba(82, 196, 26, 0.14);
border-color: #52c41a;
transform: translateY(-1px);
}
.sponsorProgramHeader {
align-items: center;
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
.sponsorLogoGrid {
display: grid;
gap: 14px;
grid-template-columns: auto minmax(0, 1fr) auto;
padding: 1.4rem;
grid-template-columns: repeat(6, minmax(0, 1fr));
background: var(--ifm-color-emphasis-200);
gap: 1px;
padding: 1px;
}
.sponsorProgramIcon {
height: 44px;
width: 44px;
[data-theme='dark'] .sponsorLogoGrid {
background: rgba(255, 255, 255, 0.08);
}
.sponsorProgramCopy {
min-width: 0;
}
.sponsorProgramTitle {
color: var(--ifm-heading-color);
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 4px;
}
.sponsorProgramCopy p {
color: var(--ifm-color-content-secondary);
font-size: 0.9rem;
line-height: 1.55;
margin: 0;
max-width: 740px;
}
.sponsorFocusList {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sponsorFocusItem {
display: grid;
gap: 11px;
grid-template-columns: auto minmax(0, 1fr);
min-height: 190px;
padding: 1.4rem;
}
.sponsorFocusItem + .sponsorFocusItem {
border-left: 1px solid var(--bx-border);
}
.sponsorFocusIcon {
height: 36px;
width: 36px;
}
.sponsorFocusBody {
align-content: start;
gap: 5px;
}
.sponsorFocusLabel {
color: var(--ifm-color-primary);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.sponsorFocusTitle {
font-size: 14px;
line-height: 1.4;
}
.sponsorFocusDescription,
.tierDescription {
color: var(--ifm-color-content-secondary);
font-size: 12px;
line-height: 1.6;
}
.tierSection {
border-top: 1px solid var(--bx-border);
}
.tierSectionHeader {
align-items: baseline;
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
.sponsorLogoTile {
display: flex;
gap: 14px;
justify-content: space-between;
padding: 14px;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 106px;
padding: 14px 10px;
flex-direction: column;
color: inherit;
background: var(--ifm-background-color);
text-align: center;
text-decoration: none !important;
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.tierSectionHeader h3 {
color: var(--ifm-heading-color);
font-size: 14px;
font-weight: 500;
margin: 0;
[data-theme='dark'] .sponsorLogoTile {
background: rgba(15, 17, 21, 0.78);
}
.tierSectionHeader p {
.sponsorLogoTile:hover,
.sponsorLogoTile:focus-visible {
z-index: 1;
color: inherit;
background: rgba(82, 196, 26, 0.04);
box-shadow: inset 0 0 0 1px rgba(82, 196, 26, 0.5);
transform: translateY(-1px);
}
.sponsorLogoMark {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
color: var(--ifm-color-primary);
font-size: 1.45rem;
font-weight: 850;
letter-spacing: 0;
line-height: 1.1;
}
.sponsorLogoTile:nth-child(2n) .sponsorLogoMark {
color: #ff7d00;
}
.sponsorLogoTile:nth-child(3n) .sponsorLogoMark {
color: #14c9c9;
}
.sponsorLogoTile:nth-child(4n) .sponsorLogoMark {
color: #722ed1;
}
.sponsorLogoTile:nth-child(5n) .sponsorLogoMark {
color: #52c41a;
}
.sponsorLogoName {
display: block;
max-width: 100%;
margin-top: 10px;
color: var(--ifm-color-content-secondary);
font-size: 12px;
margin: 0;
text-align: right;
font-size: 0.86rem;
font-weight: 600;
line-height: 1.35;
}
.tierGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
.panel {
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
}
.tierItem {
[data-theme='dark'] .panel {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.communityGrid {
display: grid;
gap: 5px;
min-height: 150px;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.panel {
min-width: 0;
padding: 1.25rem;
}
.tierItem + .tierItem {
border-left: 1px solid var(--bx-border);
}
.tierName {
font-size: 15px;
}
.tierAudience {
color: var(--ifm-color-primary);
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
text-transform: uppercase;
}
.panelHeader a {
color: var(--ifm-color-primary);
text-decoration: none !important;
}
.panelNote {
margin: -0.35rem 0 1rem;
color: var(--ifm-color-content-secondary);
font-size: 0.82rem;
line-height: 1.5;
}
.contributorList,
.pathList {
display: grid;
gap: 10px;
}
.contributorCard,
.pathItem {
display: grid;
min-width: 0;
color: inherit;
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
text-decoration: none !important;
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.contributorCard:hover,
.contributorCard:focus-visible,
.pathItem:hover,
.pathItem:focus-visible {
color: inherit;
background: var(--ifm-background-color);
border-color: var(--ifm-color-primary);
transform: translateY(-1px);
}
[data-theme='dark'] .contributorCard,
[data-theme='dark'] .pathItem {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
}
.contributorCard {
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 12px;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
color: #fff;
background: #165dff;
border-radius: 8px;
font-size: 13px;
font-weight: 800;
}
.avatarImage {
width: 44px;
height: 44px;
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
object-fit: cover;
}
.contributorCard:nth-child(2) .avatar {
background: #00a870;
}
.contributorCard:nth-child(3) .avatar {
background: #ff7d00;
}
.contributorBody {
display: grid;
min-width: 0;
gap: 2px;
}
.contributorBody strong {
color: var(--ifm-heading-color);
font-size: 0.98rem;
}
.contributorBody span {
color: var(--ifm-color-content);
font-size: 0.88rem;
}
.contributorBody em,
.pathItem em {
color: var(--ifm-color-content-secondary);
font-size: 0.82rem;
font-style: normal;
line-height: 1.45;
}
.pathItem {
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
padding: 14px;
}
.pathIndex {
color: var(--ifm-color-primary);
font-family: var(--ifm-font-family-monospace);
font-size: 0.86rem;
font-weight: 800;
}
.pathItem strong {
display: block;
margin-bottom: 4px;
color: var(--ifm-heading-color);
font-size: 0.96rem;
}
@media (max-width: 996px) {
@@ -438,35 +369,20 @@
padding: 4rem 0;
}
.sectionHead {
align-items: start;
gap: 1rem;
.sectionTitle {
font-size: 2rem;
}
.communityGrid {
grid-template-columns: 1fr;
}
.communityShell {
grid-template-columns: 1fr;
.sponsorLogoGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.pathPanel {
border-bottom: 1px solid var(--bx-border);
border-right: 0;
}
.sponsorFocusList,
.tierGrid {
grid-template-columns: 1fr;
}
.sponsorFocusItem,
.tierItem {
min-height: 0;
}
.sponsorFocusItem + .sponsorFocusItem,
.tierItem + .tierItem {
border-left: 0;
border-top: 1px solid var(--bx-border);
.sponsorLogoTile {
min-height: 96px;
}
}
@@ -475,35 +391,39 @@
padding: 3.25rem 0;
}
.pathItem {
grid-template-columns: auto minmax(0, 1fr) auto;
.sectionTitle {
font-size: 1.75rem;
}
.pathIndex,
.contributionCount {
display: none;
.sponsorWallHeader {
display: grid;
min-height: auto;
padding: 1rem;
}
.sponsorBand,
.sponsorProgramHeader {
align-items: start;
grid-template-columns: auto minmax(0, 1fr);
}
.sponsorBand > a,
.sponsorAction {
grid-column: 1 / -1;
.sponsorWallAction {
justify-content: center;
width: 100%;
}
.tierSectionHeader {
align-items: flex-start;
flex-direction: column;
gap: 2px;
.sponsorLogoGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tierSectionHeader p {
text-align: left;
.sponsorLogoMark {
font-size: 1.15rem;
}
.panel {
padding: 1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.sponsorWallAction,
.sponsorLogoTile,
.contributorCard,
.pathItem {
transition: none;
}
}

View File

@@ -1,87 +1,169 @@
import type {ReactNode} from 'react';
import Link from '@docusaurus/Link';
import Translate from '@docusaurus/Translate';
import Heading from '@theme/Heading';
import DocIcon, {type DocIconName} from '@site/src/components/DocIcon';
import Translate from '@docusaurus/Translate';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
type DocumentationPath = {
icon: DocIconName;
type FeatureItem = {
title: ReactNode;
description: ReactNode;
to: string;
icon: ReactNode;
link?: string;
};
const DOCUMENTATION_PATHS: DocumentationPath[] = [
const DatabaseIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5v6c0 1.66 4 3 9 3s9-1.34 9-3V5" />
<path d="M3 11v6c0 1.66 4 3 9 3s9-1.34 9-3v-6" />
</svg>
);
const CloudIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z" />
</svg>
);
const ClockIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
const NetworkIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="6" rx="1" />
<rect x="2" y="16" width="6" height="6" rx="1" />
<rect x="16" y="16" width="6" height="6" rx="1" />
<path d="M12 8v4" />
<path d="M12 12H5v4" />
<path d="M12 12h7v4" />
</svg>
);
const ShieldIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 2l9 4v6c0 5-3.5 9.5-9 10-5.5-.5-9-5-9-10V6l9-4z" />
<polyline points="9 12 11 14 15 10" />
</svg>
);
const RocketIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z" />
<path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
</svg>
);
const FEATURES: FeatureItem[] = [
{
icon: 'box',
title: <Translate id="feat.install.title">Install and upgrade</Translate>,
description: <Translate id="feat.install.desc">Choose Docker, Compose, or a standalone binary and keep the deployment repeatable.</Translate>,
to: '/docs/getting-started/installation',
title: <Translate id="feat.types.title">Many Backup Types</Translate>,
description: (
<Translate id="feat.types.desc">
Files and directories with multi-path sources, plus MySQL, PostgreSQL, SQLite, and SAP HANA all in one place.
</Translate>
),
icon: <DatabaseIcon />,
link: '/docs/features/backup-types',
},
{
icon: 'database',
title: <Translate id="feat.types.title">Protect files and databases</Translate>,
description: <Translate id="feat.types.desc">Configure file, MySQL, PostgreSQL, SQLite, and SAP HANA backup workloads.</Translate>,
to: '/docs/features/backup-types',
title: <Translate id="feat.storage.title">70+ Storage Backends</Translate>,
description: (
<Translate id="feat.storage.desc">
Native Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP plus SFTP, Azure Blob, Dropbox and more via rclone.
</Translate>
),
icon: <CloudIcon />,
link: '/docs/features/storage-backends',
},
{
icon: 'storage',
title: <Translate id="feat.storage.title">Connect storage targets</Translate>,
description: <Translate id="feat.storage.desc">Use native providers or any supported rclone backend through one consistent flow.</Translate>,
to: '/docs/features/storage-backends',
title: <Translate id="feat.scheduling.title">Scheduling & Retention</Translate>,
description: (
<Translate id="feat.scheduling.desc">
Cron-based schedules with a visual editor and auto-retention (by days or count), plus empty-directory cleanup.
</Translate>
),
icon: <ClockIcon />,
},
{
icon: 'network',
title: <Translate id="feat.cluster.title">Build a remote-node cluster</Translate>,
description: <Translate id="feat.cluster.desc">Deploy outbound-only agents through proxies, private CAs, or SSH bastion hosts.</Translate>,
to: '/docs/features/multi-node',
title: <Translate id="feat.cluster.title">Multi-Node Cluster</Translate>,
description: (
<Translate id="feat.cluster.desc">
Outbound-only Master-Agent polling with proxy, private-CA, and SSH-bastion support. No reverse connectivity is required.
</Translate>
),
icon: <NetworkIcon />,
link: '/docs/features/multi-node',
},
{
icon: 'monitor',
title: <Translate id="feat.monitor.title">Monitor daily operations</Translate>,
description: <Translate id="feat.monitor.desc">Track task health, storage capacity, notifications, logs, and service readiness.</Translate>,
to: '/docs/operations/monitoring',
title: <Translate id="feat.security.title">Secure by Default</Translate>,
description: (
<Translate id="feat.security.desc">
JWT auth, bcrypt passwords, AES-256-GCM encrypted config, optional backup encryption, and a full audit log.
</Translate>
),
icon: <ShieldIcon />,
},
{
icon: 'restore',
title: <Translate id="feat.recovery.title">Recover with a tested plan</Translate>,
description: <Translate id="feat.recovery.desc">Prepare upgrades, rollback points, restore validation, and incident troubleshooting.</Translate>,
to: '/docs/operations/upgrade-recovery',
title: <Translate id="feat.deploy.title">Painless Deployment</Translate>,
description: (
<Translate id="feat.deploy.desc">
Single static binary with embedded SQLite. Docker one-click or bare-metal zero external dependencies.
</Translate>
),
icon: <RocketIcon />,
link: '/docs/getting-started/installation',
},
];
function Feature({title, description, icon, link}: FeatureItem) {
const content = (
<>
<div className={styles.iconWrap}>{icon}</div>
<Heading as="h3" className={styles.featureTitle}>{title}</Heading>
<p className={styles.featureDesc}>{description}</p>
{link && (
<span className={styles.featureLink}>
<Translate id="feat.learnMore">Learn more</Translate>
<span className={styles.featureArrow} aria-hidden="true">-&gt;</span>
</span>
)}
</>
);
if (link) {
return (
<Link to={link} className={styles.featureCardLink}>
{content}
</Link>
);
}
return <div className={styles.featureCard}>{content}</div>;
}
export default function HomepageFeatures(): ReactNode {
return (
<section className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div>
<div className={styles.sectionTag}>
<Translate id="section.features.tag">Documentation paths</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="section.features.title">Move from deployment to recovery without guesswork</Translate>
</Heading>
<div className={styles.sectionTag}>
<Translate id="section.features.tag">FEATURES</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="section.features.title">Everything you need, nothing you don't</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="section.features.subtitle">
Each path connects product capability to the configuration, security, and operational decisions required in production.
Battle-tested building blocks backup runners, storage providers, scheduling, and clustering.
</Translate>
</p>
</div>
<div className={styles.pathGrid}>
{DOCUMENTATION_PATHS.map((path, index) => (
<Link key={path.to} to={path.to} className={styles.pathItem}>
<span className={styles.pathIndex}>{String(index + 1).padStart(2, '0')}</span>
<span className={styles.pathIcon}><DocIcon name={path.icon} size={21} /></span>
<span className={styles.pathBody}>
<span className={styles.pathTitle}>{path.title}</span>
<span className={styles.pathDescription}>{path.description}</span>
</span>
<DocIcon name="arrowRight" size={18} className={styles.pathArrow} />
</Link>
<div className={styles.grid}>
{FEATURES.map((feat, idx) => (
<Feature key={idx} {...feat} />
))}
</div>
</div>

View File

@@ -1,171 +1,169 @@
.section {
padding: 5.5rem 0 4.25rem;
background: var(--ifm-background-color);
padding: 5rem 0;
}
.sectionHead {
align-items: end;
display: grid;
gap: 3rem;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.7fr);
margin-bottom: 2.25rem;
text-align: center;
max-width: 720px;
margin: 0 auto 3rem;
}
.sectionTag {
color: var(--ifm-color-primary);
display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.06em;
margin-bottom: 0.75rem;
text-transform: uppercase;
font-weight: 750;
letter-spacing: 0;
color: var(--ifm-color-primary);
padding: 4px 12px;
background: rgba(22, 93, 255, 0.08);
border: 1px solid rgba(22, 93, 255, 0.16);
border-radius: 8px;
margin-bottom: 1rem;
}
[data-theme='dark'] .sectionTag {
background: rgba(96, 126, 255, 0.18);
color: var(--ifm-color-primary-lighter);
}
.sectionTitle {
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
font-size: clamp(1.9rem, 4vw, 2.55rem);
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1.18;
margin: 0;
max-width: 710px;
}
.sectionSubtitle {
font-size: 1.05rem;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
font-size: 1rem;
line-height: 1.7;
margin: 0;
}
.pathGrid {
border: 1px solid var(--bx-border);
border-radius: 4px;
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: hidden;
}
.pathItem {
align-items: start;
border-bottom: 1px solid var(--bx-border);
color: inherit;
display: grid;
gap: 12px;
grid-template-columns: auto auto minmax(0, 1fr) auto;
min-height: 154px;
padding: 1.4rem;
text-decoration: none !important;
}
.pathItem:nth-child(odd) {
border-right: 1px solid var(--bx-border);
}
.pathItem:nth-last-child(-n + 2) {
border-bottom: 0;
}
.pathItem:hover,
.pathItem:focus-visible {
background: var(--bx-surface-subtle);
color: inherit;
}
.pathIndex {
color: var(--ifm-color-content-secondary);
font-family: var(--ifm-font-family-monospace);
font-size: 11px;
line-height: 38px;
}
.pathIcon {
align-items: center;
background: var(--bx-surface-selected);
border: 1px solid color-mix(in srgb, var(--ifm-color-primary) 25%, var(--bx-border));
border-radius: 3px;
color: var(--ifm-color-primary);
display: inline-flex;
height: 38px;
justify-content: center;
width: 38px;
}
.pathBody {
display: grid;
gap: 7px;
min-width: 0;
padding-top: 7px;
}
.pathTitle {
color: var(--ifm-heading-color);
font-size: 1rem;
font-weight: 500;
line-height: 1.35;
}
.pathDescription {
color: var(--ifm-color-content-secondary);
font-size: 0.9rem;
line-height: 1.65;
}
.pathArrow {
color: var(--ifm-color-content-secondary);
margin-top: 10px;
}
.pathItem:hover .pathArrow,
.pathItem:focus-visible .pathArrow {
color: var(--ifm-color-primary);
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
}
@media (max-width: 996px) {
.section {
padding: 4rem 0;
padding: 3.5rem 0 2rem;
}
.sectionHead {
align-items: start;
gap: 1rem;
.sectionTitle {
font-size: 2rem;
}
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.pathGrid {
grid-template-columns: 1fr;
}
.pathItem,
.pathItem:nth-child(odd),
.pathItem:nth-last-child(-n + 2) {
border-bottom: 1px solid var(--bx-border);
border-right: 0;
}
.pathItem:last-child {
border-bottom: 0;
@media (min-width: 997px) and (max-width: 1200px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 520px) {
.section {
padding: 3.25rem 0;
}
.featureCard,
.featureCardLink {
position: relative;
display: flex;
flex-direction: column;
padding: 1.75rem;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
text-decoration: none !important;
color: inherit;
height: 100%;
}
.pathItem {
gap: 10px;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 0;
padding: 1rem;
}
.featureCardLink:hover {
transform: translateY(-2px);
border-color: var(--ifm-color-primary);
box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18);
color: inherit;
}
.pathIndex {
display: none;
}
[data-theme='dark'] .featureCard,
[data-theme='dark'] .featureCardLink {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
}
.pathBody {
padding-top: 6px;
[data-theme='dark'] .featureCardLink:hover {
background: rgba(64, 128, 255, 0.05);
border-color: var(--ifm-color-primary);
box-shadow: 0 12px 30px -8px rgba(64, 128, 255, 0.25);
}
.iconWrap {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(20, 201, 201, 0.12) 100%);
color: var(--ifm-color-primary);
margin-bottom: 1.25rem;
}
[data-theme='dark'] .iconWrap {
background: linear-gradient(135deg, rgba(96, 126, 255, 0.15) 0%, rgba(20, 201, 201, 0.12) 100%);
color: var(--ifm-color-primary-lighter);
}
.featureTitle {
font-size: 1.15rem;
font-weight: 700;
margin: 0 0 0.6rem;
color: var(--ifm-heading-color);
letter-spacing: 0;
}
.featureDesc {
font-size: 0.95rem;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
margin: 0;
flex: 1;
}
.featureLink {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 1rem;
font-size: 13px;
font-weight: 500;
color: var(--ifm-color-primary);
}
.featureArrow {
transition: transform 0.2s ease;
}
.featureCardLink:hover .featureArrow {
transform: translateX(4px);
}
@media (max-width: 640px) {
.sectionTitle {
font-size: 1.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.featureCard,
.featureCardLink,
.featureArrow {
transition: none;
}
}

View File

@@ -1,103 +0,0 @@
import type {ReactNode} from 'react';
import Link from '@docusaurus/Link';
import Translate, {translate} from '@docusaurus/Translate';
import Heading from '@theme/Heading';
import DocIcon, {type DocIconName} from '@site/src/components/DocIcon';
import styles from './styles.module.css';
type GuideLink = {
icon: DocIconName;
title: ReactNode;
description: ReactNode;
to: string;
};
const GUIDE_LINKS: GuideLink[] = [
{
icon: 'box',
title: <Translate id="home.guide.install.title">Install BackupX</Translate>,
description: <Translate id="home.guide.install.desc">Docker, Compose, or a standalone binary</Translate>,
to: '/docs/getting-started/installation',
},
{
icon: 'network',
title: <Translate id="home.guide.cluster.title">Connect remote nodes</Translate>,
description: <Translate id="home.guide.cluster.desc">Agents, proxies, private CAs, and bastion hosts</Translate>,
to: '/docs/features/multi-node',
},
{
icon: 'shield',
title: <Translate id="home.guide.security.title">Harden operations</Translate>,
description: <Translate id="home.guide.security.desc">Security controls, monitoring, and audit trails</Translate>,
to: '/docs/operations/security',
},
{
icon: 'restore',
title: <Translate id="home.guide.recovery.title">Prepare recovery</Translate>,
description: <Translate id="home.guide.recovery.desc">Upgrade, rollback, restore, and troubleshoot</Translate>,
to: '/docs/operations/upgrade-recovery',
},
];
export default function HomepageHero(): ReactNode {
return (
<header className={styles.hero}>
<div className={`container ${styles.heroGrid}`}>
<div className={styles.heroContent}>
<div className={styles.badge}>
<DocIcon name="bookOpen" size={16} />
<Translate id="home.badge">BackupX documentation · v2.2.1</Translate>
</div>
<Heading as="h1" className={styles.heroTitle}>
<span className={styles.heroTitleLine}><Translate id="home.title.part1">Operate BackupX</Translate></span>
<span className={styles.heroTitleAccent}><Translate id="home.title.part2">with confidence.</Translate></span>
</Heading>
<p className={styles.heroSubtitle}>
<Translate id="home.tagline">
Deploy the control plane, connect storage and remote agents, then keep backups observable and recoverable with one practical guide set.
</Translate>
</p>
<div className={styles.actions}>
<Link className={styles.primaryAction} to="/docs/getting-started/quick-start">
<DocIcon name="terminal" size={18} />
<Translate id="home.getStarted">Start with Docker</Translate>
<DocIcon name="arrowRight" size={17} />
</Link>
<Link className={styles.secondaryAction} to="https://github.com/Awuqing/BackupX">
<DocIcon name="github" size={18} />
<Translate id="home.viewSource">View source</Translate>
</Link>
</div>
<div className={styles.supported} aria-label={translate({id: 'home.supported.label', message: 'Supported environments'})}>
<span><DocIcon name="check" size={16} /><Translate id="home.supported.docker">Docker</Translate></span>
<span><DocIcon name="check" size={16} /><Translate id="home.supported.linux">Linux</Translate></span>
<span><DocIcon name="check" size={16} /><Translate id="home.supported.windows">Windows agents</Translate></span>
</div>
</div>
<nav className={styles.guidePanel} aria-label={translate({id: 'home.guide.label', message: 'Recommended documentation paths'})}>
<div className={styles.guidePanelHeader}>
<span><Translate id="home.guide.kicker">Start here</Translate></span>
<span><Translate id="home.guide.hint">Choose a guide for the next task</Translate></span>
</div>
<div className={styles.guideList}>
{GUIDE_LINKS.map(guide => (
<Link key={guide.to} className={styles.guideLink} to={guide.to}>
<span className={styles.guideIcon}><DocIcon name={guide.icon} size={20} /></span>
<span className={styles.guideBody}>
<span className={styles.guideTitle}>{guide.title}</span>
<span className={styles.guideDescription}>{guide.description}</span>
</span>
<DocIcon name="arrowRight" size={17} className={styles.guideArrow} />
</Link>
))}
</div>
</nav>
</div>
</header>
);
}

View File

@@ -1,273 +0,0 @@
.hero {
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
padding: 5.5rem 0 4.75rem;
}
.heroGrid {
align-items: center;
display: grid;
gap: 4.5rem;
grid-template-columns: minmax(0, 1fr) minmax(410px, 0.88fr);
}
.heroContent {
align-items: flex-start;
display: flex;
flex-direction: column;
}
.badge {
align-items: center;
background: var(--bx-surface-selected);
border: 1px solid color-mix(in srgb, var(--ifm-color-primary) 28%, var(--bx-border));
border-radius: 3px;
color: var(--ifm-color-primary);
display: inline-flex;
font-size: 13px;
font-weight: 400;
gap: 7px;
margin-bottom: 1.5rem;
min-height: 30px;
padding: 4px 9px;
}
.heroTitle {
color: var(--ifm-heading-color);
font-size: clamp(2.55rem, 5vw, 3.7rem);
font-weight: 500;
letter-spacing: -0.035em;
line-height: 1.06;
margin: 0;
max-width: 720px;
}
.heroTitleLine,
.heroTitleAccent {
display: block;
}
.heroTitleLine {
color: var(--ifm-heading-color);
}
.heroTitleAccent {
color: var(--ifm-color-primary);
}
.heroSubtitle {
color: var(--ifm-color-content-secondary);
font-size: 1.08rem;
line-height: 1.72;
margin: 1.5rem 0 0;
max-width: 650px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 1.75rem;
}
.primaryAction,
.secondaryAction {
align-items: center;
border: 1px solid var(--ifm-color-primary);
border-radius: 4px;
display: inline-flex;
font-size: 14px;
font-weight: 400;
gap: 8px;
justify-content: center;
min-height: 44px;
padding: 0 15px;
text-decoration: none !important;
}
.primaryAction {
background: var(--ifm-color-primary);
color: #ffffff;
}
.primaryAction:hover,
.primaryAction:focus-visible {
background: var(--ifm-color-primary-dark);
border-color: var(--ifm-color-primary-dark);
color: #ffffff;
}
.secondaryAction {
background: var(--ifm-background-color);
border-color: var(--bx-border-strong);
color: var(--ifm-color-content);
}
.secondaryAction:hover,
.secondaryAction:focus-visible {
background: var(--bx-surface-selected);
border-color: var(--ifm-color-primary);
color: var(--ifm-color-primary);
}
.supported {
color: var(--ifm-color-content-secondary);
display: flex;
flex-wrap: wrap;
font-size: 13px;
gap: 10px 18px;
margin-top: 1.4rem;
}
.supported span {
align-items: center;
display: inline-flex;
gap: 5px;
}
.supported svg {
color: var(--bx-success);
}
.guidePanel {
background: var(--ifm-background-color);
border: 1px solid var(--bx-border-strong);
border-radius: 4px;
overflow: hidden;
}
.guidePanelHeader {
align-items: baseline;
border-bottom: 1px solid var(--bx-border);
display: flex;
gap: 12px;
justify-content: space-between;
padding: 14px 16px;
}
.guidePanelHeader span:first-child {
color: var(--ifm-heading-color);
font-size: 14px;
font-weight: 500;
}
.guidePanelHeader span:last-child {
color: var(--ifm-color-content-secondary);
font-size: 12px;
text-align: right;
}
.guideList {
display: grid;
}
.guideLink {
align-items: center;
border-bottom: 1px solid var(--bx-border);
color: inherit;
display: grid;
gap: 12px;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 82px;
padding: 13px 16px;
text-decoration: none !important;
}
.guideLink:last-child {
border-bottom: 0;
}
.guideLink:hover,
.guideLink:focus-visible {
background: var(--bx-surface-selected);
color: inherit;
}
.guideIcon {
align-items: center;
background: var(--bx-surface-subtle);
border: 1px solid var(--bx-border);
border-radius: 3px;
color: var(--ifm-color-primary);
display: inline-flex;
height: 38px;
justify-content: center;
width: 38px;
}
.guideBody {
display: grid;
gap: 2px;
min-width: 0;
}
.guideTitle {
color: var(--ifm-heading-color);
font-size: 14px;
font-weight: 500;
}
.guideDescription {
color: var(--ifm-color-content-secondary);
font-size: 12px;
line-height: 1.5;
}
.guideArrow {
color: var(--ifm-color-content-secondary);
}
.guideLink:hover .guideArrow,
.guideLink:focus-visible .guideArrow {
color: var(--ifm-color-primary);
}
@media (max-width: 996px) {
.hero {
padding: 4.25rem 0 3.75rem;
}
.heroGrid {
gap: 2.75rem;
grid-template-columns: 1fr;
}
.heroContent {
max-width: 760px;
}
}
@media (max-width: 640px) {
.hero {
padding: 3.25rem 0 2.75rem;
}
.heroTitle {
font-size: 2.35rem;
}
.heroSubtitle {
font-size: 1rem;
}
.actions,
.primaryAction,
.secondaryAction {
width: 100%;
}
.guidePanelHeader {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
.guidePanelHeader span:last-child {
text-align: left;
}
.guideLink {
min-height: 78px;
padding: 12px;
}
}

View File

@@ -2,18 +2,15 @@ import type {ReactNode} from 'react';
import {useState} from 'react';
import clsx from 'clsx';
import Heading from '@theme/Heading';
import Translate, {translate} from '@docusaurus/Translate';
import Translate from '@docusaurus/Translate';
import useBaseUrl from '@docusaurus/useBaseUrl';
import Link from '@docusaurus/Link';
import DocIcon, {type DocIconName} from '@site/src/components/DocIcon';
import styles from './styles.module.css';
type Tab = {
id: string;
label: ReactNode;
icon: DocIconName;
image: string;
imageAlt: string;
title: ReactNode;
description: ReactNode;
};
@@ -23,9 +20,7 @@ function useTabs(): Tab[] {
{
id: 'dashboard',
label: <Translate id="showcase.tab.dashboard">Dashboard</Translate>,
icon: 'monitor',
image: useBaseUrl('/img/screenshots/dashboard.png'),
imageAlt: translate({id: 'showcase.dashboard.alt', message: 'BackupX dashboard showing backup health and storage usage'}),
title: <Translate id="showcase.dashboard.title">Know at a glance</Translate>,
description: (
<Translate id="showcase.dashboard.desc">
@@ -36,9 +31,7 @@ function useTabs(): Tab[] {
{
id: 'tasks',
label: <Translate id="showcase.tab.tasks">Backup Tasks</Translate>,
icon: 'database',
image: useBaseUrl('/img/screenshots/backup-tasks.png'),
imageAlt: translate({id: 'showcase.tasks.alt', message: 'BackupX backup task management screen'}),
title: <Translate id="showcase.tasks.title">Visual task editor</Translate>,
description: (
<Translate id="showcase.tasks.desc">
@@ -49,9 +42,7 @@ function useTabs(): Tab[] {
{
id: 'storage',
label: <Translate id="showcase.tab.storage">Storage Targets</Translate>,
icon: 'storage',
image: useBaseUrl('/img/screenshots/storage-targets.png'),
imageAlt: translate({id: 'showcase.storage.alt', message: 'BackupX storage target management screen'}),
title: <Translate id="showcase.storage.title">70+ backends, one flow</Translate>,
description: (
<Translate id="showcase.storage.desc">
@@ -62,9 +53,7 @@ function useTabs(): Tab[] {
{
id: 'nodes',
label: <Translate id="showcase.tab.nodes">Multi-Node</Translate>,
icon: 'network',
image: useBaseUrl('/img/screenshots/nodes.png'),
imageAlt: translate({id: 'showcase.nodes.alt', message: 'BackupX remote node management screen'}),
title: <Translate id="showcase.nodes.title">Master-Agent in minutes</Translate>,
description: (
<Translate id="showcase.nodes.desc">
@@ -83,55 +72,45 @@ export default function HomepageShowcase(): ReactNode {
<section className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div>
<div className={styles.sectionTag}>
<Translate id="showcase.tag">Product interface</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="showcase.title">See the workflow before you deploy</Translate>
</Heading>
<div className={styles.sectionTag}>
<Translate id="showcase.tag">PRODUCT</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="showcase.title">A polished console, not a DIY script</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="showcase.subtitle">
Screenshots stay connected to the guide that explains the underlying task, configuration, and operating model.
Every screen designed for day-2 operations visibility first, configuration second.
</Translate>
</p>
</div>
<div className={styles.tabs} role="tablist" aria-label={translate({id: 'showcase.tabs.label', message: 'BackupX product screens'})}>
<div className={styles.tabs}>
{tabs.map(tab => (
<button
key={tab.id}
id={`showcase-tab-${tab.id}`}
type="button"
role="tab"
aria-selected={active === tab.id}
aria-controls="showcase-panel"
className={clsx(styles.tabBtn, active === tab.id && styles.tabBtnActive)}
onClick={() => setActive(tab.id)}>
<DocIcon name={tab.icon} size={17} />
{tab.label}
</button>
))}
</div>
<div
id="showcase-panel"
role="tabpanel"
aria-labelledby={`showcase-tab-${current.id}`}
className={styles.stage}>
<div className={styles.preview}>
<div className={styles.previewBar}>
<span><DocIcon name="monitor" size={16} /><Translate id="showcase.preview.label">BackupX console</Translate></span>
<code>backupx.local</code>
<div className={styles.stage}>
<div className={styles.browser}>
<div className={styles.browserBar}>
<span className={clsx(styles.browserDot, styles.browserDotRed)} />
<span className={clsx(styles.browserDot, styles.browserDotYellow)} />
<span className={clsx(styles.browserDot, styles.browserDotGreen)} />
<div className={styles.browserUrl}>backupx.local</div>
</div>
<img src={current.image} alt={current.imageAlt} className={styles.screenshot} />
<img src={current.image} alt="" className={styles.screenshot} />
</div>
<div className={styles.caption}>
<div className={styles.captionKicker}>{current.label}</div>
<Heading as="h3" className={styles.captionTitle}>{current.title}</Heading>
<p className={styles.captionDesc}>{current.description}</p>
<Link to="/docs/getting-started/quick-start" className={styles.captionLink}>
<Translate id="showcase.cta">Explore the docs</Translate>
<DocIcon name="arrowRight" size={17} />
<span aria-hidden="true"> -&gt;</span>
</Link>
</div>
</div>

View File

@@ -1,224 +1,247 @@
.section {
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
border-top: 1px solid var(--bx-border);
padding: 5rem 0;
padding: 4.5rem 0 5.5rem;
background:
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.72) 100%),
var(--ifm-background-color);
}
[data-theme='dark'] .section {
background:
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
var(--ifm-background-color);
}
.sectionHead {
align-items: end;
display: grid;
gap: 3rem;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.7fr);
margin-bottom: 2.25rem;
text-align: center;
max-width: 720px;
margin: 0 auto 2.5rem;
}
.sectionTag {
color: var(--ifm-color-primary);
display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.06em;
margin-bottom: 0.75rem;
text-transform: uppercase;
font-weight: 750;
letter-spacing: 0;
color: #0e7490;
padding: 4px 12px;
background: rgba(20, 201, 201, 0.1);
border: 1px solid rgba(20, 201, 201, 0.2);
border-radius: 8px;
margin-bottom: 1rem;
}
[data-theme='dark'] .sectionTag {
background: rgba(20, 201, 201, 0.16);
color: #67e8f9;
}
.sectionTitle {
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
font-size: clamp(1.9rem, 4vw, 2.55rem);
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1.18;
margin: 0;
max-width: 700px;
}
.sectionSubtitle {
font-size: 1.05rem;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
font-size: 1rem;
line-height: 1.7;
margin: 0;
}
/* Tab bar */
.tabs {
border: 1px solid var(--bx-border);
border-bottom: 0;
border-radius: 4px 4px 0 0;
display: flex;
gap: 4px;
overflow-x: auto;
justify-content: center;
gap: 6px;
margin-bottom: 2rem;
flex-wrap: wrap;
padding: 6px;
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
}
.tabBtn {
align-items: center;
min-height: 40px;
padding: 8px 18px;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
border-radius: 8px;
color: var(--ifm-color-content-secondary);
cursor: pointer;
display: inline-flex;
flex: 1 0 auto;
font-size: 14px;
font-weight: 400;
gap: 7px;
justify-content: center;
min-height: 40px;
padding: 7px 14px;
font-weight: 650;
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.tabBtn:hover {
background: var(--ifm-background-color);
border-color: var(--bx-border);
color: var(--ifm-color-primary);
background: var(--ifm-background-color);
}
.tabBtnActive,
.tabBtnActive:hover {
background: var(--bx-surface-selected);
border-color: color-mix(in srgb, var(--ifm-color-primary) 30%, var(--bx-border));
color: var(--ifm-color-primary);
background: var(--ifm-background-color);
color: var(--ifm-color-primary) !important;
border-color: rgba(22, 93, 255, 0.18);
box-shadow: 0 6px 16px rgba(22, 93, 255, 0.12);
}
/* Stage */
.stage {
background: var(--ifm-background-color);
border: 1px solid var(--bx-border);
border-radius: 0 0 4px 4px;
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(300px, 0.74fr);
overflow: hidden;
}
.preview {
border-right: 1px solid var(--bx-border);
min-width: 0;
}
.previewBar {
grid-template-columns: 1.4fr 1fr;
gap: 3rem;
align-items: center;
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
color: var(--ifm-color-content-secondary);
display: flex;
font-size: 12px;
justify-content: space-between;
min-height: 42px;
padding: 8px 12px;
}
.previewBar span {
align-items: center;
display: inline-flex;
gap: 7px;
@media (max-width: 996px) {
.stage {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
.previewBar code {
.browser {
background: var(--ifm-background-color);
border: 1px solid var(--bx-border);
border-radius: 3px;
border-radius: 8px;
overflow: hidden;
box-shadow:
0 24px 58px -22px rgba(22, 93, 255, 0.28),
0 0 0 1px var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .browser {
box-shadow:
0 30px 60px -20px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
.browserBar {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
background: var(--ifm-color-emphasis-100);
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .browserBar {
background: rgba(255, 255, 255, 0.03);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.browserDot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.browserDotRed { background: #ff5f56; }
.browserDotYellow { background: #ffbd2e; }
.browserDotGreen { background: #27c93f; }
.browserUrl {
margin: 0 auto;
padding: 3px 14px;
background: var(--ifm-background-color);
border-radius: 8px;
font-size: 12px;
color: var(--ifm-color-content-secondary);
font-size: 11px;
padding: 2px 7px;
font-family: 'SFMono-Regular', Menlo, monospace;
border: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .browserUrl {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.06);
}
.screenshot {
background: var(--ifm-background-color);
display: block;
height: auto;
width: 100%;
height: auto;
background: var(--ifm-color-emphasis-100);
}
.caption {
align-content: center;
display: grid;
padding: 2rem;
padding: 0 1rem;
}
.captionKicker {
color: var(--ifm-color-primary);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
text-transform: uppercase;
@media (max-width: 996px) {
.caption {
padding: 0;
}
}
.captionTitle {
font-size: 1.7rem;
line-height: 1.2;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
font-size: 1.65rem;
font-weight: 500;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 0 0 0.9rem;
}
.captionDesc {
color: var(--ifm-color-content-secondary);
font-size: 0.96rem;
font-size: 1.05rem;
line-height: 1.7;
color: var(--ifm-color-content-secondary);
margin: 0 0 1.25rem;
}
.captionLink {
align-items: center;
color: var(--ifm-color-primary);
display: inline-flex;
font-size: 14px;
font-weight: 500;
gap: 6px;
justify-self: start;
align-items: center;
gap: 4px;
min-height: 40px;
padding: 0 12px;
border: 1px solid rgba(22, 93, 255, 0.18);
border-radius: 8px;
font-weight: 650;
color: var(--ifm-color-primary);
text-decoration: none !important;
transition: border-color 0.2s ease, background 0.2s ease;
}
.captionLink:hover,
.captionLink:focus-visible {
.captionLink:hover {
color: var(--ifm-color-primary-dark);
background: rgba(22, 93, 255, 0.06);
border-color: var(--ifm-color-primary);
}
@media (max-width: 996px) {
.section {
padding: 4rem 0;
}
.sectionHead {
align-items: start;
gap: 1rem;
grid-template-columns: 1fr;
}
.stage {
grid-template-columns: 1fr;
}
.preview {
border-bottom: 1px solid var(--bx-border);
border-right: 0;
}
.caption {
padding: 1.5rem;
.sectionTitle {
font-size: 2rem;
}
}
@media (max-width: 640px) {
.section {
padding: 3.25rem 0;
padding: 3.25rem 0 4rem;
}
.sectionTitle {
font-size: 1.75rem;
}
.tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
justify-content: stretch;
}
.tabBtn {
min-width: 0;
padding: 7px 8px;
}
.previewBar code {
display: none;
}
.caption {
padding: 1.25rem;
flex: 1 1 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.tabBtn,
.captionLink {
transition: none;
}
}

View File

@@ -1,120 +1,122 @@
/**
* BackupX documentation UI
* Flat, operational and consistent with the product interface.
* BackupX 官方文档站样式
* 灵感Ant Design / Arco Design
*/
:root {
--ifm-color-primary: #175cd3;
--ifm-color-primary-dark: #1452bd;
--ifm-color-primary-darker: #124bad;
--ifm-color-primary-darkest: #0e3c8a;
--ifm-color-primary-light: #2a6bd7;
--ifm-color-primary-lighter: #3978dc;
--ifm-color-primary-lightest: #6d9be5;
/* Primary palette (Arco blue) */
--ifm-color-primary: #165dff;
--ifm-color-primary-dark: #0e4fe6;
--ifm-color-primary-darker: #0b4bd9;
--ifm-color-primary-darkest: #093eb3;
--ifm-color-primary-light: #2f6cff;
--ifm-color-primary-lighter: #3d75ff;
--ifm-color-primary-lightest: #668eff;
/* Surfaces */
--ifm-background-color: #ffffff;
--ifm-background-surface-color: #ffffff;
--ifm-color-emphasis-100: #f6f7f9;
--ifm-color-emphasis-200: #e4e7ec;
--ifm-color-emphasis-300: #d0d5dd;
--ifm-color-emphasis-400: #98a2b3;
--ifm-color-emphasis-600: #475467;
--ifm-color-content: #202939;
--ifm-color-content-secondary: #596579;
--ifm-heading-color: #111827;
--ifm-color-emphasis-100: #f5f7fa;
--ifm-color-emphasis-200: #e5e6eb;
--ifm-color-emphasis-300: #c9cdd4;
--ifm-color-emphasis-400: #a9aeb8;
--ifm-font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--ifm-font-family-monospace: ui-monospace, 'SFMono-Regular', Menlo, Monaco, Consolas, monospace;
--ifm-font-weight-bold: 500;
--ifm-heading-font-weight: 500;
--ifm-h1-font-size: 2.15rem;
--ifm-h2-font-size: 1.6rem;
--ifm-h3-font-size: 1.24rem;
--ifm-line-height-base: 1.72;
/* Typography */
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--ifm-font-family-monospace: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--ifm-heading-font-weight: 700;
--ifm-code-font-size: 92%;
--ifm-global-radius: 4px;
--ifm-global-shadow-lw: none;
--ifm-global-shadow-md: none;
--ifm-global-shadow-tl: none;
--ifm-container-width: 1160px;
--ifm-container-width-xl: 1280px;
--ifm-h1-font-size: 2.25rem;
--ifm-h2-font-size: 1.75rem;
--ifm-h3-font-size: 1.35rem;
--ifm-line-height-base: 1.7;
--ifm-navbar-height: 58px;
--ifm-navbar-background-color: #ffffff;
--ifm-navbar-link-color: #475467;
--ifm-navbar-link-hover-color: #175cd3;
--ifm-navbar-shadow: none;
--ifm-color-content: #1d2129;
--ifm-color-content-secondary: #4e5969;
--ifm-heading-color: #1d2129;
--ifm-global-radius: 8px;
--ifm-menu-color: #475467;
--ifm-menu-color-background-active: #eef4ff;
--ifm-menu-color-background-hover: #f6f7f9;
--ifm-toc-border-color: transparent;
/* Navbar */
--ifm-navbar-height: 64px;
--ifm-navbar-background-color: rgba(255, 255, 255, 0.9);
--ifm-navbar-link-color: #4e5969;
--ifm-navbar-link-hover-color: var(--ifm-color-primary);
--ifm-code-background: #f2f4f7;
--docusaurus-highlighted-code-line-bg: #eaf1fb;
/* Sidebar */
--ifm-menu-color: #4e5969;
--ifm-menu-color-background-active: rgba(22, 93, 255, 0.08);
--ifm-menu-color-background-hover: var(--ifm-color-emphasis-100);
--bx-border: #e4e7ec;
--bx-border-strong: #d0d5dd;
--bx-surface-subtle: #f8f9fb;
--bx-surface-selected: #eef4ff;
--bx-success: #168453;
--bx-warning: #b54708;
/* Code */
--ifm-code-background: rgba(22, 93, 255, 0.06);
--docusaurus-highlighted-code-line-bg: rgba(22, 93, 255, 0.08);
/* Hero background helper (consumed in index.module.css) */
--bx-hero-bg: transparent;
}
[data-theme='dark'] {
--ifm-color-primary: #84adff;
--ifm-color-primary-dark: #6f9df5;
--ifm-color-primary-darker: #5f8fe9;
--ifm-color-primary-darkest: #4776c9;
--ifm-color-primary-light: #98bbff;
--ifm-color-primary-lighter: #aac7ff;
--ifm-color-primary-lightest: #c7d8ff;
--ifm-color-primary: #4080ff;
--ifm-color-primary-dark: #3371f2;
--ifm-color-primary-darker: #2c6ae6;
--ifm-color-primary-darkest: #2359c7;
--ifm-color-primary-light: #5a93ff;
--ifm-color-primary-lighter: #74a5ff;
--ifm-color-primary-lightest: #9dbfff;
--ifm-background-color: #0f141c;
--ifm-background-surface-color: #151b24;
--ifm-color-emphasis-100: #1a222d;
--ifm-color-emphasis-200: #273240;
--ifm-color-emphasis-300: #344054;
--ifm-color-emphasis-400: #667085;
--ifm-color-emphasis-600: #98a2b3;
--ifm-color-content: #d0d5dd;
--ifm-color-content-secondary: #98a2b3;
--ifm-heading-color: #f2f4f7;
--ifm-background-color: #0f1115;
--ifm-background-surface-color: #16181d;
--ifm-color-emphasis-100: #1d2129;
--ifm-color-emphasis-200: #272e3b;
--ifm-color-emphasis-300: #384252;
--ifm-color-emphasis-400: #4e5969;
--ifm-navbar-background-color: #0f141c;
--ifm-navbar-link-color: #c7ced8;
--ifm-menu-color: #c7ced8;
--ifm-menu-color-background-active: #1c2c48;
--ifm-menu-color-background-hover: #1a222d;
--ifm-color-content: #e6e9ef;
--ifm-color-content-secondary: #9aa3b2;
--ifm-heading-color: #f0f2f5;
--ifm-code-background: #1c2531;
--docusaurus-highlighted-code-line-bg: #213352;
--ifm-navbar-background-color: rgba(15, 17, 21, 0.9);
--ifm-navbar-link-color: #c9d1db;
--bx-border: #273240;
--bx-border-strong: #344054;
--bx-surface-subtle: #131a23;
--bx-surface-selected: #1c2c48;
--bx-success: #6ce9a6;
--bx-warning: #fec84b;
--ifm-menu-color: #c9d1db;
--ifm-menu-color-background-active: rgba(64, 128, 255, 0.15);
--ifm-menu-color-background-hover: rgba(255, 255, 255, 0.04);
--ifm-code-background: rgba(64, 128, 255, 0.14);
--docusaurus-highlighted-code-line-bg: rgba(64, 128, 255, 0.18);
}
html {
scroll-padding-top: calc(var(--ifm-navbar-height) + 20px);
/* Frosted-glass navbar */
.navbar {
backdrop-filter: saturate(180%) blur(10px);
-webkit-backdrop-filter: saturate(180%) blur(10px);
border-bottom: 1px solid var(--ifm-color-emphasis-200);
box-shadow: none;
}
body {
text-rendering: optimizeLegibility;
[data-theme='dark'] .navbar {
border-bottom-color: rgba(255, 255, 255, 0.06);
}
strong,
b {
.navbar__title {
font-weight: 700;
letter-spacing: 0;
}
.navbar__link {
font-weight: 500;
font-size: 14px;
}
.navbar__link,
.button,
a {
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
a,
button,
.button {
transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
border-radius: 8px;
font-weight: 650;
}
:focus-visible {
@@ -122,371 +124,132 @@ button,
outline-offset: 2px;
}
.button {
border-radius: 4px;
font-weight: 400;
}
.button--primary {
color: #ffffff;
}
.navbar {
border-bottom: 1px solid var(--bx-border);
}
.navbar__brand {
gap: 8px;
margin-right: 1.5rem;
}
.navbar__logo {
height: 28px;
}
.navbar__title {
font-size: 1rem;
font-weight: 500;
letter-spacing: 0;
}
.navbar__link {
font-size: 14px;
font-weight: 400;
}
.navbar__item.dropdown .navbar__link::after {
border-width: 0 1px 1px 0;
height: 6px;
margin-left: 7px;
transform: rotate(45deg) translateY(-2px);
width: 6px;
}
.dropdown__menu {
background: var(--ifm-background-surface-color);
border: 1px solid var(--bx-border);
border-radius: 4px;
padding: 4px;
}
.dropdown__link {
border-radius: 2px;
font-size: 14px;
}
.navbar-sidebar {
background: var(--ifm-background-color);
}
.navbar-sidebar__brand {
border-bottom: 1px solid var(--bx-border);
}
.theme-doc-sidebar-container {
border-right: 1px solid var(--bx-border) !important;
}
.theme-doc-sidebar-menu {
padding: 1rem 0.75rem 2rem;
}
.menu__list-item-collapsible {
border-radius: 4px;
}
/* Sidebar tweaks */
.menu__link {
border-radius: 3px;
font-size: 14px;
font-weight: 400;
line-height: 1.45;
padding: 7px 10px;
border-radius: 8px;
padding: 6px 10px;
line-height: 1.4;
}
.menu__link--active,
.menu__link--active:hover {
color: var(--ifm-color-primary);
font-weight: 500;
font-weight: 600;
}
.menu__caret::before,
.menu__link--sublist-caret::after {
background-size: 1.15rem 1.15rem;
.theme-doc-sidebar-container {
border-right: 1px solid var(--ifm-color-emphasis-200) !important;
}
.breadcrumbs {
align-items: center;
font-size: 13px;
margin-bottom: 1.5rem;
}
.breadcrumbs__link {
background: transparent;
border-radius: 2px;
color: var(--ifm-color-content-secondary);
padding: 3px 5px;
}
.breadcrumbs__item--active .breadcrumbs__link {
color: var(--ifm-color-content);
}
.theme-doc-markdown {
max-width: 860px;
}
.theme-doc-markdown header + h1,
.theme-doc-markdown > h1:first-child {
font-size: clamp(1.9rem, 4vw, 2.35rem);
line-height: 1.2;
margin-bottom: 1rem;
}
.markdown > p:first-of-type {
color: var(--ifm-color-content-secondary);
font-size: 1.04rem;
[data-theme='dark'] .theme-doc-sidebar-container {
border-right-color: rgba(255, 255, 255, 0.06) !important;
}
/* Article: better heading rhythm */
.markdown h2 {
border-top: 1px solid var(--bx-border);
margin-top: 2.75rem;
padding-top: 1.1rem;
margin-top: 2.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .markdown h2 {
border-top-color: rgba(255, 255, 255, 0.06);
}
.markdown h3 {
margin-top: 2rem;
}
.markdown h2,
.markdown h3,
.markdown h4 {
font-weight: 500;
letter-spacing: 0;
}
.markdown a:not(.hash-link) {
text-decoration-color: color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
text-underline-offset: 3px;
}
.markdown ul,
.markdown ol {
padding-left: 1.35rem;
}
.markdown li + li {
margin-top: 0.35rem;
}
.markdown blockquote {
background: var(--bx-surface-subtle);
border: 1px solid var(--bx-border);
border-radius: 4px;
color: var(--ifm-color-content-secondary);
margin: 1.5rem 0;
padding: 0.9rem 1rem;
}
.markdown blockquote > :last-child {
margin-bottom: 0;
}
/* Tables */
.markdown table {
border: 1px solid var(--bx-border);
border-collapse: separate;
border-radius: 4px;
border-spacing: 0;
display: table;
border-radius: 8px;
overflow: hidden;
width: 100%;
box-shadow: 0 0 0 1px var(--ifm-color-emphasis-200);
border-collapse: separate;
border-spacing: 0;
}
.markdown table thead tr {
background: var(--bx-surface-subtle);
background: var(--ifm-color-emphasis-100);
}
.markdown table th,
.markdown table td {
border: 0;
border-bottom: 1px solid var(--bx-border);
border-right: 1px solid var(--bx-border);
padding: 9px 12px;
text-align: left;
}
.markdown table th {
font-weight: 500;
}
.markdown table th:last-child,
.markdown table td:last-child {
border-right: 0;
border: none;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
padding: 10px 14px;
}
.markdown table tr:last-child td {
border-bottom: 0;
border-bottom: none;
}
/* Inline code */
code {
background: var(--ifm-code-background);
border: 1px solid var(--bx-border);
border-radius: 3px;
border: none;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.92em;
padding: 1px 5px;
}
pre code {
border: 0;
padding: 0;
}
.theme-code-block {
border: 1px solid var(--bx-border);
border-radius: 4px;
overflow: hidden;
}
.theme-code-block pre {
border-radius: 0;
}
/* Admonitions: softer */
.theme-admonition {
border: 1px solid var(--bx-border-strong) !important;
border-left-width: 1px !important;
border-radius: 4px;
}
.theme-admonition-heading h5 {
font-weight: 500;
}
details {
background: var(--bx-surface-subtle);
border: 1px solid var(--bx-border) !important;
border-radius: 4px;
}
details > summary {
font-weight: 500;
}
.table-of-contents {
border-left: 0;
border-top: 1px solid var(--bx-border);
font-size: 13px;
padding: 0.8rem 0 0;
}
.table-of-contents__link {
color: var(--ifm-color-content-secondary);
}
.table-of-contents__link--active {
color: var(--ifm-color-primary);
font-weight: 500;
}
.pagination-nav {
gap: 12px;
}
.pagination-nav__link {
border: 1px solid var(--bx-border);
border-radius: 4px;
padding: 0.9rem 1rem;
}
.pagination-nav__link:hover {
background: var(--bx-surface-subtle);
border-color: var(--ifm-color-primary);
}
.pagination-nav__sublabel {
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 400;
}
.pagination-nav__label {
font-size: 14px;
font-weight: 500;
}
.theme-edit-this-page,
.theme-last-updated {
font-size: 13px;
border-radius: 8px;
border-width: 1px;
border-left-width: 4px;
}
/* Footer */
.footer {
--ifm-footer-background-color: #101828;
--ifm-footer-color: #98a2b3;
--ifm-footer-link-color: #d0d5dd;
--ifm-footer-background-color: #141720;
--ifm-footer-color: #9aa3b2;
--ifm-footer-link-color: #c9d1db;
--ifm-footer-link-hover-color: #ffffff;
--ifm-footer-title-color: #f2f4f7;
border-top: 1px solid #273240;
padding: 3rem 0 2rem;
--ifm-footer-title-color: #f0f2f5;
padding: 3.5rem 0 2.5rem;
}
.footer__title {
font-size: 13px;
font-weight: 500;
letter-spacing: 0;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
}
.footer__link-item {
font-size: 14px;
transition: color 0.15s ease;
}
.footer__bottom {
border-top: 1px solid #273240;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 2rem;
margin-top: 2.5rem;
}
.footer__copyright {
color: #98a2b3;
font-size: 13px;
color: #6b7280;
}
/* Scrollbar */
::-webkit-scrollbar {
height: 10px;
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--ifm-color-emphasis-300);
border: 2px solid var(--ifm-background-color);
border-radius: 4px;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ifm-color-emphasis-400);
}
@media (max-width: 996px) {
:root {
--ifm-navbar-height: 54px;
}
.theme-doc-markdown {
max-width: none;
}
.markdown table {
display: block;
overflow-x: auto;
}
}
@media (max-width: 576px) {
.pagination-nav {
display: grid;
grid-template-columns: 1fr;
}
.footer {
padding-top: 2.25rem;
}
[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -0,0 +1,470 @@
/* Hero */
.hero {
position: relative;
overflow: hidden;
padding: 7rem 0 5.5rem;
background:
linear-gradient(180deg, rgba(22, 93, 255, 0.08) 0%, rgba(255, 255, 255, 0) 72%),
linear-gradient(90deg, rgba(20, 201, 201, 0.08) 0%, rgba(250, 173, 20, 0.08) 100%),
var(--ifm-background-color);
}
.hero::before {
position: absolute;
inset: 0;
content: "";
pointer-events: none;
background-image:
linear-gradient(rgba(22, 93, 255, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(22, 93, 255, 0.06) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.75), transparent 82%);
}
[data-theme='dark'] .hero {
background:
linear-gradient(180deg, rgba(64, 128, 255, 0.16) 0%, rgba(15, 17, 21, 0) 72%),
linear-gradient(90deg, rgba(20, 201, 201, 0.1) 0%, rgba(250, 173, 20, 0.08) 100%),
var(--ifm-background-color);
}
.heroInner {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr);
gap: 4rem;
align-items: center;
}
.heroContent {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1.25rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 5px 12px;
color: var(--ifm-color-primary);
background: rgba(22, 93, 255, 0.09);
border: 1px solid rgba(22, 93, 255, 0.2);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
}
[data-theme='dark'] .badge {
background: rgba(64, 128, 255, 0.16);
border-color: rgba(64, 128, 255, 0.3);
color: var(--ifm-color-primary-lighter);
}
.badgeDot {
width: 7px;
height: 7px;
background: #00b42a;
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(0, 180, 42, 0.12);
}
.heroTitle {
margin: 0;
color: var(--ifm-heading-color);
font-size: 3.45rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.08;
}
.heroTitleAccent {
display: block;
margin-top: 8px;
color: var(--ifm-color-primary);
}
.heroSubtitle {
max-width: 640px;
margin: 0;
color: var(--ifm-color-content-secondary);
font-size: 1.15rem;
line-height: 1.72;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
}
.primaryBtn,
.secondaryBtn {
min-height: 46px;
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.primaryBtn {
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff;
background: #165dff;
border: 1px solid #165dff;
box-shadow: 0 10px 24px rgba(22, 93, 255, 0.24);
font-weight: 650;
}
.primaryBtn:hover,
.primaryBtn:focus-visible {
color: #fff;
background: #0e4fe6;
border-color: #0e4fe6;
box-shadow: 0 14px 30px rgba(22, 93, 255, 0.3);
transform: translateY(-1px);
}
.btnArrow {
transition: transform 0.2s ease;
}
.primaryBtn:hover .btnArrow,
.primaryBtn:focus-visible .btnArrow {
transform: translateX(3px);
}
.secondaryBtn {
display: inline-flex;
align-items: center;
color: var(--ifm-font-color-base);
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-300);
font-weight: 600;
}
.secondaryBtn:hover,
.secondaryBtn:focus-visible {
color: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
background: var(--ifm-background-color);
transform: translateY(-1px);
}
.metrics {
display: flex;
align-items: center;
gap: 1.5rem;
margin-top: 0.5rem;
padding-top: 1.25rem;
}
.metric {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.metricValue {
color: var(--ifm-heading-color);
font-size: 1.35rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.1;
white-space: nowrap;
}
.metricLabel {
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
line-height: 1.35;
text-transform: uppercase;
}
.metricDivider {
width: 1px;
height: 30px;
background: var(--ifm-color-emphasis-300);
}
/* Product visual */
.heroVisual {
display: grid;
gap: 1rem;
}
.consolePanel {
overflow: hidden;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(22, 93, 255, 0.16);
border-radius: 8px;
box-shadow: 0 24px 60px rgba(29, 33, 41, 0.12);
}
[data-theme='dark'] .consolePanel {
background: rgba(22, 24, 29, 0.9);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
}
.consoleHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .consoleHeader {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.consoleHeader strong {
display: block;
margin-top: 4px;
color: var(--ifm-heading-color);
font-size: 1.2rem;
}
.consoleEyebrow {
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
text-transform: uppercase;
}
.consoleStatus {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
color: #00a870;
background: rgba(0, 180, 42, 0.1);
border: 1px solid rgba(0, 180, 42, 0.2);
border-radius: 8px;
font-size: 12px;
font-weight: 700;
}
.consoleGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .consoleGrid {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.consoleGrid > div {
min-width: 0;
padding: 1.1rem 1.25rem;
border-right: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .consoleGrid > div {
border-right-color: rgba(255, 255, 255, 0.08);
}
.consoleGrid > div:last-child {
border-right: 0;
}
.consoleGrid strong {
display: block;
margin-top: 6px;
color: var(--ifm-heading-color);
font-size: 1.45rem;
line-height: 1.1;
}
.consoleLabel {
display: block;
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 650;
}
.timeline {
display: grid;
}
.timelineRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .timelineRow {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.timelineRow:last-child {
border-bottom: 0;
}
.timelineRow strong,
.timelineRow span {
display: block;
}
.timelineRow strong {
color: var(--ifm-heading-color);
font-size: 0.95rem;
font-weight: 700;
}
.timelineRow span {
color: var(--ifm-color-content-secondary);
font-size: 0.85rem;
line-height: 1.5;
}
.timelineRow em {
color: var(--ifm-color-content-secondary);
font-size: 0.8rem;
font-style: normal;
font-weight: 650;
white-space: nowrap;
}
.timelineDotOk,
.timelineDotInfo,
.timelineDotWarn {
width: 10px;
height: 10px;
border-radius: 50%;
}
.timelineDotOk {
background: #00b42a;
}
.timelineDotInfo {
background: #165dff;
}
.timelineDotWarn {
background: #ff7d00;
}
.commandCard {
display: grid;
gap: 8px;
padding: 1rem 1.1rem;
background: #111827;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
box-shadow: 0 16px 34px rgba(17, 24, 39, 0.18);
}
.commandTitle {
color: #9ca3af;
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
text-transform: uppercase;
}
.commandCard code {
overflow-x: auto;
color: #e5e7eb;
background: transparent;
border: 0;
padding: 0;
font-size: 13px;
white-space: nowrap;
}
@media (max-width: 996px) {
.hero {
padding: 4.5rem 0 3.5rem;
}
.heroInner {
grid-template-columns: 1fr;
gap: 2.25rem;
}
.heroTitle {
font-size: 2.45rem;
}
}
@media (max-width: 640px) {
.hero {
padding: 3.75rem 0 2.75rem;
}
.heroTitle {
font-size: 2.05rem;
}
.heroSubtitle {
font-size: 1rem;
}
.actions {
width: 100%;
}
.primaryBtn,
.secondaryBtn {
width: 100%;
justify-content: center;
}
.metrics {
width: 100%;
align-items: stretch;
gap: 0.85rem;
flex-direction: column;
}
.metricDivider {
width: 100%;
height: 1px;
}
.consoleHeader,
.timelineRow {
padding: 1rem;
}
.consoleGrid {
grid-template-columns: 1fr;
}
.consoleGrid > div {
border-right: 0;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.consoleGrid > div:last-child {
border-bottom: 0;
}
[data-theme='dark'] .consoleGrid > div {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
}
@media (prefers-reduced-motion: reduce) {
.primaryBtn,
.secondaryBtn,
.btnArrow {
transition: none;
}
}

View File

@@ -1,19 +1,164 @@
import type {ReactNode} from 'react';
import {translate} from '@docusaurus/Translate';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import Translate, {translate} from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import HomepageHero from '@site/src/components/HomepageHero';
import Heading from '@theme/Heading';
import HomepageFeatures from '@site/src/components/HomepageFeatures';
import HomepageShowcase from '@site/src/components/HomepageShowcase';
import HomepageCommunity from '@site/src/components/HomepageCommunity';
import styles from './index.module.css';
function HomepageHeader() {
return (
<header className={styles.hero}>
<div className={clsx('container', styles.heroInner)}>
<div className={styles.heroContent}>
<div className={styles.badge}>
<span className={styles.badgeDot} />
<Translate id="home.badge">Open-source backup control plane · v2.2.1</Translate>
</div>
<Heading as="h1" className={styles.heroTitle}>
<Translate id="home.title.part1">Backup orchestration</Translate>
<span className={styles.heroTitleAccent}>
<Translate id="home.title.part2">for self-hosted servers.</Translate>
</span>
</Heading>
<p className={styles.heroSubtitle}>
<Translate id="home.tagline">
Run file, database, SAP HANA and remote-node backups from one clean console. Keep the control plane yours, keep the storage flexible.
</Translate>
</p>
<div className={styles.actions}>
<Link className={clsx('button button--primary button--lg', styles.primaryBtn)} to="/docs/getting-started/quick-start">
<Translate id="home.getStarted">Get Started</Translate>
<span className={styles.btnArrow} aria-hidden="true">-&gt;</span>
</Link>
<Link className={clsx('button button--lg', styles.secondaryBtn)} to="https://github.com/Awuqing/BackupX">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style={{marginRight: 6}}>
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
GitHub
</Link>
</div>
<div className={styles.metrics}>
<div className={styles.metric}>
<div className={styles.metricValue}>70+</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.backends">Storage backends</Translate>
</div>
</div>
<div className={styles.metricDivider} />
<div className={styles.metric}>
<div className={styles.metricValue}>Agent</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.backupTypes">Remote execution</Translate>
</div>
</div>
<div className={styles.metricDivider} />
<div className={styles.metric}>
<div className={styles.metricValue}>Apache 2.0</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.license">License</Translate>
</div>
</div>
</div>
</div>
<div className={styles.heroVisual}>
<div className={styles.consolePanel}>
<div className={styles.consoleHeader}>
<div>
<span className={styles.consoleEyebrow}>
<Translate id="home.visual.eyebrow">BackupX Console</Translate>
</span>
<strong>
<Translate id="home.visual.title">Operations overview</Translate>
</strong>
</div>
<span className={styles.consoleStatus}>
<Translate id="home.visual.status">Healthy</Translate>
</span>
</div>
<div className={styles.consoleGrid}>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.success">Success rate</Translate>
</span>
<strong>99.4%</strong>
</div>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.nodes">Active nodes</Translate>
</span>
<strong>12</strong>
</div>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.targets">Storage targets</Translate>
</span>
<strong>8</strong>
</div>
</div>
<div className={styles.timeline}>
<div className={styles.timelineRow}>
<span className={styles.timelineDotOk} />
<div>
<strong>
<Translate id="home.visual.row1.title">PostgreSQL nightly</Translate>
</strong>
<span>
<Translate id="home.visual.row1.desc">Encrypted archive uploaded to S3</Translate>
</span>
</div>
<em>02:10</em>
</div>
<div className={styles.timelineRow}>
<span className={styles.timelineDotInfo} />
<div>
<strong>
<Translate id="home.visual.row2.title">SAP HANA snapshot</Translate>
</strong>
<span>
<Translate id="home.visual.row2.desc">Running on agent-shanghai-02</Translate>
</span>
</div>
<em>68%</em>
</div>
<div className={styles.timelineRow}>
<span className={styles.timelineDotWarn} />
<div>
<strong>
<Translate id="home.visual.row3.title">Retention cleanup</Translate>
</strong>
<span>
<Translate id="home.visual.row3.desc">Next run in 4 hours</Translate>
</span>
</div>
<em>queued</em>
</div>
</div>
</div>
<div className={styles.commandCard}>
<div className={styles.commandTitle}>
<Translate id="home.command.title">Start with Docker</Translate>
</div>
<code>docker run -d -p 8340:8340 awuqing/backupx:v2.2.1</code>
</div>
</div>
</div>
</header>
);
}
export default function Home(): ReactNode {
const {siteConfig} = useDocusaurusContext();
return (
<Layout
title={translate({id: 'home.pageTitle', message: 'Backup orchestration for self-hosted servers'})}
description={siteConfig.tagline}>
<HomepageHero />
<HomepageHeader />
<main>
<HomepageFeatures />
<HomepageShowcase />

View File

@@ -18,17 +18,15 @@ export default function Sponsors(): ReactNode {
<section className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div>
<div className={styles.sectionTag}>
<Translate id="sponsors.tag">Sponsorship</Translate>
</div>
<Heading as="h1" className={styles.sectionTitle}>
<Translate id="sponsors.title">Keep critical maintenance moving</Translate>
</Heading>
<div className={styles.sectionTag}>
<Translate id="sponsors.tag">SPONSORS</Translate>
</div>
<Heading as="h1" className={styles.sectionTitle}>
<Translate id="sponsors.title">Sponsor the BackupX ecosystem</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="sponsors.subtitle">
Sponsorship funds real provider validation, reliable releases, recovery drills, and better operational documentation.
Sponsorship helps keep BackupX practical for real operators: tested storage providers, reliable releases, restore confidence and better documentation.
</Translate>
</p>
</div>

View File

@@ -95,3 +95,4 @@ func redirectStderr(path string) (func(), error) {
_ = f.Close()
}, nil
}

View File

@@ -357,3 +357,4 @@ func buildStorageRegistry() *storage.Registry {
storageRclone.RegisterAllBackends(registry)
return registry
}

View File

@@ -21,10 +21,10 @@ import (
type Function string
const (
FunctionBackup Function = "backup"
FunctionRestore Function = "restore"
FunctionInquire Function = "inquire"
FunctionDelete Function = "delete"
FunctionBackup Function = "backup"
FunctionRestore Function = "restore"
FunctionInquire Function = "inquire"
FunctionDelete Function = "delete"
)
// BackupRequest 是 BACKUP 操作的单条请求。

View File

@@ -160,7 +160,7 @@ func TestFileRunnerSelectiveRestore(t *testing.T) {
}
diffAssertContent(t, filepath.Join(restoreSrc, "a.txt"), "alpha")
diffAssertContent(t, filepath.Join(restoreSrc, "sub", "c.txt"), "charlie") // 选中目录 → 子项一并恢复
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt")) // 未选中 → 不恢复
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt")) // 未选中 → 不恢复
}
// TestFileRunnerDifferentialWithoutBaseIsFull 验证无基线时差异请求回退为全量(产出清单、含全部文件)。

View File

@@ -160,3 +160,4 @@ func formatFileSize(size int64) string {
return fmt.Sprintf("%d B", size)
}
}

View File

@@ -32,8 +32,8 @@ func writeTestTar(t *testing.T, entries map[string][]byte) string {
func TestVerifyTarArchive_Valid(t *testing.T) {
path := writeTestTar(t, map[string][]byte{
"readme.md": []byte("hello"),
"data.bin": []byte("world!!!"),
"readme.md": []byte("hello"),
"data.bin": []byte("world!!!"),
})
report, err := VerifyTarArchive(path, "")
if err != nil {

View File

@@ -24,11 +24,10 @@ type MaintenanceWindow struct {
// 简化语法:多个窗口以 ';' 分隔,每个窗口按 "[days=xxx;]time=HH:MM-HH:MM" 格式。
// Days 缺省 = 全周;若不合法,跳过该段而非抛错(让调用方尽力工作)。
// 示例:
//
// "time=01:00-05:00" 每天 1 点到 5 点
// "days=sat,sun;time=00:00-23:59" 仅周末全天
// "time=22:00-06:00" 每天跨夜
// "days=mon,tue,wed,thu,fri;time=22:00-06:00" 工作日跨夜
// "time=01:00-05:00" 每天 1 点到 5 点
// "days=sat,sun;time=00:00-23:59" 仅周末全天
// "time=22:00-06:00" 每天跨夜
// "days=mon,tue,wed,thu,fri;time=22:00-06:00" 工作日跨夜
func ParseMaintenanceWindows(value string) []MaintenanceWindow {
v := strings.TrimSpace(value)
if v == "" {

View File

@@ -169,9 +169,7 @@ func applyDefaults(v *viper.Viper) {
v.SetDefault("server.trusted_proxies", []string{"127.0.0.1", "::1"})
v.SetDefault("server.web_root", "")
v.SetDefault("database.path", "./data/backupx.db")
v.SetDefault("security.jwt_secret", "")
v.SetDefault("security.jwt_expire", "24h")
v.SetDefault("security.encryption_key", "")
v.SetDefault("backup.temp_dir", "/tmp/backupx")
v.SetDefault("backup.max_concurrent", 2)
v.SetDefault("backup.retries", 10)

View File

@@ -72,22 +72,6 @@ func TestLoadReadsServerExternalURLFromEnv(t *testing.T) {
}
}
func TestLoadReadsSecuritySecretsFromEnv(t *testing.T) {
t.Setenv("BACKUPX_SECURITY_JWT_SECRET", "test-jwt-secret")
t.Setenv("BACKUPX_SECURITY_ENCRYPTION_KEY", "test-encryption-key")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Security.JWTSecret != "test-jwt-secret" {
t.Fatalf("expected JWT secret from env, got %q", cfg.Security.JWTSecret)
}
if cfg.Security.EncryptionKey != "test-encryption-key" {
t.Fatalf("expected encryption key from env, got %q", cfg.Security.EncryptionKey)
}
}
func TestLoadReadsTrustedProxiesFromEnv(t *testing.T) {
t.Setenv("BACKUPX_SERVER_TRUSTED_PROXIES", "127.0.0.1,172.18.0.0/16")
cfg, err := Load("")

View File

@@ -6,12 +6,12 @@ import "time"
// 任一 Notification 可订阅多个事件EventTypes 字段存 CSV。
// 空 EventTypes + OnSuccess/OnFailure=true 时沿用旧语义(仅备份成功/失败)。
const (
NotificationEventBackupSuccess = "backup_success"
NotificationEventBackupFailed = "backup_failed"
NotificationEventBackupSuccess = "backup_success"
NotificationEventBackupFailed = "backup_failed"
NotificationEventRestoreSuccess = "restore_success"
NotificationEventRestoreFailed = "restore_failed"
NotificationEventVerifyFailed = "verify_failed"
NotificationEventSLAViolation = "sla_violation"
NotificationEventVerifyFailed = "verify_failed"
NotificationEventSLAViolation = "sla_violation"
// NotificationEventStorageUnhealthy 存储目标连接失败(后台健康扫描触发)。
NotificationEventStorageUnhealthy = "storage_unhealthy"
// NotificationEventReplicationFailed 备份复制失败。
@@ -23,13 +23,13 @@ const (
)
type Notification struct {
ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:20;index;not null" json:"type"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
ConfigCiphertext string `gorm:"column:config_ciphertext;type:text;not null" json:"-"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"`
OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"`
ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:20;index;not null" json:"type"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
ConfigCiphertext string `gorm:"column:config_ciphertext;type:text;not null" json:"-"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"`
OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"`
// EventTypes 逗号分隔,订阅的事件类型。
// 空 = 仅监听备份成功/失败(兼容旧配置);非空则严格按订阅触发。
EventTypes string `gorm:"column:event_types;size:500" json:"eventTypes"`

View File

@@ -24,19 +24,19 @@ type ReplicationRecord struct {
SourceTargetID uint `gorm:"column:source_target_id;index;not null" json:"sourceTargetId"`
SourceTarget StorageTarget `gorm:"foreignKey:SourceTargetID;references:ID" json:"sourceTarget,omitempty"`
// DestTargetID 目标存储(复制过去)
DestTargetID uint `gorm:"column:dest_target_id;index;not null" json:"destTargetId"`
DestTarget StorageTarget `gorm:"foreignKey:DestTargetID;references:ID" json:"destTarget,omitempty"`
Status string `gorm:"size:20;index;not null" json:"status"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DestTargetID uint `gorm:"column:dest_target_id;index;not null" json:"destTargetId"`
DestTarget StorageTarget `gorm:"foreignKey:DestTargetID;references:ID" json:"destTarget,omitempty"`
Status string `gorm:"size:20;index;not null" json:"status"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ReplicationRecord) TableName() string {

View File

@@ -11,10 +11,10 @@ import "time"
// - name
// - sourcePath / sourcePaths 中的 {{.Host}} / {{.Env}} 等占位符
type TaskTemplate struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Description string `gorm:"size:500" json:"description"`
TaskType string `gorm:"column:task_type;size:20;not null" json:"taskType"`
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Description string `gorm:"size:500" json:"description"`
TaskType string `gorm:"column:task_type;size:20;not null" json:"taskType"`
// Payload JSON存完整 BackupTaskUpsertInput 的序列化
Payload string `gorm:"type:text;not null" json:"payload"`
CreatedBy string `gorm:"column:created_by;size:128" json:"createdBy"`

View File

@@ -126,14 +126,14 @@ func (s *DashboardService) Timeline(ctx context.Context, days int) ([]repository
// 判定规则:任务设置了 SLAHoursRPO > 0且距最近一次 success 备份的时间 > SLAHoursRPO。
// 从未成功过的任务LastSuccessAt = nil若启用也视为违约from createdAt 起算)。
type SLAViolation struct {
TaskID uint `json:"taskId"`
TaskName string `json:"taskName"`
NodeID uint `json:"nodeId"`
NodeName string `json:"nodeName,omitempty"`
SLAHoursRPO int `json:"slaHoursRpo"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
HoursSinceLastSuccess float64 `json:"hoursSinceLastSuccess"`
NeverSucceeded bool `json:"neverSucceeded"`
TaskID uint `json:"taskId"`
TaskName string `json:"taskName"`
NodeID uint `json:"nodeId"`
NodeName string `json:"nodeName,omitempty"`
SLAHoursRPO int `json:"slaHoursRpo"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
HoursSinceLastSuccess float64 `json:"hoursSinceLastSuccess"`
NeverSucceeded bool `json:"neverSucceeded"`
}
// SLAComplianceReport Dashboard 的 SLA 合规概览。
@@ -204,15 +204,15 @@ func roundHours(value float64) float64 {
// ClusterNodeSummary 集群节点简报Dashboard 用)。
type ClusterNodeSummary struct {
ID uint `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Status string `json:"status"`
IsLocal bool `json:"isLocal"`
AgentVersion string `json:"agentVersion"`
VersionStatus string `json:"versionStatus"` // current | outdated | unknown
LastSeen time.Time `json:"lastSeen"`
TaskCount int64 `json:"taskCount"`
ID uint `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Status string `json:"status"`
IsLocal bool `json:"isLocal"`
AgentVersion string `json:"agentVersion"`
VersionStatus string `json:"versionStatus"` // current | outdated | unknown
LastSeen time.Time `json:"lastSeen"`
TaskCount int64 `json:"taskCount"`
}
// ClusterOverview Dashboard 集群概览卡片。
@@ -312,9 +312,9 @@ func (s *DashboardService) Breakdown(ctx context.Context, days int) (*BreakdownS
}
}
result := &BreakdownStats{
ByType: makeBreakdown(typeCounts, typeLabel),
ByNode: makeBreakdownByUint(nodeCounts, nodeNames, "节点 #"),
ByStatus: []BreakdownItem{},
ByType: makeBreakdown(typeCounts, typeLabel),
ByNode: makeBreakdownByUint(nodeCounts, nodeNames, "节点 #"),
ByStatus: []BreakdownItem{},
ByStorage: []BreakdownItem{},
}
// 按状态(最近 days 天记录)

View File

@@ -74,11 +74,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"`
@@ -132,3 +132,4 @@ func (s *SystemService) GetInfo(_ context.Context) *SystemInfo {
}
return info
}

View File

@@ -14,9 +14,9 @@ import (
// TaskExportService 管理备份任务的 JSON 导入 / 导出。
// 用途:
// 1. 集群迁移(旧 Master → 新 Master 的任务配置搬迁)
// 2. 灾备恢复任务配置本地文件化Master 宕机后重建)
// 3. 配置审计(版本化 Git 管理 JSON 快照)
// 1. 集群迁移(旧 Master → 新 Master 的任务配置搬迁)
// 2. 灾备恢复任务配置本地文件化Master 宕机后重建)
// 3. 配置审计(版本化 Git 管理 JSON 快照)
//
// 出于安全考虑,导出/导入不包含任何敏感字段:
// - 数据库密码DBPasswordCiphertext跳过导入后需人工填补
@@ -40,35 +40,35 @@ func NewTaskExportService(
// ExportedTask 导出格式:按名称引用存储/节点,不含敏感数据。
type ExportedTask struct {
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
CronExpr string `json:"cronExpr,omitempty"`
SourcePath string `json:"sourcePath,omitempty"`
SourcePaths []string `json:"sourcePaths,omitempty"`
ExcludePatterns []string `json:"excludePatterns,omitempty"`
DBHost string `json:"dbHost,omitempty"`
DBPort int `json:"dbPort,omitempty"`
DBUser string `json:"dbUser,omitempty"`
DBName string `json:"dbName,omitempty"`
DBPath string `json:"dbPath,omitempty"`
ExtraConfig map[string]any `json:"extraConfig,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
CronExpr string `json:"cronExpr,omitempty"`
SourcePath string `json:"sourcePath,omitempty"`
SourcePaths []string `json:"sourcePaths,omitempty"`
ExcludePatterns []string `json:"excludePatterns,omitempty"`
DBHost string `json:"dbHost,omitempty"`
DBPort int `json:"dbPort,omitempty"`
DBUser string `json:"dbUser,omitempty"`
DBName string `json:"dbName,omitempty"`
DBPath string `json:"dbPath,omitempty"`
ExtraConfig map[string]any `json:"extraConfig,omitempty"`
// 按名称引用:导入时按名称查找对应 ID
StorageTargetNames []string `json:"storageTargetNames"`
ReplicationTargetNames []string `json:"replicationTargetNames,omitempty"`
NodeName string `json:"nodeName,omitempty"`
DependsOnTaskNames []string `json:"dependsOnTaskNames,omitempty"`
Tags string `json:"tags,omitempty"`
Compression string `json:"compression,omitempty"`
Encrypt bool `json:"encrypt,omitempty"`
RetentionDays int `json:"retentionDays,omitempty"`
MaxBackups int `json:"maxBackups,omitempty"`
VerifyEnabled bool `json:"verifyEnabled,omitempty"`
VerifyCronExpr string `json:"verifyCronExpr,omitempty"`
VerifyMode string `json:"verifyMode,omitempty"`
SLAHoursRPO int `json:"slaHoursRpo,omitempty"`
AlertOnConsecutiveFails int `json:"alertOnConsecutiveFails,omitempty"`
MaintenanceWindows string `json:"maintenanceWindows,omitempty"`
StorageTargetNames []string `json:"storageTargetNames"`
ReplicationTargetNames []string `json:"replicationTargetNames,omitempty"`
NodeName string `json:"nodeName,omitempty"`
DependsOnTaskNames []string `json:"dependsOnTaskNames,omitempty"`
Tags string `json:"tags,omitempty"`
Compression string `json:"compression,omitempty"`
Encrypt bool `json:"encrypt,omitempty"`
RetentionDays int `json:"retentionDays,omitempty"`
MaxBackups int `json:"maxBackups,omitempty"`
VerifyEnabled bool `json:"verifyEnabled,omitempty"`
VerifyCronExpr string `json:"verifyCronExpr,omitempty"`
VerifyMode string `json:"verifyMode,omitempty"`
SLAHoursRPO int `json:"slaHoursRpo,omitempty"`
AlertOnConsecutiveFails int `json:"alertOnConsecutiveFails,omitempty"`
MaintenanceWindows string `json:"maintenanceWindows,omitempty"`
}
// ExportPayload 导出整体结构,带元信息。
@@ -233,67 +233,67 @@ func (s *TaskExportService) toExported(item *model.BackupTask, targetNames, node
nodeName = nodeNames[item.NodeID]
}
return ExportedTask{
Name: item.Name,
Type: item.Type,
Enabled: item.Enabled,
CronExpr: item.CronExpr,
SourcePath: item.SourcePath,
SourcePaths: sourcePaths,
ExcludePatterns: excludes,
DBHost: item.DBHost,
DBPort: item.DBPort,
DBUser: item.DBUser,
DBName: item.DBName,
DBPath: item.DBPath,
ExtraConfig: extra,
StorageTargetNames: storageNames,
ReplicationTargetNames: replicationNames,
NodeName: nodeName,
DependsOnTaskNames: dependsOnNames,
Tags: item.Tags,
Compression: item.Compression,
Encrypt: item.Encrypt,
RetentionDays: item.RetentionDays,
MaxBackups: item.MaxBackups,
VerifyEnabled: item.VerifyEnabled,
VerifyCronExpr: item.VerifyCronExpr,
VerifyMode: item.VerifyMode,
SLAHoursRPO: item.SLAHoursRPO,
Name: item.Name,
Type: item.Type,
Enabled: item.Enabled,
CronExpr: item.CronExpr,
SourcePath: item.SourcePath,
SourcePaths: sourcePaths,
ExcludePatterns: excludes,
DBHost: item.DBHost,
DBPort: item.DBPort,
DBUser: item.DBUser,
DBName: item.DBName,
DBPath: item.DBPath,
ExtraConfig: extra,
StorageTargetNames: storageNames,
ReplicationTargetNames: replicationNames,
NodeName: nodeName,
DependsOnTaskNames: dependsOnNames,
Tags: item.Tags,
Compression: item.Compression,
Encrypt: item.Encrypt,
RetentionDays: item.RetentionDays,
MaxBackups: item.MaxBackups,
VerifyEnabled: item.VerifyEnabled,
VerifyCronExpr: item.VerifyCronExpr,
VerifyMode: item.VerifyMode,
SLAHoursRPO: item.SLAHoursRPO,
AlertOnConsecutiveFails: item.AlertOnConsecutiveFails,
MaintenanceWindows: item.MaintenanceWindows,
MaintenanceWindows: item.MaintenanceWindows,
}
}
func (s *TaskExportService) toUpsertInput(t ExportedTask, targetsByName, nodesByName map[string]uint, deps []uint) BackupTaskUpsertInput {
return BackupTaskUpsertInput{
Name: t.Name,
Type: t.Type,
Enabled: t.Enabled,
CronExpr: t.CronExpr,
SourcePath: t.SourcePath,
SourcePaths: t.SourcePaths,
ExcludePatterns: t.ExcludePatterns,
DBHost: t.DBHost,
DBPort: t.DBPort,
DBUser: t.DBUser,
DBName: t.DBName,
DBPath: t.DBPath,
ExtraConfig: t.ExtraConfig,
StorageTargetIDs: idsFromNames(t.StorageTargetNames, targetsByName),
ReplicationTargetIDs: idsFromNames(t.ReplicationTargetNames, targetsByName),
NodeID: nodesByName[t.NodeName],
Tags: t.Tags,
Compression: t.Compression,
Encrypt: t.Encrypt,
RetentionDays: t.RetentionDays,
MaxBackups: t.MaxBackups,
VerifyEnabled: t.VerifyEnabled,
VerifyCronExpr: t.VerifyCronExpr,
VerifyMode: t.VerifyMode,
SLAHoursRPO: t.SLAHoursRPO,
Name: t.Name,
Type: t.Type,
Enabled: t.Enabled,
CronExpr: t.CronExpr,
SourcePath: t.SourcePath,
SourcePaths: t.SourcePaths,
ExcludePatterns: t.ExcludePatterns,
DBHost: t.DBHost,
DBPort: t.DBPort,
DBUser: t.DBUser,
DBName: t.DBName,
DBPath: t.DBPath,
ExtraConfig: t.ExtraConfig,
StorageTargetIDs: idsFromNames(t.StorageTargetNames, targetsByName),
ReplicationTargetIDs: idsFromNames(t.ReplicationTargetNames, targetsByName),
NodeID: nodesByName[t.NodeName],
Tags: t.Tags,
Compression: t.Compression,
Encrypt: t.Encrypt,
RetentionDays: t.RetentionDays,
MaxBackups: t.MaxBackups,
VerifyEnabled: t.VerifyEnabled,
VerifyCronExpr: t.VerifyCronExpr,
VerifyMode: t.VerifyMode,
SLAHoursRPO: t.SLAHoursRPO,
AlertOnConsecutiveFails: t.AlertOnConsecutiveFails,
MaintenanceWindows: t.MaintenanceWindows,
DependsOnTaskIDs: deps,
MaintenanceWindows: t.MaintenanceWindows,
DependsOnTaskIDs: deps,
}
}

View File

@@ -43,7 +43,7 @@ type LocalDiskFactory struct{}
func NewLocalDiskFactory() LocalDiskFactory { return LocalDiskFactory{} }
func (LocalDiskFactory) Type() storage.ProviderType { return storage.ProviderTypeLocalDisk }
func (LocalDiskFactory) SensitiveFields() []string { return nil }
func (LocalDiskFactory) SensitiveFields() []string { return nil }
func (LocalDiskFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[storage.LocalDiskConfig](rawConfig)
@@ -66,7 +66,7 @@ type S3Factory struct{}
func NewS3Factory() S3Factory { return S3Factory{} }
func (S3Factory) Type() storage.ProviderType { return storage.ProviderTypeS3 }
func (S3Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
func (S3Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
func (S3Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[storage.S3Config](rawConfig)
@@ -116,7 +116,7 @@ type WebDAVFactory struct{}
func NewWebDAVFactory() WebDAVFactory { return WebDAVFactory{} }
func (WebDAVFactory) Type() storage.ProviderType { return storage.ProviderTypeWebDAV }
func (WebDAVFactory) SensitiveFields() []string { return []string{"username", "password"} }
func (WebDAVFactory) SensitiveFields() []string { return []string{"username", "password"} }
func (WebDAVFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[storage.WebDAVConfig](rawConfig)
@@ -187,7 +187,7 @@ type FTPFactory struct{}
func NewFTPFactory() FTPFactory { return FTPFactory{} }
func (FTPFactory) Type() storage.ProviderType { return storage.ProviderTypeFTP }
func (FTPFactory) SensitiveFields() []string { return []string{"username", "password"} }
func (FTPFactory) SensitiveFields() []string { return []string{"username", "password"} }
func (FTPFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[storage.FTPConfig](rawConfig)
@@ -228,7 +228,7 @@ type AliyunOSSFactory struct{}
func NewAliyunOSSFactory() AliyunOSSFactory { return AliyunOSSFactory{} }
func (AliyunOSSFactory) Type() storage.ProviderType { return storage.ProviderTypeAliyunOSS }
func (AliyunOSSFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
func (AliyunOSSFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
// AliyunConfig 是阿里云 OSS 的用户配置。
type AliyunConfig struct {
@@ -269,9 +269,7 @@ type TencentCOSFactory struct{}
func NewTencentCOSFactory() TencentCOSFactory { return TencentCOSFactory{} }
func (TencentCOSFactory) Type() storage.ProviderType { return storage.ProviderTypeTencentCOS }
func (TencentCOSFactory) SensitiveFields() []string {
return []string{"accessKeyId", "secretAccessKey"}
}
func (TencentCOSFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
// TencentConfig 是腾讯云 COS 的用户配置。
type TencentConfig struct {
@@ -307,7 +305,7 @@ type QiniuKodoFactory struct{}
func NewQiniuKodoFactory() QiniuKodoFactory { return QiniuKodoFactory{} }
func (QiniuKodoFactory) Type() storage.ProviderType { return storage.ProviderTypeQiniuKodo }
func (QiniuKodoFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
func (QiniuKodoFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
// QiniuConfig 是七牛云 Kodo 的用户配置。
type QiniuConfig struct {
@@ -357,9 +355,7 @@ type RcloneFactory struct{}
func NewRcloneFactory() RcloneFactory { return RcloneFactory{} }
func (RcloneFactory) Type() storage.ProviderType { return storage.ProviderTypeRclone }
func (RcloneFactory) SensitiveFields() []string {
return []string{"pass", "password", "secret_access_key", "client_secret", "token"}
}
func (RcloneFactory) SensitiveFields() []string { return []string{"pass", "password", "secret_access_key", "client_secret", "token"} }
func (RcloneFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
backend, _ := rawConfig["backend"].(string)
@@ -466,10 +462,8 @@ func NewBackendFactory(backendType string) GenericBackendFactory {
return GenericBackendFactory{backendType: backendType, sensitive: sensitive}
}
func (f GenericBackendFactory) Type() storage.ProviderType {
return storage.ProviderType(f.backendType)
}
func (f GenericBackendFactory) SensitiveFields() []string { return f.sensitive }
func (f GenericBackendFactory) Type() storage.ProviderType { return storage.ProviderType(f.backendType) }
func (f GenericBackendFactory) SensitiveFields() []string { return f.sensitive }
func (f GenericBackendFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
root, _ := rawConfig["root"].(string)

19
web/package-lock.json generated
View File

@@ -23,7 +23,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.13.3",
"@types/node": "^22.13.10",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^4.3.4",
@@ -31,9 +31,6 @@
"typescript": "^5.7.3",
"vite": "^6.4.3",
"vitest": "^3.2.7"
},
"engines": {
"node": ">=24.0"
}
},
"node_modules/@adobe/css-tools": {
@@ -1532,13 +1529,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"version": "22.19.15",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
@@ -3471,9 +3468,9 @@
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},

View File

@@ -3,13 +3,9 @@
"private": true,
"version": "0.1.0",
"type": "module",
"engines": {
"node": ">=24.0"
},
"scripts": {
"dev": "vite",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
"build": "npm run typecheck && vite build",
"build": "tsc --noEmit -p tsconfig.json && vite build",
"preview": "vite preview",
"test": "vitest run"
},
@@ -29,7 +25,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.13.3",
"@types/node": "^22.13.10",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^4.3.4",

View File

@@ -1,50 +0,0 @@
import { Typography } from '@arco-design/web-react'
import type { ReactNode } from 'react'
export interface AdminMetric {
label: string
value: ReactNode
detail: string
}
interface AdminDataSectionProps {
title: string
description: string
actions?: ReactNode
metrics: AdminMetric[]
toolbar: ReactNode
children: ReactNode
}
export function AdminDataSection({ title, description, actions, metrics, toolbar, children }: AdminDataSectionProps) {
return (
<section className="admin-section" aria-labelledby="admin-section-title">
<header className="admin-section__header">
<div>
<Typography.Title id="admin-section-title" heading={5} className="admin-section__title">
{title}
</Typography.Title>
<Typography.Paragraph type="secondary" className="admin-section__description">
{description}
</Typography.Paragraph>
</div>
{actions}
</header>
<div className="admin-summary" aria-label={`${title}概览`}>
{metrics.map((metric) => (
<div key={metric.label} className="admin-summary__item">
<Typography.Text type="secondary">{metric.label}</Typography.Text>
<span className="admin-summary__value">{metric.value}</span>
<span className="admin-summary__detail">{metric.detail}</span>
</div>
))}
</div>
<div className="admin-data-panel">
{toolbar}
{children}
</div>
</section>
)
}

View File

@@ -1,34 +0,0 @@
import { Select } from '@arco-design/web-react'
import type { CSSProperties } from 'react'
import type { UserRole } from '../../services/users'
export const adminRoleOptions = [
{ label: '管理员 (admin)', value: 'admin' },
{ label: '运维 (operator)', value: 'operator' },
{ label: '只读 (viewer)', value: 'viewer' },
]
export const adminRoleDescriptions: Record<UserRole, string> = {
admin: '拥有系统配置、账号与访问凭据的完整管理权限。',
operator: '可执行日常备份、恢复和节点运维操作。',
viewer: '仅可查看仪表盘和允许读取的数据。',
}
interface AdminRoleSelectProps {
value: UserRole
onChange: (role: UserRole) => void
disabled?: boolean
style?: CSSProperties
}
export function AdminRoleSelect({ value, onChange, disabled, style }: AdminRoleSelectProps) {
return (
<Select
value={value}
options={adminRoleOptions}
disabled={disabled}
style={style}
onChange={(role) => onChange(role as UserRole)}
/>
)
}

View File

@@ -9,6 +9,7 @@ import {
IconCopy,
IconBook,
IconUser,
IconCommand,
IconNotification,
IconSettings,
IconMenuFold,
@@ -85,8 +86,11 @@ function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/task-templates')) {
return '/task-templates'
}
if (pathname.startsWith('/admin')) {
return '/admin'
if (pathname.startsWith('/admin/users')) {
return '/admin/users'
}
if (pathname.startsWith('/admin/api-keys')) {
return '/admin/api-keys'
}
if (pathname.startsWith('/settings') || pathname.startsWith('/system-info')) {
return '/settings'
@@ -113,7 +117,8 @@ const menuItems: MenuItemConfig[] = [
{ key: '/storage-targets', label: '存储目标', icon: <IconStorage /> },
{ key: '/nodes', label: '节点管理', icon: <IconDesktop /> },
{ key: '/settings/notifications', label: '通知配置', icon: <IconNotification /> },
{ key: '/admin', label: '访问管理', icon: <IconUser />, adminOnly: true },
{ key: '/admin/users', label: '用户管理', icon: <IconUser />, adminOnly: true },
{ key: '/admin/api-keys', label: 'API Key', icon: <IconCommand />, adminOnly: true },
{ key: '/audit', label: '审计日志', icon: <IconList /> },
{ key: '/settings', label: '系统设置', icon: <IconSettings /> },
]

View File

@@ -1,60 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it } from 'vitest'
import { useAuthStore } from '../../stores/auth'
import { AdminLayout } from './AdminLayout'
describe('AdminLayout', () => {
beforeEach(() => {
useAuthStore.setState({
token: 'test-token',
user: { id: 1, username: 'admin', displayName: 'Admin', role: 'admin' },
status: 'authenticated',
bootstrapped: true,
})
})
it('keeps user and API key management in one navigable admin area', async () => {
const actor = userEvent.setup()
render(
<MemoryRouter initialEntries={['/admin/users']}>
<Routes>
<Route path="/admin" element={<AdminLayout />}>
<Route path="users" element={<div>user management content</div>} />
<Route path="api-keys" element={<div>api key management content</div>} />
</Route>
<Route path="/audit" element={<div>audit content</div>} />
</Routes>
</MemoryRouter>,
)
expect(screen.getByText('user management content')).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: '访问管理分区' })).toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: 'API Key' }))
expect(screen.getByText('api key management content')).toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '访问审计' }))
expect(screen.getByText('audit content')).toBeInTheDocument()
})
it('blocks non-admin users before rendering management content', () => {
useAuthStore.setState({
user: { id: 2, username: 'viewer', displayName: 'Viewer', role: 'viewer' },
})
render(
<MemoryRouter initialEntries={['/admin/users']}>
<Routes>
<Route path="/admin" element={<AdminLayout />}>
<Route path="users" element={<div>restricted content</div>} />
</Route>
</Routes>
</MemoryRouter>,
)
expect(screen.getByText('当前账号无权进入访问管理(仅管理员)')).toBeInTheDocument()
expect(screen.queryByText('restricted content')).not.toBeInTheDocument()
})
})

View File

@@ -1,55 +0,0 @@
import { Alert, Button, PageHeader } from '@arco-design/web-react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { IconCommand, IconList, IconUser } from '../../components/icons'
import { useAuthStore } from '../../stores/auth'
import { isAdmin } from '../../utils/permissions'
import './admin.css'
const sections = [
{ path: '/admin/users', label: '用户账号', icon: <IconUser /> },
{ path: '/admin/api-keys', label: 'API Key', icon: <IconCommand /> },
]
export function AdminLayout() {
const user = useAuthStore((state) => state.user)
const location = useLocation()
const navigate = useNavigate()
if (!isAdmin(user)) {
return <Alert type="warning" content="当前账号无权进入访问管理(仅管理员)" />
}
return (
<div className="admin-page">
<PageHeader
className="admin-page__header"
title="访问管理"
subTitle="统一管理系统账号、角色权限、多因素认证与程序化访问凭据。"
extra={(
<Button icon={<IconList />} onClick={() => navigate('/audit')}>
访
</Button>
)}
/>
<nav className="admin-page__nav" aria-label="访问管理分区">
{sections.map((section) => {
const selected = location.pathname.startsWith(section.path)
return (
<Button
key={section.path}
type={selected ? 'secondary' : 'text'}
icon={section.icon}
aria-current={selected ? 'page' : undefined}
onClick={() => navigate(section.path)}
>
{section.label}
</Button>
)
})}
</nav>
<Outlet />
</div>
)
}

View File

@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ApiKeySummary } from '../../services/api-keys'
import { resolveApiKeyStatus } from './ApiKeysPage'
const baseKey: ApiKeySummary = {
id: 1,
name: 'automation',
role: 'viewer',
prefix: 'bax_example',
createdBy: 'admin',
disabled: false,
createdAt: '2026-08-01T00:00:00Z',
}
describe('resolveApiKeyStatus', () => {
const now = new Date('2026-08-07T00:00:00Z').getTime()
it('derives active, disabled, and expired states from the credential lifecycle', () => {
expect(resolveApiKeyStatus(baseKey, now)).toBe('active')
expect(resolveApiKeyStatus({ ...baseKey, disabled: true }, now)).toBe('disabled')
expect(resolveApiKeyStatus({ ...baseKey, expiresAt: '2026-08-06T23:59:59Z' }, now)).toBe('expired')
})
it('keeps expiration authoritative when an expired key is also disabled', () => {
expect(resolveApiKeyStatus({
...baseKey,
disabled: true,
expiresAt: '2026-08-01T00:00:00Z',
}, now)).toBe('expired')
})
})

View File

@@ -1,70 +1,37 @@
import {
Alert,
Button,
Empty,
Form,
Grid,
Input,
InputNumber,
Message,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Tooltip,
Typography,
} from '@arco-design/web-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { AdminDataSection } from '../../components/admin/AdminDataSection'
import { AdminRoleSelect, adminRoleDescriptions, adminRoleOptions } from '../../components/admin/AdminRoleSelect'
import { IconCopy, IconDelete, IconList, IconPlus, IconRefresh, IconSearch } from '../../components/icons'
import {
createApiKey,
listApiKeys,
revokeApiKey,
toggleApiKey,
type ApiKeyCreateInput,
type ApiKeySummary,
} from '../../services/api-keys'
import { Alert, Button, Card, Empty, Form, Input, InputNumber, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { createApiKey, listApiKeys, revokeApiKey, toggleApiKey, type ApiKeyCreateInput, type ApiKeySummary } from '../../services/api-keys'
import type { UserRole } from '../../services/users'
import { useAuthStore } from '../../stores/auth'
import { resolveErrorMessage } from '../../utils/error'
import { isAdmin, roleLabel } from '../../utils/permissions'
import { formatDateTime } from '../../utils/format'
import { roleLabel } from '../../utils/permissions'
type ApiKeyStatus = 'active' | 'disabled' | 'expired'
type ApiKeyStatusFilter = ApiKeyStatus | 'all'
export function resolveApiKeyStatus(item: ApiKeySummary, now: number): ApiKeyStatus {
if (item.expiresAt && new Date(item.expiresAt).getTime() <= now) {
return 'expired'
}
return item.disabled ? 'disabled' : 'active'
}
const roleOptions = [
{ label: '管理员 (admin)', value: 'admin' },
{ label: '运维 (operator)', value: 'operator' },
{ label: '只读 (viewer)', value: 'viewer' },
]
// ApiKeysPage API Key 管理admin 专属)。
// 新创建的 Key 明文只返回一次,需要用户立即保存。
export function ApiKeysPage() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const [items, setItems] = useState<ApiKeySummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [query, setQuery] = useState('')
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all')
const [statusFilter, setStatusFilter] = useState<ApiKeyStatusFilter>('all')
const [modalVisible, setModalVisible] = useState(false)
const [draft, setDraft] = useState<ApiKeyCreateInput>({ name: '', role: 'viewer', ttlHours: 0 })
const [submitting, setSubmitting] = useState(false)
const [rowAction, setRowAction] = useState('')
const [plainKey, setPlainKey] = useState('')
const [plainKey, setPlainKey] = useState<string>('')
const load = useCallback(async () => {
setLoading(true)
try {
setItems(await listApiKeys())
setError('')
} catch (loadError) {
setError(resolveErrorMessage(loadError, '加载 API Key 失败'))
} catch (e) {
setError(resolveErrorMessage(e, '加载 API Key 失败'))
} finally {
setLoading(false)
}
@@ -74,81 +41,47 @@ export function ApiKeysPage() {
void load()
}, [load])
const now = useMemo(() => Date.now(), [items])
const filteredItems = useMemo(() => {
const keyword = query.trim().toLowerCase()
return items.filter((item) => {
const matchesQuery = !keyword || [item.name, item.prefix, item.createdBy]
.some((value) => value?.toLowerCase().includes(keyword))
const matchesRole = roleFilter === 'all' || item.role === roleFilter
const matchesStatus = statusFilter === 'all' || resolveApiKeyStatus(item, now) === statusFilter
return matchesQuery && matchesRole && matchesStatus
})
}, [items, now, query, roleFilter, statusFilter])
const activeCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'active').length
const disabledCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'disabled').length
const expiredCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'expired').length
const usedCount = items.filter((item) => Boolean(item.lastUsedAt)).length
const filtersActive = Boolean(query.trim()) || roleFilter !== 'all' || statusFilter !== 'all'
function openCreate() {
setDraft({ name: '', role: 'viewer', ttlHours: 0 })
setPlainKey('')
setModalVisible(true)
}
function closeModal() {
setModalVisible(false)
setPlainKey('')
}
async function handleSubmit() {
const payload = { ...draft, name: draft.name.trim(), ttlHours: Number(draft.ttlHours ?? 0) }
if (!payload.name) {
if (!draft.name.trim()) {
Message.error('名称不能为空')
return
}
if (payload.ttlHours < 0 || payload.ttlHours > 87600) {
Message.error('有效期需要在 0 到 87600 小时之间')
return
}
setSubmitting(true)
try {
const result = await createApiKey(payload)
const result = await createApiKey(draft)
setPlainKey(result.plainKey)
await load()
} catch (submitError) {
Message.error(resolveErrorMessage(submitError, '创建失败'))
} catch (e) {
Message.error(resolveErrorMessage(e, '创建失败'))
} finally {
setSubmitting(false)
}
}
async function handleToggle(item: ApiKeySummary) {
setRowAction(`toggle:${item.id}`)
try {
await toggleApiKey(item.id, !item.disabled)
Message.success(item.disabled ? 'API Key 已启用' : 'API Key 已停用')
Message.success(item.disabled ? '已启用' : '已停用')
await load()
} catch (toggleError) {
Message.error(resolveErrorMessage(toggleError, '操作失败'))
} finally {
setRowAction('')
} catch (e) {
Message.error(resolveErrorMessage(e, '操作失败'))
}
}
async function handleRevoke(item: ApiKeySummary) {
setRowAction(`revoke:${item.id}`)
if (!window.confirm(`确定撤销 API Key「${item.name}」?操作不可撤销。`)) return
try {
await revokeApiKey(item.id)
Message.success('API Key 已撤销')
Message.success('已撤销')
await load()
} catch (revokeError) {
Message.error(resolveErrorMessage(revokeError, '撤销失败'))
} finally {
setRowAction('')
} catch (e) {
Message.error(resolveErrorMessage(e, '撤销失败'))
}
}
@@ -162,221 +95,83 @@ export function ApiKeysPage() {
}
}
if (!isAdmin(user)) {
return <Alert type="warning" content="当前账号无权访问 API Key 管理(仅 admin" />
}
return (
<AdminDataSection
title="API Key"
description="为 CI/CD、监控和自动化任务签发独立凭据并集中管理权限、有效期、使用状态与撤销操作。"
metrics={[
{ label: '凭据总数', value: items.length, detail: `${usedCount} 个凭据已有调用记录` },
{ label: '当前可用', value: activeCount, detail: '未停用且未超过有效期' },
{ label: '已停用', value: disabledCount, detail: '保留记录,可再次启用' },
{ label: '已过期', value: expiredCount, detail: '到期后无法继续认证' },
]}
actions={(
<Space>
<Button icon={<IconList />} onClick={() => navigate('/audit?category=api_key')}></Button>
<Button type="primary" icon={<IconPlus />} onClick={openCreate}> API Key</Button>
</Space>
)}
toolbar={(
<div className="admin-toolbar">
<div className="admin-toolbar__filters">
<Input
style={{ width: 260 }}
allowClear
prefix={<IconSearch />}
value={query}
aria-label="搜索 API Key"
placeholder="搜索名称、前缀或创建者"
onChange={setQuery}
/>
<Select
style={{ width: 150 }}
value={roleFilter}
options={[{ label: '全部角色', value: 'all' }, ...adminRoleOptions]}
onChange={(value) => setRoleFilter(value as UserRole | 'all')}
/>
<Select
style={{ width: 140 }}
value={statusFilter}
options={[
{ label: '全部状态', value: 'all' },
{ label: '当前可用', value: 'active' },
{ label: '已停用', value: 'disabled' },
{ label: '已过期', value: 'expired' },
]}
onChange={(value) => setStatusFilter(value as ApiKeyStatusFilter)}
/>
<Button
type="text"
disabled={!filtersActive}
onClick={() => { setQuery(''); setRoleFilter('all'); setStatusFilter('all') }}
>
</Button>
</div>
<div className="admin-toolbar__status">
<Typography.Text type="secondary"> {filteredItems.length} / {items.length}</Typography.Text>
<Button icon={<IconRefresh />} loading={loading} onClick={() => void load()}></Button>
</div>
</div>
)}
>
{error ? (
<div className="admin-data-panel__alert">
<Alert type="error" content={error} />
</div>
) : null}
<Table
rowKey="id"
loading={loading}
data={filteredItems}
stripe
pagination={filteredItems.length > 10 ? { pageSize: 10 } : false}
noDataElement={<Empty description={filtersActive ? '没有符合筛选条件的 API Key' : '暂无 API Key'} />}
columns={[
{
title: '名称',
dataIndex: 'name',
width: 190,
render: (value: string, row: ApiKeySummary) => (
<div className="admin-identity">
<Typography.Text>{value}</Typography.Text>
<span className="admin-identity__secondary" title={`${row.createdBy || '-'} 创建于 ${formatDateTime(row.createdAt)}`}>
{row.createdBy || '-'} · {formatDateTime(row.createdAt)}
</span>
</div>
),
},
{
title: '角色',
dataIndex: 'role',
width: 90,
render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag>,
},
{
title: 'Key 前缀',
dataIndex: 'prefix',
width: 135,
render: (value: string) => <span className="admin-key-prefix">{value}</span>,
},
{
title: '最近使用',
dataIndex: 'lastUsedAt',
width: 160,
render: (value?: string) => value ? <span className="admin-date">{formatDateTime(value)}</span> : '从未使用',
},
{
title: '有效期',
dataIndex: 'expiresAt',
width: 160,
render: (value?: string) => value ? <span className="admin-date">{formatDateTime(value)}</span> : '永不过期',
},
{
title: '状态',
dataIndex: 'disabled',
width: 90,
render: (_: boolean, row: ApiKeySummary) => {
const status = resolveApiKeyStatus(row, now)
if (status === 'expired') return <Tag color="orange" bordered></Tag>
if (status === 'disabled') return <Tag color="red" bordered></Tag>
return <Tag color="green" bordered></Tag>
},
},
{
title: '操作',
width: 150,
render: (_: unknown, row: ApiKeySummary) => {
const expired = resolveApiKeyStatus(row, now) === 'expired'
return (
<Space>
{expired ? (
<Tooltip content="已过期凭据不能重新启用,请生成新凭据">
<span><Button size="small" type="text" disabled></Button></span>
</Tooltip>
) : (
<Button
size="small"
type="text"
loading={rowAction === `toggle:${row.id}`}
onClick={() => void handleToggle(row)}
>
{row.disabled ? '启用' : '停用'}
</Button>
)}
<Popconfirm
title={`确定撤销 API Key「${row.name}」?`}
content="撤销后无法恢复,使用该凭据的自动化任务将立即失效。"
onOk={() => handleRevoke(row)}
>
<Button
size="small"
type="text"
status="danger"
icon={<IconDelete />}
loading={rowAction === `revoke:${row.id}`}
>
</Button>
</Popconfirm>
</Space>
)
},
},
]}
/>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title heading={4}>API Key</Typography.Title>
<Typography.Paragraph type="secondary">
API Key CI/CD访 BackupX <Typography.Text code>Authorization: Bearer bax_xxx</Typography.Text> <Typography.Text code>X-Api-Key: bax_xxx</Typography.Text>
</Typography.Paragraph>
</div>
<Space>
<Button type="primary" onClick={openCreate}> API Key</Button>
</Space>
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
<Card>
<Table
rowKey="id"
loading={loading}
data={items}
pagination={false}
stripe
noDataElement={<Empty description="暂无 API Key" />}
columns={[
{ title: '名称', dataIndex: 'name' },
{ title: '角色', dataIndex: 'role', render: (v: string) => <Tag color="arcoblue" bordered>{roleLabel(v)}</Tag> },
{ title: 'Key 前缀', dataIndex: 'prefix', render: (v: string) => <Typography.Text code>{v}</Typography.Text> },
{ title: '创建者', dataIndex: 'createdBy', render: (v: string) => v || '-' },
{ title: '最近使用', dataIndex: 'lastUsedAt', render: (v?: string) => v ? formatDateTime(v) : '从未使用' },
{ title: '过期', dataIndex: 'expiresAt', render: (v?: string) => v ? formatDateTime(v) : '永不过期' },
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered></Tag> : <Tag color="green" bordered></Tag> },
{ title: '操作', width: 180, render: (_: unknown, row: ApiKeySummary) => (
<Space>
<Button size="small" type="text" onClick={() => void handleToggle(row)}>{row.disabled ? '启用' : '停用'}</Button>
<Button size="small" type="text" status="danger" onClick={() => void handleRevoke(row)}></Button>
</Space>
) },
]}
/>
</Card>
<Modal
visible={modalVisible}
title={plainKey ? '保存 API Key' : '生成 API Key'}
style={{ width: 640 }}
onCancel={closeModal}
onOk={plainKey ? closeModal : handleSubmit}
okText={plainKey ? '我已保存' : '生成'}
title="生成 API Key"
onCancel={() => { setModalVisible(false); setPlainKey('') }}
onOk={plainKey ? () => { setModalVisible(false); setPlainKey('') } : handleSubmit}
okText={plainKey ? '完成' : '生成'}
confirmLoading={submitting}
unmountOnExit
>
{plainKey ? (
<div className="admin-key-result">
<Alert type="warning" content="明文 Key 只显示一次。关闭窗口前,请将它保存到安全的密钥管理系统。" />
<Input.TextArea value={plainKey} autoSize={{ minRows: 2, maxRows: 3 }} readOnly />
<Button type="outline" icon={<IconCopy />} onClick={() => void copyPlainKey()}></Button>
</div>
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Alert type="warning" content="明文 Key 只显示一次,请立即妥善保存。" />
<Input.TextArea value={plainKey} autoSize readOnly />
<Button type="outline" onClick={() => void copyPlainKey()}></Button>
</Space>
) : (
<Form layout="vertical">
<Form.Item label="名称" required>
<Input
value={draft.name}
maxLength={128}
showWordLimit
placeholder="例如ci-deploy-script"
onChange={(value) => setDraft({ ...draft, name: value })}
/>
<Input value={draft.name} onChange={(v) => setDraft({ ...draft, name: v })} placeholder="例如ci-deploy-script" />
</Form.Item>
<Form.Item label="角色" required>
<Select value={draft.role} options={roleOptions} onChange={(v: UserRole) => setDraft({ ...draft, role: v })} />
</Form.Item>
<Form.Item label="有效期小时0=永不过期)">
<InputNumber style={{ width: '100%' }} min={0} value={draft.ttlHours ?? 0} onChange={(v) => setDraft({ ...draft, ttlHours: Number(v ?? 0) })} />
</Form.Item>
<Grid.Row gutter={16}>
<Grid.Col span={12}>
<Form.Item label="角色" required>
<AdminRoleSelect value={draft.role} onChange={(role) => setDraft({ ...draft, role })} />
<span className="admin-form-note">{adminRoleDescriptions[draft.role]}</span>
</Form.Item>
</Grid.Col>
<Grid.Col span={12}>
<Form.Item label="有效期">
<InputNumber
style={{ width: '100%' }}
min={0}
max={87600}
suffix="小时"
value={draft.ttlHours ?? 0}
onChange={(value) => setDraft({ ...draft, ttlHours: Number(value ?? 0) })}
/>
<span className="admin-form-note">0 </span>
</Form.Item>
</Grid.Col>
</Grid.Row>
</Form>
)}
</Modal>
</AdminDataSection>
</Space>
)
}
// 避免未使用告警
void Switch

View File

@@ -1,71 +1,40 @@
import {
Alert,
Button,
Empty,
Form,
Grid,
Input,
Message,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Tooltip,
Typography,
} from '@arco-design/web-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { AdminDataSection } from '../../components/admin/AdminDataSection'
import { AdminRoleSelect, adminRoleDescriptions, adminRoleOptions } from '../../components/admin/AdminRoleSelect'
import { IconDelete, IconEdit, IconList, IconPlus, IconRefresh, IconSafe, IconSearch } from '../../components/icons'
import { Alert, Button, Card, Empty, Form, Input, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { createUser, deleteUser, listUsers, resetUserTwoFactor, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users'
import { clearTrustedDeviceToken } from '../../services/auth'
import {
createUser,
deleteUser,
listUsers,
resetUserTwoFactor,
updateUser,
type UserRole,
type UserSummary,
type UserUpsertPayload,
} from '../../services/users'
import { useAuthStore } from '../../stores/auth'
import { resolveErrorMessage } from '../../utils/error'
import { formatDateTime } from '../../utils/format'
import { roleLabel } from '../../utils/permissions'
import { isAdmin, roleLabel } from '../../utils/permissions'
type UserStatusFilter = 'all' | 'enabled' | 'disabled'
const roleOptions = [
{ label: '管理员 (admin)', value: 'admin' },
{ label: '运维 (operator)', value: 'operator' },
{ label: '只读 (viewer)', value: 'viewer' },
]
function createEmpty(): UserUpsertPayload {
return { username: '', password: '', displayName: '', email: '', phone: '', role: 'operator', disabled: false }
}
// UsersPage admin 用户管理。非 admin 角色进入路由会被路由守卫拦截。
export function UsersPage() {
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const setUser = useAuthStore((state) => state.setUser)
const user = useAuthStore((s) => s.user)
const setUser = useAuthStore((s) => s.setUser)
const [items, setItems] = useState<UserSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [query, setQuery] = useState('')
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all')
const [statusFilter, setStatusFilter] = useState<UserStatusFilter>('all')
const [editing, setEditing] = useState<UserSummary | null>(null)
const [modalVisible, setModalVisible] = useState(false)
const [draft, setDraft] = useState<UserUpsertPayload>(createEmpty())
const [submitting, setSubmitting] = useState(false)
const [rowAction, setRowAction] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
setItems(await listUsers())
setError('')
} catch (loadError) {
setError(resolveErrorMessage(loadError, '加载用户失败'))
} catch (e) {
setError(resolveErrorMessage(e, '加载用户失败'))
} finally {
setLoading(false)
}
@@ -75,24 +44,6 @@ export function UsersPage() {
void load()
}, [load])
const filteredItems = useMemo(() => {
const keyword = query.trim().toLowerCase()
return items.filter((item) => {
const matchesQuery = !keyword || [item.username, item.displayName, item.email, item.phone]
.some((value) => value?.toLowerCase().includes(keyword))
const matchesRole = roleFilter === 'all' || item.role === roleFilter
const matchesStatus = statusFilter === 'all'
|| (statusFilter === 'enabled' && !item.disabled)
|| (statusFilter === 'disabled' && item.disabled)
return matchesQuery && matchesRole && matchesStatus
})
}, [items, query, roleFilter, statusFilter])
const enabledCount = items.filter((item) => !item.disabled).length
const adminCount = items.filter((item) => item.role === 'admin').length
const mfaCount = items.filter((item) => !item.disabled && item.mfaEnabled).length
const filtersActive = Boolean(query.trim()) || roleFilter !== 'all' || statusFilter !== 'all'
function openCreate() {
setEditing(null)
setDraft(createEmpty())
@@ -114,65 +65,51 @@ export function UsersPage() {
}
async function handleSubmit() {
const payload: UserUpsertPayload = {
...draft,
username: draft.username.trim(),
displayName: draft.displayName.trim(),
email: draft.email?.trim(),
phone: draft.phone?.trim(),
}
if (payload.username.length < 3) {
Message.error('用户名至少需要 3 个字符')
if (!draft.username.trim() || !draft.displayName.trim()) {
Message.error('用户名与显示名称不能为空')
return
}
if (!payload.displayName) {
Message.error('显示名称不能为空')
if (!editing && !draft.password?.trim()) {
Message.error('创建用户必须设置初始密码')
return
}
if ((!editing || payload.password?.trim()) && (payload.password?.length ?? 0) < 8) {
Message.error(editing ? '新密码至少需要 8 个字符' : '初始密码至少需要 8 个字符')
return
}
setSubmitting(true)
try {
if (editing) {
const updated = await updateUser(editing.id, payload)
const updated = await updateUser(editing.id, draft)
if (updated.id === user?.id) {
if (payload.password?.trim()) {
if (draft.password?.trim()) {
clearTrustedDeviceToken(updated.username)
}
setUser(updated)
}
Message.success('用户已更新')
} else {
await createUser(payload)
await createUser(draft)
Message.success('用户已创建')
}
setModalVisible(false)
await load()
} catch (submitError) {
Message.error(resolveErrorMessage(submitError, '保存失败'))
} catch (e) {
Message.error(resolveErrorMessage(e, '保存失败'))
} finally {
setSubmitting(false)
}
}
async function handleDelete(item: UserSummary) {
setRowAction(`delete:${item.id}`)
if (!window.confirm(`确定删除用户「${item.username}」吗?`)) return
try {
await deleteUser(item.id)
Message.success('用户已删除')
Message.success('已删除')
await load()
} catch (deleteError) {
Message.error(resolveErrorMessage(deleteError, '删除失败'))
} finally {
setRowAction('')
} catch (e) {
Message.error(resolveErrorMessage(e, '删除失败'))
}
}
async function handleResetTwoFactor(item: UserSummary) {
setRowAction(`mfa:${item.id}`)
if (!window.confirm(`确定重置用户「${item.username}」的全部 MFA 配置吗?该用户之后可仅凭密码登录。`)) return
try {
const updated = await resetUserTwoFactor(item.id)
if (updated.id === user?.id) {
@@ -181,274 +118,104 @@ export function UsersPage() {
}
Message.success('MFA 已重置')
await load()
} catch (resetError) {
Message.error(resolveErrorMessage(resetError, '重置 MFA 失败'))
} finally {
setRowAction('')
} catch (e) {
Message.error(resolveErrorMessage(e, '重置 MFA 失败'))
}
}
const editingSelf = editing?.id === user?.id
if (!isAdmin(user)) {
return <Alert type="warning" content="当前账号无权访问用户管理(仅 admin" />
}
return (
<AdminDataSection
title="用户账号"
description="维护登录账号、角色、联系方式和多因素认证状态。当前账号与最后一个管理员受到界面级保护。"
metrics={[
{ label: '账号总数', value: items.length, detail: '系统中的全部登录账号' },
{ label: '启用账号', value: enabledCount, detail: `${items.length - enabledCount} 个账号已停用` },
{ label: '管理员', value: adminCount, detail: '拥有访问管理权限' },
{ label: 'MFA 覆盖', value: `${mfaCount}/${enabledCount}`, detail: '已启用账号中的 MFA 使用情况' },
]}
actions={(
<Space>
<Button icon={<IconList />} onClick={() => navigate('/audit?category=user')}></Button>
<Button type="primary" icon={<IconPlus />} onClick={openCreate}></Button>
</Space>
)}
toolbar={(
<div className="admin-toolbar">
<div className="admin-toolbar__filters">
<Input
style={{ width: 260 }}
allowClear
prefix={<IconSearch />}
value={query}
aria-label="搜索用户"
placeholder="搜索用户名、名称或联系方式"
onChange={setQuery}
/>
<Select
style={{ width: 150 }}
value={roleFilter}
options={[{ label: '全部角色', value: 'all' }, ...adminRoleOptions]}
onChange={(value) => setRoleFilter(value as UserRole | 'all')}
/>
<Select
style={{ width: 140 }}
value={statusFilter}
options={[
{ label: '全部状态', value: 'all' },
{ label: '已启用', value: 'enabled' },
{ label: '已停用', value: 'disabled' },
]}
onChange={(value) => setStatusFilter(value as UserStatusFilter)}
/>
<Button
type="text"
disabled={!filtersActive}
onClick={() => { setQuery(''); setRoleFilter('all'); setStatusFilter('all') }}
>
</Button>
</div>
<div className="admin-toolbar__status">
<Typography.Text type="secondary"> {filteredItems.length} / {items.length}</Typography.Text>
<Button icon={<IconRefresh />} loading={loading} onClick={() => void load()}></Button>
</div>
</div>
)}
>
{error ? (
<div className="admin-data-panel__alert">
<Alert type="error" content={error} />
</div>
) : null}
<Table
rowKey="id"
loading={loading}
data={filteredItems}
stripe
pagination={filteredItems.length > 10 ? { pageSize: 10 } : false}
noDataElement={<Empty description={filtersActive ? '没有符合筛选条件的用户' : '暂无用户'} />}
columns={[
{
title: '用户',
dataIndex: 'username',
width: 170,
render: (value: string, row: UserSummary) => (
<div className="admin-identity">
<Space size={6}>
<Typography.Text>{value}</Typography.Text>
{row.id === user?.id ? <Tag bordered></Tag> : null}
</Space>
<span className="admin-identity__secondary">{row.displayName}</span>
<span className="admin-identity__secondary" title={formatDateTime(row.createdAt)}>
{formatDateTime(row.createdAt)}
</span>
</div>
),
},
{
title: '角色',
dataIndex: 'role',
width: 80,
render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag>,
},
{
title: '联系方式',
dataIndex: 'email',
width: 190,
render: (_: string, row: UserSummary) => (
<div className="admin-contact">
<Typography.Text>{row.email || '未配置邮箱'}</Typography.Text>
<span className="admin-contact__secondary">{row.phone || '未配置手机号'}</span>
</div>
),
},
{
title: '状态',
dataIndex: 'disabled',
width: 80,
render: (disabled: boolean) => disabled
? <Tag color="red" bordered></Tag>
: <Tag color="green" bordered></Tag>,
},
{
title: '多因素认证',
dataIndex: 'mfaEnabled',
width: 210,
render: (_: boolean, row: UserSummary) => row.mfaEnabled ? (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title heading={4}></Typography.Title>
<Typography.Paragraph type="secondary"></Typography.Paragraph>
</div>
<Space>
<Button type="primary" onClick={openCreate}></Button>
</Space>
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
<Card>
<Table
rowKey="id"
loading={loading}
data={items}
pagination={false}
stripe
noDataElement={<Empty description="暂无用户" />}
columns={[
{ title: '用户名', dataIndex: 'username', render: (value: string, row: UserSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text bold>{value}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.displayName}</Typography.Text>
</Space>
) },
{ title: '角色', dataIndex: 'role', render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag> },
{ title: '邮箱 / 手机', dataIndex: 'email', render: (_: string, row: UserSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text>{row.email || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.phone || '-'}</Typography.Text>
</Space>
) },
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered></Tag> : <Tag color="green" bordered></Tag> },
{ title: 'MFA', dataIndex: 'mfaEnabled', render: (_: boolean, row: UserSummary) => row.mfaEnabled ? (
<Space wrap size={4}>
{row.twoFactorEnabled ? <Tag color="green" bordered>TOTP</Tag> : null}
{row.webAuthnEnabled ? <Tag color="arcoblue" bordered>Passkey {row.webAuthnCredentialCount}</Tag> : null}
{row.emailOtpEnabled ? <Tag color="purple" bordered></Tag> : null}
{row.smsOtpEnabled ? <Tag color="orange" bordered></Tag> : null}
{row.trustedDeviceCount > 0 ? <Tag bordered> {row.trustedDeviceCount}</Tag> : null}
{row.twoFactorEnabled ? <Typography.Text type="secondary"> {row.twoFactorRecoveryCodesRemaining}</Typography.Text> : null}
{row.twoFactorEnabled ? <Typography.Text type="secondary" style={{ fontSize: 12 }}> {row.twoFactorRecoveryCodesRemaining}</Typography.Text> : null}
</Space>
) : <Tag bordered></Tag>,
},
{
title: '操作',
width: 270,
render: (_: unknown, row: UserSummary) => {
const deleteDisabled = row.id === user?.id || (row.role === 'admin' && adminCount <= 1)
const deleteReason = row.id === user?.id ? '不能删除当前登录账号' : '不能删除系统最后一个管理员'
return (
<Space wrap>
<Button size="small" type="text" icon={<IconEdit />} onClick={() => openEdit(row)}></Button>
{row.mfaEnabled ? (
<Popconfirm
title={`确定重置用户「${row.username}」的全部 MFA 配置?`}
content="重置后,该用户可仅凭密码登录。"
onOk={() => handleResetTwoFactor(row)}
>
<Button
size="small"
type="text"
icon={<IconSafe />}
loading={rowAction === `mfa:${row.id}`}
>
MFA
</Button>
</Popconfirm>
) : null}
{deleteDisabled ? (
<Tooltip content={deleteReason}>
<span>
<Button size="small" type="text" status="danger" icon={<IconDelete />} disabled></Button>
</span>
</Tooltip>
) : (
<Popconfirm
title={`确定删除用户「${row.username}」?`}
content="删除后无法恢复。"
onOk={() => handleDelete(row)}
>
<Button
size="small"
type="text"
status="danger"
icon={<IconDelete />}
loading={rowAction === `delete:${row.id}`}
>
</Button>
</Popconfirm>
)}
</Space>
)
},
},
]}
/>
) : <Tag bordered></Tag> },
{ title: '创建时间', dataIndex: 'createdAt' },
{ title: '操作', width: 260, render: (_: unknown, row: UserSummary) => (
<Space>
<Button size="small" type="text" onClick={() => openEdit(row)}></Button>
{row.mfaEnabled && <Button size="small" type="text" onClick={() => void handleResetTwoFactor(row)}> MFA</Button>}
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(row)} disabled={row.id === user?.id}></Button>
</Space>
) },
]}
/>
</Card>
<Modal
visible={modalVisible}
title={editing ? '编辑用户' : '新建用户'}
style={{ width: 680 }}
onCancel={() => setModalVisible(false)}
onOk={handleSubmit}
confirmLoading={submitting}
unmountOnExit
>
<Form layout="vertical">
<Grid.Row gutter={16}>
<Grid.Col span={12}>
<Form.Item label="用户名" required>
<Input
value={draft.username}
placeholder="至少 3 个字符"
disabled={Boolean(editing)}
onChange={(value) => setDraft({ ...draft, username: value })}
/>
</Form.Item>
</Grid.Col>
<Grid.Col span={12}>
<Form.Item label="显示名称" required>
<Input value={draft.displayName} onChange={(value) => setDraft({ ...draft, displayName: value })} />
</Form.Item>
</Grid.Col>
</Grid.Row>
<Grid.Row gutter={16}>
<Grid.Col span={12}>
<Form.Item label="邮箱">
<Input value={draft.email ?? ''} onChange={(value) => setDraft({ ...draft, email: value })} />
</Form.Item>
</Grid.Col>
<Grid.Col span={12}>
<Form.Item label="手机号">
<Input value={draft.phone ?? ''} onChange={(value) => setDraft({ ...draft, phone: value })} />
</Form.Item>
</Grid.Col>
</Grid.Row>
<Form.Item label={editing ? '新密码(留空不修改)' : '初始密码'} required={!editing}>
<Input.Password
value={draft.password}
placeholder="至少 8 个字符"
onChange={(value) => setDraft({ ...draft, password: value })}
/>
<Form.Item label="用户名" required>
<Input value={draft.username} onChange={(v) => setDraft({ ...draft, username: v })} disabled={!!editing} />
</Form.Item>
<Form.Item label="显示名称" required>
<Input value={draft.displayName} onChange={(v) => setDraft({ ...draft, displayName: v })} />
</Form.Item>
<Form.Item label="邮箱">
<Input value={draft.email} onChange={(v) => setDraft({ ...draft, email: v })} />
</Form.Item>
<Form.Item label="手机号">
<Input value={draft.phone} onChange={(v) => setDraft({ ...draft, phone: v })} />
</Form.Item>
<Form.Item label={editing ? '新密码(留空不修改)' : '初始密码'} required={!editing}>
<Input.Password value={draft.password} onChange={(v) => setDraft({ ...draft, password: v })} />
</Form.Item>
<Form.Item label="角色" required>
<Select value={draft.role} options={roleOptions} onChange={(v: UserRole) => setDraft({ ...draft, role: v })} />
</Form.Item>
<Form.Item label="停用账号">
<Switch checked={draft.disabled} onChange={(v) => setDraft({ ...draft, disabled: v })} />
</Form.Item>
<Grid.Row gutter={16}>
<Grid.Col span={12}>
<Form.Item label="角色" required>
<AdminRoleSelect
value={draft.role}
disabled={editingSelf}
onChange={(role) => setDraft({ ...draft, role })}
/>
<span className="admin-form-note">
{editingSelf ? '当前登录账号不能在此修改自身角色。' : adminRoleDescriptions[draft.role]}
</span>
</Form.Item>
</Grid.Col>
<Grid.Col span={12}>
<Form.Item label="账号状态">
<div className="admin-switch-field">
<Switch
checked={!draft.disabled}
disabled={editingSelf}
onChange={(enabled) => setDraft({ ...draft, disabled: !enabled })}
/>
<Typography.Text>{draft.disabled ? '已停用' : '已启用'}</Typography.Text>
</div>
{editingSelf ? <span className="admin-form-note"></span> : null}
</Form.Item>
</Grid.Col>
</Grid.Row>
</Form>
</Modal>
</AdminDataSection>
</Space>
)
}

View File

@@ -1,178 +0,0 @@
.admin-page {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
}
.admin-page__header {
padding: 0;
}
.admin-page__nav {
display: flex;
gap: 8px;
padding: 8px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-bg-2);
}
.admin-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-section__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.admin-section__title {
margin: 0 0 4px;
}
.admin-section__description {
max-width: 760px;
margin: 0;
}
.admin-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 24px;
padding: 16px 20px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-bg-2);
}
.admin-summary__item {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.admin-summary__value {
color: var(--color-text-1);
font-size: 24px;
line-height: 32px;
}
.admin-summary__detail {
overflow: hidden;
color: var(--color-text-3);
font-size: 12px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-data-panel {
overflow: hidden;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-bg-2);
}
.admin-data-panel__alert {
padding: 12px 16px 0;
}
.admin-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border-2);
}
.admin-toolbar__filters,
.admin-toolbar__status {
display: flex;
align-items: center;
gap: 8px;
}
.admin-identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-identity__secondary,
.admin-contact__secondary {
overflow: hidden;
color: var(--color-text-3);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-identity > .arco-typography {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-contact {
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-contact > .arco-typography {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-date,
.admin-key-prefix {
font-size: 12px;
white-space: nowrap;
}
.admin-form-note {
display: block;
margin-top: 6px;
color: var(--color-text-3);
font-size: 12px;
line-height: 20px;
}
.admin-switch-field {
display: flex;
align-items: center;
min-height: 32px;
gap: 8px;
}
.admin-key-result {
display: flex;
flex-direction: column;
gap: 12px;
}
@media (max-width: 1440px) {
.admin-summary {
gap: 16px;
}
.admin-toolbar {
align-items: flex-start;
flex-direction: column;
}
.admin-toolbar__status {
align-self: stretch;
justify-content: flex-end;
}
}

View File

@@ -1,7 +1,6 @@
import { Button, DatePicker, Input, InputNumber, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
import type { ColumnProps } from '@arco-design/web-react/es/Table'
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { exportAuditLogs, listAuditLogs } from '../../services/audit'
import { fetchSettings, updateSettings } from '../../services/system'
import { useAuthStore } from '../../stores/auth'
@@ -17,8 +16,6 @@ const categoryOptions = [
{ label: '备份任务', value: 'backup_task' },
{ label: '备份记录', value: 'backup_record' },
{ label: '系统设置', value: 'settings' },
{ label: '用户账号', value: 'user' },
{ label: 'API Key', value: 'api_key' },
]
const categoryLabels: Record<string, string> = {
@@ -27,8 +24,6 @@ const categoryLabels: Record<string, string> = {
backup_task: '备份任务',
backup_record: '备份记录',
settings: '系统设置',
user: '用户账号',
api_key: 'API Key',
}
const actionLabels: Record<string, string> = {
@@ -58,7 +53,6 @@ const actionLabels: Record<string, string> = {
delete: '删除',
enable: '启用',
disable: '停用',
revoke: '撤销',
run: '执行',
restore: '恢复',
}
@@ -111,15 +105,11 @@ const columns: ColumnProps<AuditLog>[] = [
]
export function AuditLogsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const requestedCategory = searchParams.get('category') ?? ''
const [logs, setLogs] = useState<AuditLog[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [category, setCategory] = useState(
categoryOptions.some((option) => option.value === requestedCategory) ? requestedCategory : '',
)
const [category, setCategory] = useState('')
const [username, setUsername] = useState('')
const [keyword, setKeyword] = useState('')
const [dateRange, setDateRange] = useState<string[] | null>(null)
@@ -200,7 +190,6 @@ export function AuditLogsPage() {
function handleReset() {
setCategory('')
setSearchParams({}, { replace: true })
setUsername('')
setKeyword('')
setDateRange(null)
@@ -241,11 +230,7 @@ export function AuditLogsPage() {
style={{ width: 160 }}
value={category}
options={categoryOptions}
onChange={(v) => {
setCategory(v)
setSearchParams(v ? { category: v } : {}, { replace: true })
setPage(1)
}}
onChange={(v) => { setCategory(v); setPage(1) }}
placeholder="分类"
/>
<Input

View File

@@ -11,7 +11,6 @@ import { ReplicationRecordsPage } from '../pages/replication-records/Replication
import { TaskTemplatesPage } from '../pages/task-templates/TaskTemplatesPage'
import { UsersPage } from '../pages/admin/UsersPage'
import { ApiKeysPage } from '../pages/admin/ApiKeysPage'
import { AdminLayout } from '../pages/admin/AdminLayout'
import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCallbackPage'
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
import { SettingsPage } from '../pages/settings/SettingsPage'
@@ -41,11 +40,8 @@ export function RouterView() {
<Route path="verify/records" element={<VerificationRecordsPage />} />
<Route path="replication/records" element={<ReplicationRecordsPage />} />
<Route path="task-templates" element={<TaskTemplatesPage />} />
<Route path="admin" element={<AdminLayout />}>
<Route index element={<Navigate to="users" replace />} />
<Route path="users" element={<UsersPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
</Route>
<Route path="admin/users" element={<UsersPage />} />
<Route path="admin/api-keys" element={<ApiKeysPage />} />
<Route path="storage-targets" element={<StorageTargetsPage />} />
<Route path="storage-targets/google-drive/callback" element={<GoogleDriveCallbackPage />} />
<Route path="settings" element={<SettingsPage />} />

View File

@@ -29,5 +29,6 @@
"src/pages/login/page.tsx",
"src/pages/system-info/page.tsx",
"src/stores/auth.test.ts"
]
],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,11 +1,11 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"noEmit": true,
"types": ["node"]
},
"include": ["vite.config.ts"]

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More