Compare commits

..

2 Commits

Author SHA1 Message Date
Awuqing
4cbd4c302a style(go): 统一后端代码格式 2026-08-09 19:42:15 +08:00
Awuqing
14b61b23a2 chore(repo): 完善仓库维护与自动化 2026-08-09 19:42:02 +08:00
228 changed files with 7378 additions and 12776 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -21,12 +21,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: '1.25'
cache-dependency-path: server/go.sum
- name: Verify modules
@@ -54,57 +54,24 @@ jobs:
working-directory: server
run: go test ./... -v
backend-windows:
name: Go Test (Windows)
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: "1.25"
cache-dependency-path: server/go.sum
- name: Verify modules
working-directory: server
run: go mod verify
- name: Build
working-directory: server
run: go build ./...
- name: Test
working-directory: server
run: go test ./...
frontend:
name: React Build & Test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
node-version: '24'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
working-directory: web
run: npm ci
- name: Audit production dependencies
working-directory: web
run: npm audit --omit=dev --audit-level=high
- name: Lint and formatting
working-directory: web
run: npm run lint && npm run format:check
- name: Test
working-directory: web
run: npm run test
@@ -112,48 +79,3 @@ jobs:
- name: Type Check & Build
working-directory: web
run: npm run build
docs:
name: Documentation Build
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
cache: "npm"
cache-dependency-path: docs-site/package-lock.json
- name: Install dependencies
working-directory: docs-site
run: npm ci
- name: Type Check & Build
working-directory: docs-site
run: npm run typecheck && npm run build
container:
name: Container Build
needs: [backend, backend-windows, frontend, docs]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- name: Validate Compose configuration
run: docker compose config --quiet
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build image
uses: docker/build-push-action@v7
with:
context: .
push: false
tags: backupx:ci
cache-from: type=gha,scope=backupx-ci
cache-to: type=gha,mode=max,scope=backupx-ci

View File

@@ -38,10 +38,10 @@ jobs:
working-directory: docs-site
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
@@ -58,7 +58,7 @@ jobs:
- name: Upload artifact
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v5
uses: actions/upload-pages-artifact@v3
with:
path: docs-site/build
@@ -77,4 +77,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
uses: actions/deploy-pages@v4

View File

@@ -18,11 +18,11 @@ name: Release
on:
push:
tags:
- "v*"
- 'v*'
workflow_dispatch:
inputs:
version:
description: "版本号(如 v1.2.3"
description: '版本号(如 v1.2.3'
required: true
type: string
@@ -44,7 +44,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Validate release input
shell: bash
@@ -63,9 +63,9 @@ jobs:
fi
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: '1.25'
cache-dependency-path: server/go.sum
- name: Verify backend
@@ -81,19 +81,16 @@ jobs:
go test ./...
- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
node-version: '24'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Verify frontend
working-directory: web
run: |
npm ci
npm audit --omit=dev --audit-level=high
npm run lint
npm run format:check
npm run test
# ─── Job 1: 构建前端 ───
@@ -103,13 +100,13 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
node-version: '24'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install & Build
@@ -119,7 +116,7 @@ jobs:
npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: web-dist
path: web/dist
@@ -141,16 +138,16 @@ jobs:
- goos: linux
goarch: arm64
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: '1.25'
cache-dependency-path: server/go.sum
- name: Download frontend artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: web-dist
path: web/dist
@@ -160,7 +157,7 @@ jobs:
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
CGO_ENABLED: '0'
run: |
go build \
-trimpath \
@@ -175,20 +172,20 @@ jobs:
cp backupx "${ARCHIVE_NAME}/"
cp -r web/dist "${ARCHIVE_NAME}/web"
cp server/config.example.yaml "${ARCHIVE_NAME}/"
cp deploy/install.sh "${ARCHIVE_NAME}/"
cp deploy/backupx.service "${ARCHIVE_NAME}/"
cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true
cp deploy/backupx.service "${ARCHIVE_NAME}/" 2>/dev/null || true
# v2.2+: 随发布包提供 Grafana dashboard 与 nginx.conf 模板
if [ -d deploy/grafana ]; then
cp -r deploy/grafana "${ARCHIVE_NAME}/grafana"
fi
cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf"
cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" 2>/dev/null || true
tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"
cp "${ARCHIVE_NAME}.tar.gz" "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz"
sha256sum "${ARCHIVE_NAME}.tar.gz" > "${ARCHIVE_NAME}.tar.gz.sha256"
sha256sum "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" > "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256"
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.VERSION }}
files: |
@@ -202,26 +199,26 @@ jobs:
# ─── Job 3: Docker 多架构 → Docker Hub ───
build-docker:
name: Build & Push Docker
needs: verify
needs: build-web
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build & Push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -229,7 +226,7 @@ jobs:
build-args: |
VERSION=${{ env.VERSION }}
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/backupx:latest
${{ secrets.DOCKERHUB_USERNAME }}/backupx:${{ env.VERSION }}
${{ !contains(env.VERSION, '-') && format('{0}/backupx:latest', secrets.DOCKERHUB_USERNAME) || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -43,7 +43,7 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}"
# ---- Stage 3: Production image ----
FROM alpine:3.24
FROM alpine:3.21
ARG USE_CHINA_MIRROR
# 国内镜像Alpine apk 使用阿里云源

View File

@@ -51,8 +51,7 @@ verify: verify-server verify-web verify-docs
verify-server: format-check vet-server test-server build-server
verify-web:
cd web && npm run lint && npm run format:check && npm run test && npm run build
verify-web: test-web build-web
verify-docs: check-docs

View File

@@ -28,12 +28,12 @@
<table>
<tr>
<td width="50%"><img src="screenshots/dashboard.png" alt="BackupX dashboard with 30-day backup trends, storage distribution, and task health"><br><sub><strong>Dashboard</strong> — 30-day success and failure trends, storage distribution, task volume, and cluster health.</sub></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="BackupX task list with schedules, targets, retention, tags, RPO, and verification status"><br><sub><strong>Backup tasks</strong> — schedules, multi-target policies, retention, tags, RPO goals, and recurring verification.</sub></td>
<td width="50%"><img src="screenshots/dashboard.png" alt="Dashboard"></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="Backup Tasks"></td>
</tr>
<tr>
<td><img src="screenshots/storage-targets.png" alt="BackupX storage targets with connection health, capacity usage, and redundancy roles"><br><sub><strong>Storage targets</strong> — connection health, live capacity, favourites, and redundancy roles at a glance.</sub></td>
<td><img src="screenshots/backup-records.png" alt="BackupX backup history with success and failure states, checksums, destinations, and retention locks"><br><sub><strong>Backup records</strong> — success and failure states, checksums, destinations, execution logs, and retention locks.</sub></td>
<td><img src="screenshots/storage-targets.png" alt="Storage Targets"></td>
<td><img src="screenshots/backup-records.png" alt="Backup Records"></td>
</tr>
</table>

View File

@@ -28,12 +28,12 @@
<table>
<tr>
<td width="50%"><img src="screenshots/dashboard.png" alt="BackupX 仪表盘,展示 30 天备份趋势、存储分布和任务健康度"><br><sub><strong>仪表盘</strong> — 30 天成功/失败趋势、存储分布、任务规模与集群健康度。</sub></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="BackupX 备份任务列表展示调度、存储目标、保留策略、标签、RPO 和验证状态"><br><sub><strong>备份任务</strong> — 调度、双目标策略、保留规则、标签、RPO 与定时验证。</sub></td>
<td width="50%"><img src="screenshots/dashboard.png" alt="仪表盘"></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="备份任务"></td>
</tr>
<tr>
<td><img src="screenshots/storage-targets.png" alt="BackupX 存储目标,展示连接状态、容量使用和冗余角色"><br><sub><strong>存储目标</strong> — 连接状态、实时容量、收藏及冗余角色一目了然。</sub></td>
<td><img src="screenshots/backup-records.png" alt="BackupX 备份历史,展示成功失败状态、校验和、存储位置及保留锁"><br><sub><strong>备份记录</strong> — 成功/失败状态、校验和、落盘位置、执行日志与保留锁定。</sub></td>
<td><img src="screenshots/storage-targets.png" alt="存储目标"></td>
<td><img src="screenshots/backup-records.png" alt="备份记录"></td>
</tr>
</table>

View File

@@ -25,17 +25,17 @@ For significant features or refactors, open an issue first to align on scope bef
## Pull requests
1. Fork and create a topic branch (e.g. `fix/windows-path-escape`)
2. Run `make verify` and make sure formatting, tests, builds, and documentation checks pass
2. Run `make test` and make sure everything passes
3. Keep changes focused — one concern per PR
4. Write Conventional Commits with a Chinese subject — examples:
- `feat(audit): 新增审计日志模块`
- `fix(browser): 修复目录浏览器无法进入子目录`
- `refactor(storage): 简化存储目标解密逻辑`
- Types: `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `chore`
4. Write commit messages in Chinese following `类型: 简要描述` — examples:
- `功能: 新增审计日志模块`
- `修复: 目录浏览器无法进入子目录`
- `重构: 简化存储目标解密逻辑`
- Types: `功能` / `修复` / `重构` / `文档` / `构建` / `测试`
5. PR title and body in Chinese too. Describe the why and how, not just the what.
## Coding guidelines
- **Go** — handle every error (no `_ = err`); use the existing logger (`zap`); no `fmt.Println` in production paths
- **TypeScript** — strict mode, no implicit any, and pass the repository ESLint and Prettier checks
- **TypeScript** — strict mode, no implicit any, follow existing ESLint/Prettier configs
- **Commit scope** — one logical change per commit; don't mix drive-by cleanups with feature work

View File

@@ -76,24 +76,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 +105,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: 'Upgrade & Recovery', to: '/docs/operations/upgrade-recovery'},
],
},
{
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 仪表盘,展示 30 天备份趋势、存储分布、任务规模和集群健康度"},
"showcase.dashboard.title": {"message": "一眼掌握运行态"},
"showcase.dashboard.desc": {"message": "在一个实时运维视图中掌握 30 天成功/失败趋势、存储分布、任务数量、数据量和最近执行记录。"},
"showcase.tasks.alt": {"message": "BackupX 任务列表展示调度、存储目标、保留策略、标签、RPO 目标和验证状态"},
"showcase.tasks.title": {"message": "策略状态清晰可扫"},
"showcase.tasks.desc": {"message": "集中查看调度、多目标策略、保留规则、标签、RPO 目标和验证状态,并可一键操作任意任务。"},
"showcase.storage.alt": {"message": "BackupX 存储目标,展示连接状态、实时容量、收藏和冗余角色"},
"showcase.storage.title": {"message": "每个目标,统一视图"},
"showcase.storage.desc": {"message": "统一比较本地磁盘与 70+ 远程后端的连接状态、实时容量、收藏及冗余角色。"},
"showcase.nodes.alt": {"message": "BackupX 节点列表展示健康度、Agent 版本、队列深度、标签和心跳时间"},
"showcase.nodes.title": {"message": "集群健康一屏掌握"},
"showcase.nodes.desc": {"message": "集中监控本地 Master 与每个远程节点的健康度、Agent 版本、队列深度、标签和心跳时间。"},
"showcase.dashboard.title": {"message": "一眼掌握全局"},
"showcase.dashboard.desc": {"message": "备份成功率、存储使用量、最近执行记录、即将触发的计划 — 一页实时数据。"},
"showcase.tasks.title": {"message": "可视化任务编辑器"},
"showcase.tasks.desc": {"message": "文件、MySQL、PostgreSQL、SQLite、SAP HANA — 三步完成。Cron 编辑器、多目标分发、保留策略、压缩、加密 — 点击即用。"},
"showcase.storage.title": {"message": "70+ 后端,统一体验"},
"showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"},
"showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"},
"showcase.nodes.desc": {"message": "创建节点、复制令牌、在任意远程主机启动 Agent。路由到节点的任务在本地执行并直接上传到存储 — 无需反向连通性。"},
"showcase.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

@@ -25,17 +25,17 @@ BackupX 使用 Apache License 2.0 开源,欢迎提交 Issue 与 Pull Request
## 提交 PR
1. Fork 仓库,创建主题分支(如 `fix/windows-path-escape`
2. 执行 `make verify`确认格式、测试、构建和文档检查全部通过
2. 执行 `make test` 确认本地全通过
3. 保持每个 PR 只做一件事
4. Commit message 使用 Conventional Commits主题使用中文
- `feat(audit): 新增审计日志模块`
- `fix(browser): 修复目录浏览器无法进入子目录`
- `refactor(storage): 简化存储目标解密逻辑`
- 类型:`feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `chore`
4. Commit message 使用中文,格式 `类型: 简要描述`
- `功能: 新增审计日志模块`
- `修复: 目录浏览器无法进入子目录`
- `重构: 简化存储目标解密逻辑`
- 类型:`功能` / `修复` / `重构` / `文档` / `构建` / `测试`
5. PR 标题和正文同样使用中文,描述"为什么"和"怎么做",而非仅仅"做了什么"
## 代码规范
- **Go** — 所有错误必须处理(禁止 `_ = err`),日志使用现有 `zap`,禁止生产路径中出现 `fmt.Println`
- **TypeScript** — 严格模式,禁止隐式 any并通过仓库中的 ESLintPrettier 检查
- **TypeScript** — 严格模式,禁止隐式 any遵循现有 ESLint/Prettier 配置
- **Commit 粒度** — 每个 commit 一件事,不要把顺手的小修改和功能代码混在一起

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.Upgrade & Recovery": {"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

@@ -14,14 +14,14 @@
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.2",
"@docusaurus/tsconfig": "3.10.2",
"@docusaurus/types": "3.10.2",
"@types/react": "^19.2.18",
"@types/react": "^19.0.0",
"typescript": "~6.0.2"
},
"engines": {
@@ -6293,9 +6293,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"version": "19.2.14",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -16429,24 +16429,24 @@
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"version": "19.2.5",
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"version": "19.2.5",
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
"react": "^19.2.5"
}
},
"node_modules/react-fast-compare": {

View File

@@ -21,14 +21,14 @@
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.2",
"@docusaurus/tsconfig": "3.10.2",
"@docusaurus/types": "3.10.2",
"@types/react": "^19.2.18",
"@types/react": "^19.0.0",
"typescript": "~6.0.2"
},
"browserslist": {

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,64 +20,44 @@ 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 30-day backup trends, storage distribution, task volume and cluster health',
}),
title: <Translate id="showcase.dashboard.title">Operations at a glance</Translate>,
title: <Translate id="showcase.dashboard.title">Know at a glance</Translate>,
description: (
<Translate id="showcase.dashboard.desc">
Track 30-day success and failure trends, storage distribution, task counts, data volume and recent runs from one live operations view.
Backup success rates, storage usage, recent runs and upcoming schedules all on one page with live data.
</Translate>
),
},
{
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 task list showing schedules, storage targets, retention, tags, RPO goals and verification status',
}),
title: <Translate id="showcase.tasks.title">Policies you can scan</Translate>,
title: <Translate id="showcase.tasks.title">Visual task editor</Translate>,
description: (
<Translate id="showcase.tasks.desc">
Review schedules, multi-target policies, retention, tags, RPO goals and verification status together, then act on any task in one click.
Files, MySQL, PostgreSQL, SQLite and SAP HANA with a three-step wizard. Cron editor, multi-target dispatch, retention, compression and encryption point and click.
</Translate>
),
},
{
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 targets showing connection health, live capacity, favourites and redundancy roles',
}),
title: <Translate id="showcase.storage.title">Every target, one view</Translate>,
title: <Translate id="showcase.storage.title">70+ backends, one flow</Translate>,
description: (
<Translate id="showcase.storage.desc">
Compare connection health, live capacity, favourites and redundancy roles across local disks and 70+ remote backends.
Alibaba OSS, Tencent COS, S3, Google Drive, WebDAV plus every rclone backend behind a uniform form. Test connection, favourite, and view live usage.
</Translate>
),
},
{
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 node list showing health, Agent versions, queue depth, labels and heartbeat times',
}),
title: <Translate id="showcase.nodes.title">Cluster health in one view</Translate>,
title: <Translate id="showcase.nodes.title">Master-Agent in minutes</Translate>,
description: (
<Translate id="showcase.nodes.desc">
Monitor health, Agent versions, queue depth, labels and heartbeat time across the local Master and every remote node.
Create a node, copy the token, start the Agent on any remote host. Tasks routed to a node run locally there and upload directly to storage no reverse connectivity required.
</Translate>
),
},
@@ -95,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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 96 KiB

BIN
server/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1,10 +1,15 @@
APP_NAME=backupx
BUILD_DIR=./bin
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
.PHONY: build run test
build:
$(MAKE) -C .. build-server
mkdir -p $(BUILD_DIR)
go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx
run:
$(MAKE) -C .. dev-server
go run -ldflags "-X main.version=$(VERSION)" ./cmd/backupx
test:
$(MAKE) -C .. test-server
go test ./...

View File

@@ -9,8 +9,6 @@ import (
"syscall"
"backupx/server/internal/agent"
"backupx/server/internal/config"
applogger "backupx/server/internal/logger"
)
// runAgent 是 `backupx agent` 子命令入口。
@@ -61,23 +59,11 @@ func runAgent(args []string) {
os.Exit(2)
}
agentLogger, err := applogger.New(config.LogConfig{Level: "info"})
if err != nil {
fmt.Fprintf(os.Stderr, "agent: init logger: %v\n", err)
os.Exit(1)
}
defer func() {
if syncErr := agentLogger.Sync(); syncErr != nil {
fmt.Fprintf(os.Stderr, "agent: flush logger: %v\n", syncErr)
}
}()
a, err := agent.New(cfg, version)
if err != nil {
fmt.Fprintf(os.Stderr, "agent: init: %v\n", err)
os.Exit(1)
}
a.SetLogger(agentLogger)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

View File

@@ -3,143 +3,141 @@ module backupx/server
go 1.25.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/klauspost/compress v1.19.2
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/klauspost/compress v1.18.1
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.24.1
github.com/rclone/rclone v1.75.0
github.com/prometheus/client_golang v1.23.2
github.com/rclone/rclone v1.73.5
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.26.6
github.com/spf13/viper v1.21.0
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.55.0
golang.org/x/oauth2 v0.36.0
google.golang.org/api v0.279.0
github.com/spf13/viper v1.20.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.50.0
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.255.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.31.2
gorm.io/gorm v1.25.12
)
require (
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.39 // indirect
github.com/Files-com/files-sdk-go/v3 v3.3.194 // indirect
github.com/IBM/go-sdk-core/v5 v5.23.1 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.38 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
github.com/IBM/go-sdk-core/v5 v5.18.5 // indirect
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/ProtonMail/gopenpgp/v3 v3.4.1 // indirect
github.com/PuerkitoBio/goquery v1.12.0 // indirect
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect
github.com/abbot/go-http-auth v0.4.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/anchore/go-lzo v0.1.1 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/apache/arrow-go/v18 v18.7.0 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.30 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
github.com/aws/smithy-go v1.27.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/buengese/sgzip v0.1.1 // indirect
github.com/buger/jsonparser v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.4 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.16.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20260701064823-d8c5600d73b8 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.13.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/colinmarc/hdfs/v2 v2.4.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/creasty/defaults v1.8.0 // indirect
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diskfs/go-diskfs v1.9.4 // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/dromara/dongle v1.0.1 // indirect
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.4.0 // indirect
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-chi/chi/v5 v5.3.1 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/errors v0.22.8 // indirect
github.com/go-openapi/strfmt v0.27.0 // indirect
github.com/go-openapi/errors v0.22.4 // indirect
github.com/go-openapi/strfmt v0.25.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260708165336-dd6561bacfa2 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
@@ -148,74 +146,72 @@ require (
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb // indirect
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lanrat/extsort v1.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lpar/calendar v0.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/lpar/date v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/ncw/swift/v2 v2.0.5 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/onsi/ginkgo/v2 v2.19.0 // indirect
github.com/oracle/oci-go-sdk/v65 v65.121.0 // indirect
github.com/panjf2000/ants/v2 v2.12.1 // indirect
github.com/oracle/oci-go-sdk/v65 v65.104.0 // indirect
github.com/panjf2000/ants/v2 v2.11.3 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/peterh/liner v1.2.2 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/sftp v1.13.11 // indirect
github.com/pkg/sftp v1.13.10 // indirect
github.com/pkg/xattr v0.4.12 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/rclone/Proton-API-Bridge v1.0.4 // indirect
github.com/rclone/go-proton-api v1.0.3 // indirect
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect
github.com/relvacode/iso8601 v1.7.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rfjakob/eme v1.2.0 // indirect
github.com/rfjakob/eme v1.1.2 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.10 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/sony/gobreaker/v2 v2.4.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20260717075258-c6acd6a5bd04 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
@@ -224,42 +220,42 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
github.com/zeebo/errs v1.4.0 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect
golang.org/x/image v0.44.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d // indirect
google.golang.org/grpc v1.84.0-dev.0.20260723093437-b6eac429d7b6 // indirect
google.golang.org/protobuf v1.36.11 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.14.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.53.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
storj.io/common v0.0.0-20260629224719-ba1bff0a7846 // indirect
storj.io/drpc v1.0.0 // indirect
storj.io/eventkit v0.0.0-20260716074419-6861a92e2aa5 // indirect
storj.io/common v0.0.0-20251107171817-6221ae45072c // indirect
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
storj.io/infectious v0.0.2 // indirect
storj.io/picobuf v0.0.4 // indirect
storj.io/uplink v1.14.3 // indirect
storj.io/uplink v1.13.1 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"os"
"runtime"
@@ -12,7 +13,6 @@ import (
"time"
"backupx/server/internal/backup"
"go.uber.org/zap"
)
// Agent 是 Agent 进程的主控制器。
@@ -21,7 +21,6 @@ type Agent struct {
client *MasterClient
executor *Executor
version string
logger *zap.Logger
mu sync.Mutex
started bool
@@ -45,20 +44,9 @@ func New(cfg *Config, version string) (*Agent, error) {
client: client,
executor: executor,
version: version,
logger: zap.NewNop(),
}, nil
}
// SetLogger attaches the process logger used by the Agent runtime loop.
func (a *Agent) SetLogger(logger *zap.Logger) {
if logger != nil {
a.logger = logger
if a.executor != nil {
a.executor.SetLogger(logger)
}
}
}
// Run 启动 Agent 主循环,阻塞直到 ctx 被取消。
func (a *Agent) Run(ctx context.Context) error {
a.mu.Lock()
@@ -76,7 +64,7 @@ func (a *Agent) Run(ctx context.Context) error {
if err := a.heartbeatOnce(ctx); err != nil {
return fmt.Errorf("initial heartbeat failed: %w", err)
}
a.logger.Info("agent connected to master", zap.String("master", a.cfg.Master))
log.Printf("[agent] connected to master %s", a.cfg.Master)
var wg sync.WaitGroup
wg.Add(2)
@@ -102,17 +90,14 @@ func (a *Agent) heartbeatLoop(ctx context.Context, interval time.Duration) {
return
case <-ticker.C:
if err := a.heartbeatOnce(ctx); err != nil {
a.logger.Warn("agent heartbeat failed", zap.Error(err))
log.Printf("[agent] heartbeat failed: %v", err)
}
}
}
}
func (a *Agent) heartbeatOnce(ctx context.Context) error {
hostname, err := os.Hostname()
if err != nil {
a.logger.Warn("resolve agent hostname failed", zap.Error(err))
}
hostname, _ := os.Hostname()
req := HeartbeatRequest{
Hostname: hostname,
IPAddress: detectLocalIP(),
@@ -120,7 +105,7 @@ func (a *Agent) heartbeatOnce(ctx context.Context) error {
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
_, err = a.client.Heartbeat(ctx, req)
_, err := a.client.Heartbeat(ctx, req)
return err
}
@@ -141,13 +126,13 @@ func (a *Agent) pollLoop(ctx context.Context, interval time.Duration) {
func (a *Agent) pollAndHandleOnce(ctx context.Context) {
cmd, err := a.client.PollCommand(ctx)
if err != nil {
a.logger.Warn("poll agent command failed", zap.Error(err))
log.Printf("[agent] poll command failed: %v", err)
return
}
if cmd == nil {
return
}
a.logger.Info("agent command received", zap.Uint("command_id", cmd.ID), zap.String("command_type", cmd.Type))
log.Printf("[agent] received command #%d type=%s", cmd.ID, cmd.Type)
switch cmd.Type {
case "run_task":
a.handleRunTask(ctx, cmd)
@@ -161,8 +146,8 @@ func (a *Agent) pollAndHandleOnce(ctx context.Context) {
a.handleDeleteStorageObject(ctx, cmd)
default:
msg := fmt.Sprintf("unknown command type: %s", cmd.Type)
a.logger.Warn("unknown agent command", zap.Uint("command_id", cmd.ID), zap.String("command_type", cmd.Type))
a.submitCommandResult(ctx, cmd.ID, false, msg, nil)
log.Printf("[agent] %s", msg)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, msg, nil)
}
}
@@ -173,14 +158,14 @@ func (a *Agent) handleRunTask(ctx context.Context, cmd *CommandPayload) {
RecordID uint `json:"recordId"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
a.submitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
if err := a.executor.ExecuteRunTask(ctx, payload.TaskID, payload.RecordID); err != nil {
a.submitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
a.submitCommandResult(ctx, cmd.ID, true, "", map[string]any{
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{
"taskId": payload.TaskID,
"recordId": payload.RecordID,
})
@@ -192,18 +177,18 @@ func (a *Agent) handleRestoreRecord(ctx context.Context, cmd *CommandPayload) {
RestoreRecordID uint `json:"restoreRecordId"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
a.submitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
if payload.RestoreRecordID == 0 {
a.submitCommandResult(ctx, cmd.ID, false, "restoreRecordId is required", nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "restoreRecordId is required", nil)
return
}
if err := a.executor.ExecuteRestore(ctx, payload.RestoreRecordID); err != nil {
a.submitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
a.submitCommandResult(ctx, cmd.ID, true, "", map[string]any{
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{
"restoreRecordId": payload.RestoreRecordID,
})
}
@@ -217,23 +202,23 @@ func (a *Agent) handleDeleteStorageObject(ctx context.Context, cmd *CommandPaylo
StoragePath string `json:"storagePath"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
a.submitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
if strings.TrimSpace(payload.StoragePath) == "" {
a.submitCommandResult(ctx, cmd.ID, false, "storagePath is required", nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "storagePath is required", nil)
return
}
provider, err := a.executor.storageRegistry.Create(ctx, payload.TargetType, payload.TargetConfig)
if err != nil {
a.submitCommandResult(ctx, cmd.ID, false, "create provider: "+err.Error(), nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "create provider: "+err.Error(), nil)
return
}
if err := provider.Delete(ctx, payload.StoragePath); err != nil {
a.submitCommandResult(ctx, cmd.ID, false, "delete object: "+err.Error(), nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "delete object: "+err.Error(), nil)
return
}
a.submitCommandResult(ctx, cmd.ID, true, "", map[string]any{"deleted": true})
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"deleted": true})
}
// handleDiscoverDB 处理 discover_db 命令:在 Agent 本机执行 mysql/psql 列出数据库。
@@ -246,7 +231,7 @@ func (a *Agent) handleDiscoverDB(ctx context.Context, cmd *CommandPayload) {
Password string `json:"password"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
a.submitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
databases, err := backup.DiscoverDatabases(ctx, backup.NewOSCommandExecutor(), backup.DiscoverRequest{
@@ -257,10 +242,10 @@ func (a *Agent) handleDiscoverDB(ctx context.Context, cmd *CommandPayload) {
Password: payload.Password,
})
if err != nil {
a.submitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
a.submitCommandResult(ctx, cmd.ID, true, "", map[string]any{"databases": databases})
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"databases": databases})
}
// handleListDir 处理 list_dir 命令(阶段四实现)
@@ -269,44 +254,15 @@ func (a *Agent) handleListDir(ctx context.Context, cmd *CommandPayload) {
Path string `json:"path"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
a.submitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
entries, err := listLocalDir(payload.Path)
if err != nil {
a.submitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
a.submitCommandResult(ctx, cmd.ID, true, "", map[string]any{"entries": entries})
}
func (a *Agent) submitCommandResult(ctx context.Context, commandID uint, success bool, message string, data any) {
reportCtx, cancel := agentFinalizationContext(ctx)
defer cancel()
var err error
attempts := 0
retryLoop:
for attempts < 3 {
attempts++
err = a.client.SubmitCommandResult(reportCtx, commandID, success, message, data)
if err == nil {
return
}
if attempts < 3 {
timer := time.NewTimer(time.Duration(attempts) * 100 * time.Millisecond)
select {
case <-reportCtx.Done():
timer.Stop()
break retryLoop
case <-timer.C:
}
}
}
a.logger.Error("submit agent command result failed",
zap.Uint("command_id", commandID),
zap.Bool("success", success),
zap.Int("attempts", attempts),
zap.Error(err))
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"entries": entries})
}
// 辅助函数

View File

@@ -1,36 +0,0 @@
package agent
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"go.uber.org/zap"
)
func TestSubmitCommandResultRetriesWithCanceledCommandContext(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if requests.Add(1) < 3 {
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
agent := &Agent{
client: NewMasterClient(server.URL, "token", false),
logger: zap.NewNop(),
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
agent.submitCommandResult(ctx, 17, false, "backup failed", nil)
if got := requests.Load(); got != 3 {
t.Fatalf("submit requests = %d, want 3", got)
}
}

View File

@@ -17,7 +17,6 @@ import (
"backupx/server/internal/storage"
storageRclone "backupx/server/internal/storage/rclone"
"backupx/server/pkg/compress"
"go.uber.org/zap"
)
// Executor 负责在 Agent 本地执行命令。
@@ -26,26 +25,35 @@ type Executor struct {
tempDir string
backupRegistry *backup.Registry
storageRegistry *storage.Registry
logger *zap.Logger
}
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
func NewExecutor(client *MasterClient, tempDir string) *Executor {
backupRegistry := backup.NewDefaultRegistry()
storageRegistry := storageRclone.NewDefaultRegistry()
backupRegistry := backup.NewRegistry(
backup.NewFileRunner(),
backup.NewSQLiteRunner(),
backup.NewMySQLRunner(nil),
backup.NewPostgreSQLRunner(nil),
backup.NewSAPHANARunner(nil),
backup.NewMongoDBRunner(nil),
)
storageRegistry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(),
storageRclone.NewWebDAVFactory(),
storageRclone.NewGoogleDriveFactory(),
storageRclone.NewAliyunOSSFactory(),
storageRclone.NewTencentCOSFactory(),
storageRclone.NewQiniuKodoFactory(),
storageRclone.NewFTPFactory(),
storageRclone.NewRcloneFactory(),
)
storageRclone.RegisterAllBackends(storageRegistry)
return &Executor{
client: client,
tempDir: tempDir,
backupRegistry: backupRegistry,
storageRegistry: storageRegistry,
logger: zap.NewNop(),
}
}
// SetLogger attaches the Agent process logger to execution and reporting paths.
func (e *Executor) SetLogger(logger *zap.Logger) {
if logger != nil {
e.logger = logger
}
}
@@ -82,7 +90,7 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
}
// 3) 运行 runner
logger := newRecordLogger(ctx, e.client, e.logger, recordID)
logger := newRecordLogger(ctx, e.client, recordID)
result, err := runner.Run(ctx, backupSpec, logger)
if err != nil {
e.reportRecordFailure(ctx, recordID, err.Error())
@@ -169,9 +177,7 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
}
// 6) 上报最终成功
reportCtx, cancel := agentFinalizationContext(ctx)
defer cancel()
if err := e.client.UpdateRecord(reportCtx, recordID, RecordUpdate{
return e.client.UpdateRecord(ctx, recordID, RecordUpdate{
Status: "success",
FileName: fileName,
FileSize: fileSize,
@@ -181,10 +187,7 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
StorageTransferMode: selectedStorageTransferMode,
StorageUploadResults: uploadResults,
LogAppend: fmt.Sprintf("[agent] 任务完成,总计 %d 字节\n", fileSize),
}); err != nil {
return fmt.Errorf("report backup success to master: %w", err)
}
return nil
})
}
// uploadToTarget 上传单个目标。为保持简化不做上传级重试rclone 本身已有 low-level 重试)。
@@ -219,11 +222,7 @@ func (e *Executor) uploadToTarget(ctx context.Context, recordID uint, target Sto
// appendLog 追加日志到 Master 记录(尽力而为,失败不中断主流程)
func (e *Executor) appendLog(ctx context.Context, recordID uint, line string) {
if err := e.client.UpdateRecord(ctx, recordID, RecordUpdate{LogAppend: line}); err != nil {
e.logger.Warn("append backup record log to master failed",
zap.Uint("record_id", recordID),
zap.Error(err))
}
_ = e.client.UpdateRecord(ctx, recordID, RecordUpdate{LogAppend: line})
}
// reportRecordFailure 上报失败状态
@@ -232,27 +231,12 @@ func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg s
}
func (e *Executor) reportRecordFailureWithUploadResults(ctx context.Context, recordID uint, msg string, uploadResults []StorageResultItem) {
reportCtx, cancel := agentFinalizationContext(ctx)
defer cancel()
if err := e.client.UpdateRecord(reportCtx, recordID, RecordUpdate{
_ = e.client.UpdateRecord(ctx, recordID, RecordUpdate{
Status: "failed",
ErrorMessage: msg,
StorageUploadResults: uploadResults,
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
}); err != nil {
e.logger.Error("report backup failure to master failed",
zap.Uint("record_id", recordID),
zap.Error(err))
}
}
// agentFinalizationContext lets terminal state reach the Master even when the
// command context was canceled, while bounding shutdown/network delays.
func agentFinalizationContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
})
}
// buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。
@@ -317,40 +301,30 @@ func compactStringList(items []string) []string {
type recordLogger struct {
ctx context.Context
client *MasterClient
logger *zap.Logger
recordID uint
}
func newRecordLogger(ctx context.Context, client *MasterClient, logger *zap.Logger, recordID uint) *recordLogger {
return &recordLogger{ctx: ctx, client: client, logger: logger, recordID: recordID}
func newRecordLogger(ctx context.Context, client *MasterClient, recordID uint) *recordLogger {
return &recordLogger{ctx: ctx, client: client, recordID: recordID}
}
func (l *recordLogger) WriteLine(message string) {
if err := l.client.UpdateRecord(l.ctx, l.recordID, RecordUpdate{LogAppend: message + "\n"}); err != nil {
l.logger.Warn("append backup runner log to master failed",
zap.Uint("record_id", l.recordID),
zap.Error(err))
}
_ = l.client.UpdateRecord(l.ctx, l.recordID, RecordUpdate{LogAppend: message + "\n"})
}
// restoreLogger 把 runner 日志回传到 Master 恢复记录。
type restoreLogger struct {
ctx context.Context
client *MasterClient
logger *zap.Logger
restoreID uint
}
func newRestoreLogger(ctx context.Context, client *MasterClient, logger *zap.Logger, restoreID uint) *restoreLogger {
return &restoreLogger{ctx: ctx, client: client, logger: logger, restoreID: restoreID}
func newRestoreLogger(ctx context.Context, client *MasterClient, restoreID uint) *restoreLogger {
return &restoreLogger{ctx: ctx, client: client, restoreID: restoreID}
}
func (l *restoreLogger) WriteLine(message string) {
if err := l.client.UpdateRestore(l.ctx, l.restoreID, RestoreUpdate{LogAppend: message + "\n"}); err != nil {
l.logger.Warn("append restore runner log to master failed",
zap.Uint("restore_record_id", l.restoreID),
zap.Error(err))
}
_ = l.client.UpdateRestore(l.ctx, l.restoreID, RestoreUpdate{LogAppend: message + "\n"})
}
// DeleteStorageObject 在 Agent 本机上删除指定存储对象(供跨节点清理调用)。
@@ -479,44 +453,29 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("不支持的备份类型: %v", err))
return err
}
logger := newRestoreLogger(ctx, e.client, e.logger, restoreRecordID)
logger := newRestoreLogger(ctx, e.client, restoreRecordID)
if err := runner.Restore(ctx, taskSpec, preparedPath, logger); err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, err.Error())
return err
}
// 5) 上报成功
reportCtx, cancel := agentFinalizationContext(ctx)
defer cancel()
if err := e.client.UpdateRestore(reportCtx, restoreRecordID, RestoreUpdate{
return e.client.UpdateRestore(ctx, restoreRecordID, RestoreUpdate{
Status: "success",
LogAppend: "[agent] 恢复执行完成\n",
}); err != nil {
return fmt.Errorf("report restore success to master: %w", err)
}
return nil
})
}
func (e *Executor) appendRestoreLog(ctx context.Context, restoreID uint, line string) {
if err := e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{LogAppend: line}); err != nil {
e.logger.Warn("append restore log to master failed",
zap.Uint("restore_record_id", restoreID),
zap.Error(err))
}
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{LogAppend: line})
}
func (e *Executor) reportRestoreFailure(ctx context.Context, restoreID uint, msg string) {
reportCtx, cancel := agentFinalizationContext(ctx)
defer cancel()
if err := e.client.UpdateRestore(reportCtx, restoreID, RestoreUpdate{
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{
Status: "failed",
ErrorMessage: msg,
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
}); err != nil {
e.logger.Error("report restore failure to master failed",
zap.Uint("restore_record_id", restoreID),
zap.Error(err))
}
})
}
// buildRestoreBackupTaskSpec 把 RestoreSpec 转成 backup.TaskSpec。

View File

@@ -18,35 +18,8 @@ import (
"time"
"backupx/server/internal/storage"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
)
func TestReportRecordFailureUsesFinalizationContextAndLogsUpdateError(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
http.Error(w, "master unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()
core, observed := observer.New(zapcore.ErrorLevel)
executor := NewExecutor(NewMasterClient(server.URL, "token", false), t.TempDir())
executor.SetLogger(zap.New(core))
ctx, cancel := context.WithCancel(context.Background())
cancel()
executor.reportRecordFailure(ctx, 42, "backup failed")
if requestCount != 1 {
t.Fatalf("terminal update requests = %d, want 1 despite canceled command context", requestCount)
}
if observed.Len() != 1 || observed.All()[0].Message != "report backup failure to master failed" {
t.Fatalf("observed logs = %#v", observed.All())
}
}
func TestBuildBackupTaskSpecParsesJSONSourcePaths(t *testing.T) {
spec := &TaskSpec{
TaskID: 7,
@@ -350,8 +323,6 @@ func (f *agentTestStorageFactory) Type() storage.ProviderType {
return "agent_test_storage"
}
func (f *agentTestStorageFactory) SensitiveFields() []string { return nil }
func (f *agentTestStorageFactory) New(_ context.Context, config map[string]any) (storage.StorageProvider, error) {
name, _ := config["name"].(string)
provider := f.providers[name]

View File

@@ -20,7 +20,7 @@ type DirEntry struct {
func listLocalDir(path string) ([]DirEntry, error) {
cleaned := filepath.Clean(strings.TrimSpace(path))
if strings.TrimSpace(path) == "" || cleaned == "." {
cleaned = localFilesystemRoot()
cleaned = "/"
}
entries, err := os.ReadDir(cleaned)
if err != nil {
@@ -48,15 +48,3 @@ func listLocalDir(path string) ([]DirEntry, error) {
})
return result, nil
}
func localFilesystemRoot() string {
root := string(os.PathSeparator)
workingDir, err := os.Getwd()
if err != nil {
return root
}
if volume := filepath.VolumeName(workingDir); volume != "" {
return volume + root
}
return root
}

View File

@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
stdhttp "net/http"
"sync"
"time"
"backupx/server/internal/backup"
@@ -13,7 +12,6 @@ import (
"backupx/server/internal/config"
"backupx/server/internal/database"
aphttp "backupx/server/internal/http"
"backupx/server/internal/lifecycle"
"backupx/server/internal/logger"
"backupx/server/internal/metrics"
"backupx/server/internal/notify"
@@ -21,6 +19,7 @@ import (
"backupx/server/internal/scheduler"
"backupx/server/internal/security"
"backupx/server/internal/service"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
storageRclone "backupx/server/internal/storage/rclone"
"go.uber.org/zap"
@@ -34,10 +33,6 @@ type Application struct {
db *gorm.DB
httpServer *stdhttp.Server
scheduler *scheduler.Service
background *lifecycle.Supervisor
shutdownMu sync.Mutex
closeOnce sync.Once
}
func New(ctx context.Context, cfg config.Config, version string) (*Application, error) {
@@ -60,30 +55,34 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
oauthSessionRepo := repository.NewOAuthSessionRepository(db)
resolvedSecurity, err := service.ResolveSecurity(ctx, cfg.Security, systemConfigRepo)
if err != nil {
resolveErr := fmt.Errorf("resolve security config: %w", err)
if sqlDB, handleErr := db.DB(); handleErr != nil {
resolveErr = errors.Join(resolveErr, fmt.Errorf("get database handle for cleanup: %w", handleErr))
} else if closeErr := sqlDB.Close(); closeErr != nil {
resolveErr = errors.Join(resolveErr, fmt.Errorf("close database after bootstrap failure: %w", closeErr))
}
return nil, resolveErr
return nil, fmt.Errorf("resolve security config: %w", err)
}
background := lifecycle.NewSupervisor(ctx)
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter, configCipher)
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
storageRegistry := storageRclone.NewDefaultRegistry()
storageRegistry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(),
storageRclone.NewWebDAVFactory(),
storageRclone.NewGoogleDriveFactory(),
storageRclone.NewAliyunOSSFactory(),
storageRclone.NewTencentCOSFactory(),
storageRclone.NewQiniuKodoFactory(),
storageRclone.NewFTPFactory(),
storageRclone.NewRcloneFactory(),
)
// 将全部 rclone 后端注册为独立存储类型sftp、azureblob、dropbox 等与 s3、ftp 完全平级)
storageRclone.RegisterAllBackends(storageRegistry)
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
storageTargetService.SetBackgroundRunner(background)
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry)
// nodeRepo 在下方 Cluster 节点管理区块才实例化,这里延后注入
backupRunnerRegistry := backup.NewDefaultRegistry()
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil), backup.NewMongoDBRunner(nil))
logHub := backup.NewLogHub()
retentionService := backupretention.NewService(backupRecordRepo, configCipher.Key())
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
@@ -97,7 +96,6 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
storageRclone.StartAccounting(rcloneCtx)
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent, cfg.Backup.Retries, cfg.Backup.BandwidthLimit)
backupExecutionService.SetBackgroundRunner(background)
schedulerService := scheduler.NewService(backupTaskRepo, backupExecutionService, appLogger)
backupTaskService.SetScheduler(schedulerService)
// 审计日志注入延迟到 auditService 创建后(见下方)
@@ -106,15 +104,12 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
restoreLogHub := backup.NewLogHub()
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
dashboardService.SetBackgroundRunner(background)
reportService := service.NewReportService(backupTaskRepo, backupRecordRepo)
settingsService := service.NewSettingsService(systemConfigRepo)
// Audit
auditLogRepo := repository.NewAuditLogRepository(db)
auditService := service.NewAuditService(auditLogRepo)
auditService.SetLogger(appLogger)
auditService.SetBackgroundRunner(background)
authService.SetAuditService(auditService)
schedulerService.SetAuditRecorder(auditService)
// 审计日志外输:启动时用当前 settings 初始化 webhook后续前端修改立即生效
@@ -130,7 +125,6 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
backupTaskService.SetNodeRepository(nodeRepo)
schedulerService.SetNodeRepository(nodeRepo)
nodeService := service.NewNodeService(nodeRepo, version)
nodeService.SetBackgroundRunner(background)
nodeService.SetTaskRepository(backupTaskRepo)
if err := nodeService.EnsureLocalNode(ctx); err != nil {
appLogger.Warn("failed to ensure local node", zap.Error(err))
@@ -142,15 +136,12 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
agentCmdRepo := repository.NewAgentCommandRepository(db)
nodeService.SetAgentCommandRepository(agentCmdRepo)
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher, storageRegistry)
agentService.SetLogger(appLogger)
agentService.SetBackgroundRunner(background)
agentService.SetRestoreRepository(restoreRecordRepo)
agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute)
// 一键部署install token service + 后台 GC
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
installTokenService := service.NewInstallTokenService(installTokenRepo, nodeRepo)
installTokenService.SetBackgroundRunner(background)
installTokenService.StartGC(ctx, time.Hour)
// 把 Agent 下发能力注入到备份执行服务,实现多节点路由
@@ -175,7 +166,6 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
cfg.Backup.TempDir,
cfg.Backup.MaxConcurrent,
)
restoreService.SetBackgroundRunner(background)
// 验证服务:定期校验备份可恢复性(企业合规刚需)
verificationRecordRepo := repository.NewVerificationRecordRepository(db)
@@ -192,7 +182,6 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
cfg.Backup.TempDir,
cfg.Backup.MaxConcurrent,
)
verificationService.SetBackgroundRunner(background)
// 验证失败通知:通过 NotificationService 的事件总线派发 verify_failed
verificationService.SetNotifier(service.NewVerificationEventNotifier(notificationService))
// 恢复完成/失败事件派发restore_success / restore_failed
@@ -217,8 +206,6 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
nodeRepo, storageRegistry, configCipher,
cfg.Backup.TempDir, cfg.Backup.MaxConcurrent,
)
replicationService.SetLogger(appLogger)
replicationService.SetBackgroundRunner(background)
replicationService.SetEventDispatcher(notificationService)
backupExecutionService.SetReplicationTrigger(replicationService)
// 备份成功后触发下游依赖任务(任务依赖链工作流)
@@ -242,7 +229,6 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
// 集群版本监控:每 30 分钟扫描,节点 24 小时内只告警一次
clusterVersionMonitor := service.NewClusterVersionMonitor(nodeRepo, version)
clusterVersionMonitor.SetEventDispatcher(notificationService)
clusterVersionMonitor.SetBackgroundRunner(background)
clusterVersionMonitor.Start(ctx, 30*time.Minute, 24*time.Hour)
// Dashboard 集群概览依赖注入
@@ -261,7 +247,6 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
metrics.NewRepoSource(storageTargetRepo, backupRecordRepo, nodeRepo, backupTaskRepo, agentCmdRepo),
30*time.Second,
)
metricsCollector.SetBackgroundRunner(background)
metricsCollector.Start(ctx)
router := aphttp.NewRouter(aphttp.RouterDependencies{
@@ -314,21 +299,13 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
db: db,
httpServer: httpServer,
scheduler: schedulerService,
background: background,
}, nil
}
func (a *Application) Run(ctx context.Context) error {
if a.scheduler != nil {
runCtx := ctx
if a.background != nil {
runCtx = a.background.Context()
}
if err := a.scheduler.Start(runCtx); err != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
shutdownErr := a.Shutdown(shutdownCtx)
return errors.Join(fmt.Errorf("start scheduler: %w", err), shutdownErr)
if err := a.scheduler.Start(context.Background()); err != nil {
return fmt.Errorf("start scheduler: %w", err)
}
}
errCh := make(chan error, 1)
@@ -343,77 +320,30 @@ func (a *Application) Run(ctx context.Context) error {
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
a.logger.Info("shutdown signal received")
return a.Shutdown(shutdownCtx)
case err := <-errCh:
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
shutdownErr := a.Shutdown(shutdownCtx)
if err != nil {
return errors.Join(fmt.Errorf("serve http: %w", err), shutdownErr)
if err := a.httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown http server: %w", err)
}
if a.scheduler != nil {
if err := a.scheduler.Stop(context.Background()); err != nil {
return fmt.Errorf("stop scheduler: %w", err)
}
}
return nil
case err := <-errCh:
if err != nil {
return fmt.Errorf("serve http: %w", err)
}
return shutdownErr
}
}
// Shutdown stops new scheduled and HTTP work before canceling and waiting for
// application-owned background tasks. Every phase is attempted even if an
// earlier phase fails, so a timeout cannot leave workers detached.
func (a *Application) Shutdown(ctx context.Context) error {
if a == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
a.shutdownMu.Lock()
defer a.shutdownMu.Unlock()
var shutdownErrors []error
if a.scheduler != nil {
if err := a.scheduler.Stop(ctx); err != nil {
shutdownErrors = append(shutdownErrors, fmt.Errorf("stop scheduler: %w", err))
}
}
if a.httpServer != nil {
if err := a.httpServer.Shutdown(ctx); err != nil {
shutdownErrors = append(shutdownErrors, fmt.Errorf("shutdown http server: %w", err))
}
}
if a.background != nil {
if err := a.background.Shutdown(ctx); err != nil {
shutdownErrors = append(shutdownErrors, fmt.Errorf("stop background tasks: %w", err))
}
}
return errors.Join(shutdownErrors...)
}
func (a *Application) Close() {
if a == nil {
return
if a.logger != nil {
_ = a.logger.Sync()
}
a.closeOnce.Do(func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := a.Shutdown(shutdownCtx); err != nil && a.logger != nil {
a.logger.Warn("application cleanup incomplete", zap.Error(err))
}
if a.db != nil {
if sqlDB, err := a.db.DB(); err != nil {
if a.logger != nil {
a.logger.Warn("get database handle for close failed", zap.Error(err))
}
} else if err := sqlDB.Close(); err != nil && a.logger != nil {
a.logger.Warn("close database failed", zap.Error(err))
}
}
if a.logger != nil {
if err := a.logger.Sync(); err != nil {
a.logger.Warn("flush logger failed", zap.Error(err))
}
}
})
}
func (a *Application) Logger() *zap.Logger {

View File

@@ -1,68 +0,0 @@
package app
import (
"context"
"errors"
"path/filepath"
"testing"
"backupx/server/internal/lifecycle"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
)
func TestApplicationCloseStopsBackgroundAndClosesDatabaseOnce(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "app.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open database: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get database handle: %v", err)
}
background := lifecycle.NewSupervisor(context.Background())
started := make(chan struct{})
finished := make(chan struct{})
if !background.Go(func(ctx context.Context) {
close(started)
<-ctx.Done()
close(finished)
}) {
t.Fatal("expected background task to be accepted")
}
<-started
application := &Application{db: db, logger: zap.NewNop(), background: background}
application.Close()
application.Close()
select {
case <-finished:
default:
t.Fatal("Close returned before the background task exited")
}
if err := sqlDB.Ping(); err == nil {
t.Fatal("database remained open after Close")
}
}
func TestApplicationShutdownCanRetryAfterWaitTimeout(t *testing.T) {
background := lifecycle.NewSupervisor(context.Background())
release := make(chan struct{})
if !background.Go(func(context.Context) { <-release }) {
t.Fatal("expected background task to be accepted")
}
application := &Application{logger: zap.NewNop(), background: background}
waitCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := application.Shutdown(waitCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("first Shutdown error = %v, want context.Canceled", err)
}
close(release)
if err := application.Shutdown(context.Background()); err != nil {
t.Fatalf("second Shutdown returned error: %v", err)
}
}

View File

@@ -30,7 +30,7 @@ type Agent struct {
// NewAgent 构造 Agent初始化 storage provider 与 catalog。
func NewAgent(ctx context.Context, cfg *Config) (*Agent, error) {
registry := storageRclone.NewDefaultRegistry()
registry := buildStorageRegistry()
provider, err := registry.Create(ctx, cfg.StorageType, cfg.StorageConfig)
if err != nil {
return nil, fmt.Errorf("create storage provider: %w", err)
@@ -337,3 +337,23 @@ func boolStr(b bool) string {
}
return "false"
}
// buildStorageRegistry 构造与主程序一致的 storage registry。
//
// Backint Agent 作为独立 CLI 进程运行,不依赖 BackupX HTTP 服务,
// 因此这里直接引用 storage/rclone 包注册所有后端。
func buildStorageRegistry() *storage.Registry {
registry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(),
storageRclone.NewWebDAVFactory(),
storageRclone.NewGoogleDriveFactory(),
storageRclone.NewAliyunOSSFactory(),
storageRclone.NewTencentCOSFactory(),
storageRclone.NewQiniuKodoFactory(),
storageRclone.NewFTPFactory(),
storageRclone.NewRcloneFactory(),
)
storageRclone.RegisterAllBackends(registry)
return registry
}

View File

@@ -0,0 +1,37 @@
//go:build ignore
package backup
import (
"context"
"io"
"os"
"os/exec"
)
type CommandExecutor interface {
LookPath(file string) (string, error)
Run(ctx context.Context, name string, args []string, env map[string]string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error
}
type OSCommandExecutor struct{}
func NewOSCommandExecutor() *OSCommandExecutor {
return &OSCommandExecutor{}
}
func (e *OSCommandExecutor) LookPath(file string) (string, error) {
return exec.LookPath(file)
}
func (e *OSCommandExecutor) Run(ctx context.Context, name string, args []string, env map[string]string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
command := exec.CommandContext(ctx, name, args...)
command.Stdin = stdin
command.Stdout = stdout
command.Stderr = stderr
command.Env = os.Environ()
for key, value := range env {
command.Env = append(command.Env, key+"="+value)
}
return command.Run()
}

View File

@@ -1,145 +0,0 @@
package backup
import (
"context"
"io"
"os"
"reflect"
"testing"
)
type capturingLogWriter struct {
lines []string
}
func (w *capturingLogWriter) WriteLine(message string) {
w.lines = append(w.lines, message)
}
func TestLogLineWriterHandlesFragmentedWrites(t *testing.T) {
log := &capturingLogWriter{}
w := newLogLineWriter(log, "tool")
assertWrite := func(chunk string) {
t.Helper()
n, err := w.Write([]byte(chunk))
if err != nil || n != len(chunk) {
t.Fatalf("Write(%q) = (%d, %v), want (%d, nil)", chunk, n, err, len(chunk))
}
}
assertWrite("fir")
if len(log.lines) != 0 || string(w.pending) != "fir" {
t.Fatalf("first fragment logged or buffered incorrectly: lines=%#v pending=%q", log.lines, w.pending)
}
assertWrite("st\nsec")
if !reflect.DeepEqual(log.lines, []string{"[tool] first"}) || string(w.pending) != "sec" {
t.Fatalf("second fragment handled incorrectly: lines=%#v pending=%q", log.lines, w.pending)
}
assertWrite("ond\n")
if !reflect.DeepEqual(log.lines, []string{"[tool] first", "[tool] second"}) || len(w.pending) != 0 {
t.Fatalf("completed fragments handled incorrectly: lines=%#v pending=%q", log.lines, w.pending)
}
if got := w.collected(); got != "first\nsecond" {
t.Fatalf("collected() = %q, want complete raw output", got)
}
}
func TestLogLineWriterEmitsMultipleCompleteLinesOnce(t *testing.T) {
log := &capturingLogWriter{}
w := newLogLineWriter(log, "tool")
n, err := w.Write([]byte("one\ntwo\n\n three \r\n"))
if err != nil || n != len("one\ntwo\n\n three \r\n") {
t.Fatalf("Write() = (%d, %v)", n, err)
}
want := []string{"[tool] one", "[tool] two", "[tool] three"}
if !reflect.DeepEqual(log.lines, want) {
t.Fatalf("lines = %#v, want %#v", log.lines, want)
}
if len(w.pending) != 0 {
t.Fatalf("complete input left pending bytes: %q", w.pending)
}
}
func TestLogLineWriterFlushIsIdempotentAndPreservesCollection(t *testing.T) {
log := &capturingLogWriter{}
w := newLogLineWriter(log, "tool")
_, _ = w.Write([]byte("complete\n tail "))
w.Flush()
w.Flush()
want := []string{"[tool] complete", "[tool] tail"}
if !reflect.DeepEqual(log.lines, want) {
t.Fatalf("lines after repeated Flush = %#v, want %#v", log.lines, want)
}
if len(w.pending) != 0 {
t.Fatalf("Flush left pending bytes: %q", w.pending)
}
if got := w.collected(); got != "complete\n tail" {
t.Fatalf("collected() = %q, want complete stderr independent of Flush", got)
}
}
func TestPostgreSQLRunnerFlushesEachCommandTail(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(_ string, args []string, options CommandOptions) error {
name := args[len(args)-1]
_, _ = io.WriteString(options.Stdout, name)
_, _ = io.WriteString(options.Stderr, "warning "+name)
return nil
}}
log := &capturingLogWriter{}
runner := NewPostgreSQLRunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{
Name: "pg-log-lines",
TempDir: t.TempDir(),
Database: DatabaseSpec{
Host: "127.0.0.1", Port: 5432, User: "postgres", Names: []string{"app", "audit"},
},
}, log)
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(result.TempDir) })
for _, expected := range []string{"[pg_dump] warning app", "[pg_dump] warning audit"} {
count := 0
for _, line := range log.lines {
if line == expected {
count++
}
}
if count != 1 {
t.Fatalf("line %q occurred %d times in %#v", expected, count, log.lines)
}
}
}
func TestMongoDBRunnerFlushesUnterminatedStderr(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(_ string, _ []string, options CommandOptions) error {
_, _ = io.WriteString(options.Stdout, "archive")
_, _ = io.WriteString(options.Stderr, "tail warning")
return nil
}}
log := &capturingLogWriter{}
runner := NewMongoDBRunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{
Name: "mongo-log-tail",
Database: DatabaseSpec{
Host: "127.0.0.1", Port: 27017, Names: []string{"app"},
},
}, log)
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(result.TempDir) })
count := 0
for _, line := range log.lines {
if line == "[mongodump] tail warning" {
count++
}
}
if count != 1 {
t.Fatalf("unterminated stderr line occurred %d times in %#v", count, log.lines)
}
}

View File

@@ -62,10 +62,8 @@ func (r *MongoDBRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter
writer.WriteLine(fmt.Sprintf("连接到 MongoDB: %s:%d", task.Database.Host, task.Database.Port))
stderrWriter := newLogLineWriter(writer, "mongodump")
writer.WriteLine("开始执行 mongodump")
runErr := r.executor.Run(ctx, "mongodump", args, CommandOptions{Stdout: file, Stderr: stderrWriter})
stderrWriter.Flush()
if runErr != nil {
return nil, fmt.Errorf("run mongodump: %w: %s", runErr, stderrWriter.collected())
if err := r.executor.Run(ctx, "mongodump", args, CommandOptions{Stdout: file, Stderr: stderrWriter}); err != nil {
return nil, fmt.Errorf("run mongodump: %w: %s", err, stderrWriter.collected())
}
info, err := file.Stat()
if err != nil {

View File

@@ -1,6 +1,7 @@
package backup
import (
"bufio"
"bytes"
"context"
"fmt"
@@ -69,10 +70,8 @@ func (r *MySQLRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter)
stderrWriter := newLogLineWriter(writer, "mysqldump")
writer.WriteLine("开始执行 mysqldump")
runErr := r.executor.Run(ctx, "mysqldump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: mysqlEnv(task.Database.Password)})
stderrWriter.Flush()
if runErr != nil {
return nil, fmt.Errorf("run mysqldump: %w: %s", runErr, stderrWriter.collected())
if err := r.executor.Run(ctx, "mysqldump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: mysqlEnv(task.Database.Password)}); err != nil {
return nil, fmt.Errorf("run mysqldump: %w: %s", err, stderrWriter.collected())
}
info, err := file.Stat()
if err != nil {
@@ -110,10 +109,9 @@ func mysqlEnv(password string) []string {
// logLineWriter streams each line of output to a LogWriter in real-time.
type logLineWriter struct {
writer LogWriter
prefix string
pending []byte
output []byte
writer LogWriter
prefix string
buf bytes.Buffer
}
func newLogLineWriter(w LogWriter, prefix string) *logLineWriter {
@@ -121,43 +119,28 @@ func newLogLineWriter(w LogWriter, prefix string) *logLineWriter {
}
func (w *logLineWriter) Write(p []byte) (int, error) {
w.output = append(w.output, p...)
w.pending = append(w.pending, p...)
consumed := 0
for {
newline := bytes.IndexByte(w.pending[consumed:], '\n')
if newline < 0 {
break
n := len(p)
w.buf.Write(p)
scanner := bufio.NewScanner(strings.NewReader(w.buf.String()))
var remaining string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
w.writer.WriteLine(fmt.Sprintf("[%s] %s", w.prefix, line))
}
end := consumed + newline
w.emit(w.pending[consumed:end])
consumed = end + 1
}
if consumed > 0 {
copy(w.pending, w.pending[consumed:])
w.pending = w.pending[:len(w.pending)-consumed]
}
return len(p), nil
}
// Flush emits the final unterminated line. It is safe to call more than once.
func (w *logLineWriter) Flush() {
if len(w.pending) == 0 {
return
}
w.emit(w.pending)
w.pending = w.pending[:0]
}
func (w *logLineWriter) emit(raw []byte) {
line := strings.TrimSpace(string(raw))
if line != "" {
w.writer.WriteLine(fmt.Sprintf("[%s] %s", w.prefix, line))
// Keep any partial last line (no newline yet)
lastNl := bytes.LastIndexByte(p, '\n')
if lastNl >= 0 {
remaining = w.buf.String()[w.buf.Len()-(len(p)-lastNl-1):]
w.buf.Reset()
w.buf.WriteString(remaining)
}
return n, nil
}
func (w *logLineWriter) collected() string {
return strings.TrimSpace(string(w.output))
return strings.TrimSpace(w.buf.String())
}
func formatFileSize(size int64) string {

View File

@@ -0,0 +1,171 @@
//go:build ignore
package backup
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
type PostgreSQLRunner struct {
executor CommandExecutor
}
func NewPostgreSQLRunner(executor CommandExecutor) *PostgreSQLRunner {
if executor == nil {
executor = NewOSCommandExecutor()
}
return &PostgreSQLRunner{executor: executor}
}
func (r *PostgreSQLRunner) Type() string {
return "postgresql"
}
func (r *PostgreSQLRunner) Run(ctx context.Context, spec TaskSpec, logger LogSink) (*Result, error) {
if _, err := r.executor.LookPath("pg_dump"); err != nil {
return nil, fmt.Errorf("pg_dump is required: %w", err)
}
databases := splitDatabaseNames(spec.DBName)
if len(databases) == 0 {
return nil, fmt.Errorf("postgresql database name is required")
}
tempDir, err := CreateTaskTempDir(spec.TaskName, spec.StartedAt)
if err != nil {
return nil, err
}
if len(databases) == 1 {
return r.dumpSingleDatabase(ctx, spec, databases[0], tempDir, logger)
}
multiDumpDir := filepath.Join(tempDir, "postgres-dumps")
if err := os.MkdirAll(multiDumpDir, 0o755); err != nil {
return nil, fmt.Errorf("create postgres multi dump directory: %w", err)
}
for _, databaseName := range databases {
if _, err := r.dumpDatabaseToFile(ctx, spec, databaseName, filepath.Join(multiDumpDir, sanitizeDumpName(databaseName)+".sql"), logger); err != nil {
return nil, err
}
}
fileName := BuildArtifactName(spec.TaskName, spec.StartedAt, "tar.gz")
artifactPath := filepath.Join(tempDir, fileName)
size, err := CreateTarGz(ctx, multiDumpDir, nil, artifactPath, logger)
if err != nil {
return nil, err
}
return &Result{ArtifactPath: artifactPath, FileName: fileName, Size: size, StorageKey: BuildStorageKey("postgresql", spec.StartedAt, fileName)}, nil
}
func (r *PostgreSQLRunner) Restore(ctx context.Context, spec TaskSpec, artifactPath string, logger LogSink) error {
if _, err := r.executor.LookPath("psql"); err != nil {
return fmt.Errorf("psql is required: %w", err)
}
databases := splitDatabaseNames(spec.DBName)
if len(databases) == 0 {
return fmt.Errorf("postgresql database name is required")
}
if strings.HasSuffix(strings.ToLower(artifactPath), ".tar.gz") {
restoreDir, err := CreateTaskTempDir(spec.TaskName+"-restore", spec.StartedAt)
if err != nil {
return err
}
if err := ExtractTarGz(ctx, artifactPath, restoreDir, logger); err != nil {
return err
}
for _, databaseName := range databases {
filePath := filepath.Join(restoreDir, filepath.Base(restoreDir), sanitizeDumpName(databaseName)+".sql")
if _, err := os.Stat(filePath); err != nil {
fallback := filepath.Join(restoreDir, "postgres-dumps", sanitizeDumpName(databaseName)+".sql")
filePath = fallback
}
if err := r.restoreDatabaseFromFile(ctx, spec, databaseName, filePath, logger); err != nil {
return err
}
}
return nil
}
return r.restoreDatabaseFromFile(ctx, spec, databases[0], artifactPath, logger)
}
func (r *PostgreSQLRunner) dumpSingleDatabase(ctx context.Context, spec TaskSpec, databaseName string, tempDir string, logger LogSink) (*Result, error) {
fileName := BuildArtifactName(spec.TaskName, spec.StartedAt, "sql")
artifactPath := filepath.Join(tempDir, fileName)
size, err := r.dumpDatabaseToFile(ctx, spec, databaseName, artifactPath, logger)
if err != nil {
return nil, err
}
return &Result{ArtifactPath: artifactPath, FileName: fileName, Size: size, StorageKey: BuildStorageKey("postgresql", spec.StartedAt, fileName)}, nil
}
func (r *PostgreSQLRunner) dumpDatabaseToFile(ctx context.Context, spec TaskSpec, databaseName string, artifactPath string, logger LogSink) (int64, error) {
output, err := os.Create(filepath.Clean(artifactPath))
if err != nil {
return 0, fmt.Errorf("create postgres dump file: %w", err)
}
defer output.Close()
stderr := &bytes.Buffer{}
args := []string{"-h", spec.DBHost, "-p", fmt.Sprintf("%d", spec.DBPort), "-U", spec.DBUser, "-d", databaseName, "--no-owner", "--no-privileges"}
if logger != nil {
logger.Infof("开始执行 pg_dump%s", databaseName)
}
if err := r.executor.Run(ctx, "pg_dump", args, postgresEnv(spec.DBPassword), nil, output, stderr); err != nil {
return 0, fmt.Errorf("run pg_dump: %w: %s", err, strings.TrimSpace(stderr.String()))
}
info, err := output.Stat()
if err != nil {
return 0, fmt.Errorf("stat postgres dump file: %w", err)
}
return info.Size(), nil
}
func (r *PostgreSQLRunner) restoreDatabaseFromFile(ctx context.Context, spec TaskSpec, databaseName string, artifactPath string, logger LogSink) error {
input, err := os.Open(filepath.Clean(artifactPath))
if err != nil {
return fmt.Errorf("open postgres restore file: %w", err)
}
defer input.Close()
stderr := &bytes.Buffer{}
args := []string{"-h", spec.DBHost, "-p", fmt.Sprintf("%d", spec.DBPort), "-U", spec.DBUser, "-d", databaseName}
if logger != nil {
logger.Infof("开始执行 psql 恢复:%s", databaseName)
}
if err := r.executor.Run(ctx, "psql", args, postgresEnv(spec.DBPassword), input, nil, stderr); err != nil {
return fmt.Errorf("run psql restore: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
func postgresEnv(password string) map[string]string {
if strings.TrimSpace(password) == "" {
return nil
}
return map[string]string{"PGPASSWORD": password}
}
func splitDatabaseNames(value string) []string {
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
result = append(result, trimmed)
}
return result
}
func sanitizeDumpName(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
trimmed = strings.ReplaceAll(trimmed, " ", "-")
trimmed = strings.ReplaceAll(trimmed, "/", "-")
trimmed = strings.ReplaceAll(trimmed, "\\", "-")
trimmed = strings.Trim(trimmed, "-._")
if trimmed == "" {
return "database"
}
return trimmed
}

View File

@@ -43,14 +43,12 @@ func (r *PostgreSQLRunner) Run(ctx context.Context, task TaskSpec, writer LogWri
}
writer.WriteLine(fmt.Sprintf("连接到 PostgreSQL: %s:%d", task.Database.Host, task.Database.Port))
writer.WriteLine(fmt.Sprintf("备份数据库: %s", strings.Join(dbNames, ", ")))
stderrWriter := newLogLineWriter(writer, "pg_dump")
for index, name := range dbNames {
args := []string{"--clean", "--if-exists", "--create", "--format=plain", "-h", task.Database.Host, "-p", strconv.Itoa(task.Database.Port), "-U", task.Database.User, "--dbname", name}
writer.WriteLine(fmt.Sprintf("开始导出数据库 [%d/%d]: %s", index+1, len(dbNames), name))
stderrWriter := newLogLineWriter(writer, "pg_dump")
runErr := r.executor.Run(ctx, "pg_dump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: append(os.Environ(), "PGPASSWORD="+task.Database.Password)})
stderrWriter.Flush()
if runErr != nil {
return nil, fmt.Errorf("run pg_dump for %s: %w", name, runErr)
if err := r.executor.Run(ctx, "pg_dump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: append(os.Environ(), "PGPASSWORD="+task.Database.Password)}); err != nil {
return nil, fmt.Errorf("run pg_dump for %s: %w", name, err)
}
writer.WriteLine(fmt.Sprintf("数据库 %s 导出完成", name))
if index < len(dbNames)-1 {

View File

@@ -20,18 +20,6 @@ func NewRegistry(runners ...BackupRunner) *Registry {
return registry
}
// NewDefaultRegistry returns the runner set shared by Master and Agent.
func NewDefaultRegistry() *Registry {
return NewRegistry(
NewFileRunner(),
NewSQLiteRunner(),
NewMySQLRunner(nil),
NewPostgreSQLRunner(nil),
NewSAPHANARunner(nil),
NewMongoDBRunner(nil),
)
}
func (r *Registry) Register(runner BackupRunner) {
if runner == nil {
return

View File

@@ -304,7 +304,6 @@ func (r *SAPHANARunner) runHdbsqlWithRetry(ctx context.Context, name string, arg
}
stderrWriter := newLogLineWriter(writer, "hdbsql")
err := r.executor.Run(ctx, name, args, CommandOptions{Stderr: stderrWriter})
stderrWriter.Flush()
if err == nil {
return nil
}

View File

@@ -1,12 +1,10 @@
package database
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"backupx/server/internal/config"
"backupx/server/internal/model"
@@ -32,22 +30,6 @@ func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) {
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
initialized := false
defer func() {
if initialized {
return
}
sqlDB, dbErr := db.DB()
if dbErr != nil {
if logger != nil {
logger.Warn("get database handle after initialization failure", zap.Error(dbErr))
}
return
}
if closeErr := sqlDB.Close(); closeErr != nil && logger != nil {
logger.Warn("close database after initialization failure", zap.Error(closeErr))
}
}()
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}, &model.BackupTaskStorageTarget{}, &model.AuditLog{}, &model.AgentCommand{}, &model.AgentInstallToken{}, &model.RestoreRecord{}, &model.VerificationRecord{}, &model.ApiKey{}, &model.ReplicationRecord{}, &model.TaskTemplate{}); err != nil {
return nil, fmt.Errorf("migrate schema: %w", err)
@@ -55,104 +37,11 @@ func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) {
// 一次性数据迁移:从 backup_tasks.storage_target_id 回填到多对多中间表
var count int64
if err := db.Model(&model.BackupTaskStorageTarget{}).Count(&count).Error; err != nil {
return nil, fmt.Errorf("count backup task storage target mappings: %w", err)
}
db.Model(&model.BackupTaskStorageTarget{}).Count(&count)
if count == 0 {
if err := db.Exec("INSERT INTO backup_task_storage_targets (backup_task_id, storage_target_id) SELECT id, storage_target_id FROM backup_tasks WHERE storage_target_id > 0").Error; err != nil {
return nil, fmt.Errorf("backfill backup task storage target mappings: %w", err)
}
}
reconciled, err := reconcileInterruptedOperations(db, time.Now().UTC())
if err != nil {
return nil, fmt.Errorf("reconcile interrupted operations: %w", err)
}
if reconciled > 0 {
logger.Warn("interrupted operations marked as failed", zap.Int64("records", reconciled))
db.Exec("INSERT INTO backup_task_storage_targets (backup_task_id, storage_target_id) SELECT id, storage_target_id FROM backup_tasks WHERE storage_target_id > 0")
}
logger.Info("database initialized", zap.String("path", cfg.Path))
initialized = true
return db, nil
}
func reconcileInterruptedOperations(db *gorm.DB, completedAt time.Time) (int64, error) {
const message = "应用在任务完成前重启,执行状态已自动收敛为失败"
var reconciled int64
err := db.Transaction(func(tx *gorm.DB) error {
// Pending/dispatched Agent commands survive a Master restart. Their Agent
// may still be executing (or may claim the pending command after startup),
// so their linked records must not be mistaken for orphaned local work.
var activeCommands []model.AgentCommand
if err := tx.Where("status IN ? AND type IN ?",
[]string{model.AgentCommandStatusPending, model.AgentCommandStatusDispatched},
[]string{model.AgentCommandTypeRunTask, model.AgentCommandTypeRestoreRecord}).
Find(&activeCommands).Error; err != nil {
return fmt.Errorf("active agent commands: %w", err)
}
activeBackupRecordIDs := make([]uint, 0, len(activeCommands))
activeRestoreRecordIDs := make([]uint, 0, len(activeCommands))
for i := range activeCommands {
cmd := &activeCommands[i]
switch cmd.Type {
case model.AgentCommandTypeRunTask:
var payload struct {
RecordID uint `json:"recordId"`
}
if json.Unmarshal([]byte(cmd.Payload), &payload) == nil && payload.RecordID > 0 {
activeBackupRecordIDs = append(activeBackupRecordIDs, payload.RecordID)
}
case model.AgentCommandTypeRestoreRecord:
var payload struct {
RestoreRecordID uint `json:"restoreRecordId"`
}
if json.Unmarshal([]byte(cmd.Payload), &payload) == nil && payload.RestoreRecordID > 0 {
activeRestoreRecordIDs = append(activeRestoreRecordIDs, payload.RestoreRecordID)
}
}
}
markFailed := func(entity any, runningStatus, failedStatus string, activeAgentRecordIDs []uint) error {
query := tx.Model(entity).Where("status = ?", runningStatus)
if len(activeAgentRecordIDs) > 0 {
query = query.Where("id NOT IN ?", activeAgentRecordIDs)
}
result := query.
Updates(map[string]any{
"status": failedStatus,
"error_message": message,
"completed_at": completedAt,
"duration_seconds": gorm.Expr("CAST(MAX(0, (julianday(?) - julianday(started_at)) * 86400) AS INTEGER)", completedAt),
})
if result.Error != nil {
return result.Error
}
reconciled += result.RowsAffected
return nil
}
if err := markFailed(&model.BackupRecord{}, model.BackupRecordStatusRunning, model.BackupRecordStatusFailed, activeBackupRecordIDs); err != nil {
return fmt.Errorf("backup records: %w", err)
}
if err := markFailed(&model.RestoreRecord{}, model.RestoreRecordStatusRunning, model.RestoreRecordStatusFailed, activeRestoreRecordIDs); err != nil {
return fmt.Errorf("restore records: %w", err)
}
if err := markFailed(&model.VerificationRecord{}, model.VerificationRecordStatusRunning, model.VerificationRecordStatusFailed, nil); err != nil {
return fmt.Errorf("verification records: %w", err)
}
if err := markFailed(&model.ReplicationRecord{}, model.ReplicationStatusRunning, model.ReplicationStatusFailed, nil); err != nil {
return fmt.Errorf("replication records: %w", err)
}
result := tx.Model(&model.BackupTask{}).
Where("last_status = ? AND NOT EXISTS (SELECT 1 FROM backup_records WHERE backup_records.task_id = backup_tasks.id AND backup_records.status = ?)", model.BackupTaskStatusRunning, model.BackupRecordStatusRunning).
Update("last_status", model.BackupTaskStatusFailed)
if result.Error != nil {
return fmt.Errorf("backup tasks: %w", result.Error)
}
reconciled += result.RowsAffected
return nil
})
return reconciled, err
}

View File

@@ -1,14 +1,11 @@
package database
import (
"fmt"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func TestOpenConfiguresSQLiteForSingleMasterConcurrency(t *testing.T) {
@@ -41,178 +38,3 @@ func TestOpenConfiguresSQLiteForSingleMasterConcurrency(t *testing.T) {
t.Fatalf("busy_timeout = %d, want 5000", busyTimeout)
}
}
func TestReconcileInterruptedOperations(t *testing.T) {
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatal(err)
}
db, err := Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "reconcile.db")}, log)
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close database: %v", err)
}
})
startedAt := time.Now().UTC().Add(-time.Minute)
task := model.BackupTask{Name: "interrupted", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
items := []any{
&model.BackupRecord{TaskID: task.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt},
&model.RestoreRecord{TaskID: task.ID, Status: model.RestoreRecordStatusRunning, StartedAt: startedAt},
&model.VerificationRecord{TaskID: task.ID, Status: model.VerificationRecordStatusRunning, StartedAt: startedAt},
&model.ReplicationRecord{TaskID: task.ID, Status: model.ReplicationStatusRunning, StartedAt: startedAt},
}
for _, item := range items {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
completedAt := time.Now().UTC()
count, err := reconcileInterruptedOperations(db, completedAt)
if err != nil {
t.Fatal(err)
}
if count != 5 {
t.Fatalf("reconciled records = %d, want 5", count)
}
var runningRecords int64
for _, entity := range []any{&model.BackupRecord{}, &model.RestoreRecord{}, &model.VerificationRecord{}, &model.ReplicationRecord{}} {
if err := db.Model(entity).Where("status = ?", "running").Count(&runningRecords).Error; err != nil {
t.Fatal(err)
}
if runningRecords != 0 {
t.Fatalf("%T still has %d running records", entity, runningRecords)
}
}
if err := db.First(&task, task.ID).Error; err != nil {
t.Fatal(err)
}
if task.LastStatus != model.BackupTaskStatusFailed {
t.Fatalf("task last status = %q, want failed", task.LastStatus)
}
var backupRecord model.BackupRecord
if err := db.Where("task_id = ?", task.ID).First(&backupRecord).Error; err != nil {
t.Fatal(err)
}
if backupRecord.DurationSeconds <= 0 {
t.Fatalf("backup duration = %d, want positive duration", backupRecord.DurationSeconds)
}
}
func TestReconcileInterruptedOperationsPreservesRemoteAgentWork(t *testing.T) {
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatal(err)
}
db, err := Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "remote-reconcile.db")}, log)
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close database: %v", err)
}
})
localNode := model.Node{Name: "local", Token: "local-token", Status: model.NodeStatusOnline, IsLocal: true}
remoteNode := model.Node{Name: "remote", Token: "remote-token", Status: model.NodeStatusOnline, IsLocal: false}
if err := db.Create(&localNode).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&remoteNode).Error; err != nil {
t.Fatal(err)
}
localTask := model.BackupTask{Name: "local-interrupted", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning}
remoteTask := model.BackupTask{Name: "remote-still-running", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning}
orphanRemoteTask := model.BackupTask{Name: "remote-without-command", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning}
if err := db.Create(&localTask).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&remoteTask).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&orphanRemoteTask).Error; err != nil {
t.Fatal(err)
}
startedAt := time.Now().UTC().Add(-time.Minute)
localBackup := &model.BackupRecord{TaskID: localTask.ID, NodeID: localNode.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt}
localRestore := &model.RestoreRecord{TaskID: localTask.ID, NodeID: localNode.ID, Status: model.RestoreRecordStatusRunning, StartedAt: startedAt}
remoteBackup := &model.BackupRecord{TaskID: remoteTask.ID, NodeID: remoteNode.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt}
remoteRestore := &model.RestoreRecord{TaskID: remoteTask.ID, NodeID: remoteNode.ID, Status: model.RestoreRecordStatusRunning, StartedAt: startedAt}
orphanRemoteBackup := &model.BackupRecord{TaskID: orphanRemoteTask.ID, NodeID: remoteNode.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt}
items := []any{localBackup, localRestore, remoteBackup, remoteRestore, orphanRemoteBackup}
for _, item := range items {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
activeCommands := []model.AgentCommand{
{NodeID: remoteNode.ID, Type: model.AgentCommandTypeRunTask, Status: model.AgentCommandStatusDispatched, Payload: fmt.Sprintf(`{"recordId":%d}`, remoteBackup.ID)},
{NodeID: remoteNode.ID, Type: model.AgentCommandTypeRestoreRecord, Status: model.AgentCommandStatusPending, Payload: fmt.Sprintf(`{"restoreRecordId":%d}`, remoteRestore.ID)},
}
if err := db.Create(&activeCommands).Error; err != nil {
t.Fatal(err)
}
count, err := reconcileInterruptedOperations(db, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if count != 5 {
t.Fatalf("reconciled rows = %d, want local and unlinked remote records plus their tasks", count)
}
for _, entity := range []any{&model.BackupRecord{}, &model.RestoreRecord{}} {
var remoteRunning int64
if err := db.Model(entity).Where("task_id = ? AND status = ?", remoteTask.ID, "running").Count(&remoteRunning).Error; err != nil {
t.Fatal(err)
}
if remoteRunning != 1 {
t.Fatalf("%T remote running records = %d, want 1", entity, remoteRunning)
}
var localRunning int64
if err := db.Model(entity).Where("task_id = ? AND status = ?", localTask.ID, "running").Count(&localRunning).Error; err != nil {
t.Fatal(err)
}
if localRunning != 0 {
t.Fatalf("%T local running records = %d, want 0", entity, localRunning)
}
}
if err := db.First(&localTask, localTask.ID).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&remoteTask, remoteTask.ID).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&orphanRemoteTask, orphanRemoteTask.ID).Error; err != nil {
t.Fatal(err)
}
if localTask.LastStatus != model.BackupTaskStatusFailed || remoteTask.LastStatus != model.BackupTaskStatusRunning {
t.Fatalf("task statuses = local:%q remote:%q", localTask.LastStatus, remoteTask.LastStatus)
}
if orphanRemoteTask.LastStatus != model.BackupTaskStatusFailed {
t.Fatalf("unlinked remote task status = %q, want failed", orphanRemoteTask.LastStatus)
}
if err := db.First(orphanRemoteBackup, orphanRemoteBackup.ID).Error; err != nil {
t.Fatal(err)
}
if orphanRemoteBackup.Status != model.BackupRecordStatusFailed {
t.Fatalf("unlinked remote backup status = %q, want failed", orphanRemoteBackup.Status)
}
}

View File

@@ -46,7 +46,9 @@ func (h *HealthHandler) Ready(c *gin.Context) {
checks["database"] = "error: " + err.Error()
overallOK = false
} else {
if err := sqlDB.PingContext(c.Request.Context()); err != nil {
ctx, cancel := c.Request.Context(), func() {}
_ = cancel
if err := sqlDB.PingContext(ctx); err != nil {
checks["database"] = "ping failed: " + err.Error()
overallOK = false
} else {

View File

@@ -399,7 +399,6 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
func requestLogger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
response.SetLogger(c, logger)
c.Next()
logger.Info("http request",
zap.String("method", c.Request.Method),

View File

@@ -0,0 +1,98 @@
//go:build ignore
package httpapi
import (
"net/http"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type authHandler struct {
service *service.AuthService
logger *zap.Logger
}
type setupRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
DisplayName string `json:"displayName" binding:"required,min=1,max=128"`
}
type loginRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
}
func newAuthHandler(service *service.AuthService, logger *zap.Logger) *authHandler {
return &authHandler{service: service, logger: logger}
}
func (h *authHandler) registerRoutes(router gin.IRouter, protected gin.IRouter) {
router.GET("/auth/setup/status", h.getSetupStatus)
router.POST("/auth/setup", h.setup)
router.POST("/auth/login", h.login)
protected.GET("/auth/profile", h.profile)
}
func (h *authHandler) getSetupStatus(c *gin.Context) {
initialized, err := h.service.GetSetupStatus(c.Request.Context())
if err != nil {
writeError(c, h.logger, err)
return
}
response.Success(c, gin.H{"initialized": initialized})
}
func (h *authHandler) setup(c *gin.Context) {
payload, err := bindJSON[setupRequest](c, h.logger)
if err != nil {
writeError(c, h.logger, err)
return
}
result, err := h.service.Setup(c.Request.Context(), service.SetupInput{
Username: payload.Username,
Password: payload.Password,
DisplayName: payload.DisplayName,
})
if err != nil {
writeError(c, h.logger, err)
return
}
c.JSON(http.StatusCreated, response.Envelope{Code: "OK", Message: "success", Data: result})
}
func (h *authHandler) login(c *gin.Context) {
payload, err := bindJSON[loginRequest](c, h.logger)
if err != nil {
writeError(c, h.logger, err)
return
}
result, err := h.service.Login(c.Request.Context(), service.LoginInput{
Username: payload.Username,
Password: payload.Password,
RemoteAddr: c.ClientIP(),
})
if err != nil {
writeError(c, h.logger, err)
return
}
response.Success(c, result)
}
func (h *authHandler) profile(c *gin.Context) {
userID, err := getUserID(c)
if err != nil {
response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "认证信息无效")
return
}
result, err := h.service.GetCurrentUser(c.Request.Context(), userID)
if err != nil {
writeError(c, h.logger, err)
return
}
response.Success(c, result)
}

View File

@@ -0,0 +1,23 @@
//go:build ignore
package httpapi
import (
"fmt"
"github.com/gin-gonic/gin"
)
const claimsContextKey = "authClaims"
func getUserID(c *gin.Context) (uint, error) {
value, ok := c.Get(claimsContextKey)
if !ok {
return 0, fmt.Errorf("missing auth claims")
}
claims, ok := value.(AuthClaims)
if !ok {
return 0, fmt.Errorf("invalid auth claims")
}
return claims.UserID, nil
}

View File

@@ -0,0 +1,92 @@
//go:build ignore
package httpapi
import (
"errors"
"fmt"
"net/http"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/security"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type AuthClaims struct {
UserID uint
Username string
Role string
}
func Recovery(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("panic recovered", zap.Any("panic", recovered), zap.String("path", c.Request.URL.Path))
response.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "服务器内部错误")
c.Abort()
}
}()
c.Next()
}
}
func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
logger.Info("http request",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.String("client_ip", c.ClientIP()),
)
}
}
func AuthMiddleware(jwtManager *security.JWTManager) gin.HandlerFunc {
return func(c *gin.Context) {
authorization := strings.TrimSpace(c.GetHeader("Authorization"))
if authorization == "" || !strings.HasPrefix(strings.ToLower(authorization), "bearer ") {
response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "缺少有效的认证令牌")
c.Abort()
return
}
tokenValue := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer"))
if tokenValue == authorization {
tokenValue = strings.TrimSpace(strings.TrimPrefix(authorization, "bearer"))
}
claims, err := jwtManager.Parse(tokenValue)
if err != nil {
response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "认证令牌无效或已过期")
c.Abort()
return
}
c.Set(claimsContextKey, AuthClaims{UserID: claims.UserID, Username: claims.Username, Role: claims.Role})
c.Next()
}
}
func writeError(c *gin.Context, logger *zap.Logger, err error) {
var appErr *apperror.AppError
if errors.As(err, &appErr) {
if appErr.Err != nil {
logger.Warn("request failed", zap.String("code", appErr.Code), zap.Error(appErr.Err))
}
response.Error(c, appErr.Status, appErr.Code, appErr.Message)
return
}
logger.Error("unexpected error", zap.Error(err))
response.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "服务器内部错误")
}
func bindJSON[T any](c *gin.Context, logger *zap.Logger) (*T, error) {
var payload T
if err := c.ShouldBindJSON(&payload); err != nil {
logger.Warn("bind json failed", zap.Error(err))
return nil, apperror.Wrap(http.StatusBadRequest, "INVALID_REQUEST", fmt.Sprintf("请求参数错误: %v", err), err)
}
return &payload, nil
}

View File

@@ -0,0 +1,38 @@
//go:build ignore
package httpapi
import (
"backupx/server/internal/security"
"backupx/server/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type Dependencies struct {
Logger *zap.Logger
AuthService *service.AuthService
SystemService *service.SystemService
JWTManager *security.JWTManager
Mode string
}
func NewRouter(deps Dependencies) *gin.Engine {
gin.SetMode(deps.Mode)
router := gin.New()
router.Use(Recovery(deps.Logger), RequestLogger(deps.Logger))
api := router.Group("/api")
authHandler := newAuthHandler(deps.AuthService, deps.Logger)
systemHandler := newSystemHandler(deps.SystemService)
protected := api.Group("")
protected.Use(AuthMiddleware(deps.JWTManager))
authHandler.registerRoutes(api, protected)
systemHandler.registerRoutes(protected)
api.GET("/healthz", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
return router
}

View File

@@ -0,0 +1,96 @@
//go:build ignore
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
)
func TestSetupLoginProfileAndSystemInfo(t *testing.T) {
tmpDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tmpDir, "backupx.db")},
Security: config.SecurityConfig{JWTSecret: "test-jwt-secret", JWTExpire: "1h", EncryptionKey: "test-encryption-key"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger.New() error = %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("database.Open() error = %v", err)
}
jwtManager := security.NewJWTManager(cfg.Security.JWTSecret, time.Hour)
authService := service.NewAuthService(repository.NewUserRepository(db), jwtManager, security.NewLoginLimiter(5, time.Minute))
systemService := service.NewSystemService(cfg, "test", time.Now().Add(-time.Minute))
router := NewRouter(Dependencies{Logger: log, AuthService: authService, SystemService: systemService, JWTManager: jwtManager, Mode: "test"})
setupBody := map[string]string{"username": "admin", "password": "super-secret", "displayName": "管理员"}
setupResp := performJSONRequest(t, router, http.MethodPost, "/api/auth/setup", setupBody, "")
if setupResp.Code != http.StatusCreated {
t.Fatalf("unexpected setup status: %d body=%s", setupResp.Code, setupResp.Body.String())
}
var setupPayload struct {
Code string `json:"code"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(setupResp.Body.Bytes(), &setupPayload); err != nil {
t.Fatalf("decode setup response: %v", err)
}
if setupPayload.Data.Token == "" {
t.Fatal("expected token in setup response")
}
profileResp := performJSONRequest(t, router, http.MethodGet, "/api/auth/profile", nil, setupPayload.Data.Token)
if profileResp.Code != http.StatusOK {
t.Fatalf("unexpected profile status: %d body=%s", profileResp.Code, profileResp.Body.String())
}
loginBody := map[string]string{"username": "admin", "password": "super-secret"}
loginResp := performJSONRequest(t, router, http.MethodPost, "/api/auth/login", loginBody, "")
if loginResp.Code != http.StatusOK {
t.Fatalf("unexpected login status: %d body=%s", loginResp.Code, loginResp.Body.String())
}
systemResp := performJSONRequest(t, router, http.MethodGet, "/api/system/info", nil, setupPayload.Data.Token)
if systemResp.Code != http.StatusOK {
t.Fatalf("unexpected system info status: %d body=%s", systemResp.Code, systemResp.Body.String())
}
}
func performJSONRequest(t *testing.T, handler http.Handler, method string, path string, payload any, token string) *httptest.ResponseRecorder {
t.Helper()
var body []byte
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
body = encoded
}
request := httptest.NewRequest(method, path, bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}

View File

@@ -0,0 +1,25 @@
//go:build ignore
package httpapi
import (
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type systemHandler struct {
service *service.SystemService
}
func newSystemHandler(service *service.SystemService) *systemHandler {
return &systemHandler{service: service}
}
func (h *systemHandler) registerRoutes(protected gin.IRouter) {
protected.GET("/system/info", h.info)
}
func (h *systemHandler) info(c *gin.Context) {
response.Success(c, h.service.GetInfo())
}

View File

@@ -1,86 +0,0 @@
package lifecycle
import (
"context"
"sync"
)
// Supervisor owns application background tasks. It rejects new work once
// shutdown starts, cancels the shared task context, and waits for accepted work.
type Supervisor struct {
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
stopping bool
wg sync.WaitGroup
done chan struct{}
stopOnce sync.Once
}
func NewSupervisor(parent context.Context) *Supervisor {
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithCancel(parent)
return &Supervisor{
ctx: ctx,
cancel: cancel,
done: make(chan struct{}),
}
}
// Context is the root context passed to every accepted task.
func (s *Supervisor) Context() context.Context {
return s.ctx
}
// Go starts task unless shutdown has begun or the root context is already
// canceled. The lock makes Add and the transition to Wait mutually exclusive.
func (s *Supervisor) Go(task func(context.Context)) bool {
if s == nil || task == nil {
return false
}
s.mu.Lock()
if s.stopping || s.ctx.Err() != nil {
s.mu.Unlock()
return false
}
s.wg.Add(1)
s.mu.Unlock()
go func() {
defer s.wg.Done()
task(s.ctx)
}()
return true
}
// Shutdown is idempotent. Cancellation always happens, even when waitCtx has
// already expired; callers may call Shutdown again to wait for eventual exit.
func (s *Supervisor) Shutdown(waitCtx context.Context) error {
if s == nil {
return nil
}
if waitCtx == nil {
waitCtx = context.Background()
}
s.stopOnce.Do(func() {
s.mu.Lock()
s.stopping = true
s.cancel()
s.mu.Unlock()
go func() {
s.wg.Wait()
close(s.done)
}()
})
select {
case <-s.done:
return nil
case <-waitCtx.Done():
return waitCtx.Err()
}
}

View File

@@ -1,54 +0,0 @@
package lifecycle
import (
"context"
"errors"
"testing"
"time"
)
func TestSupervisorShutdownCancelsAndWaits(t *testing.T) {
supervisor := NewSupervisor(context.Background())
started := make(chan struct{})
finished := make(chan struct{})
if !supervisor.Go(func(ctx context.Context) {
close(started)
<-ctx.Done()
close(finished)
}) {
t.Fatal("expected task to be accepted")
}
<-started
waitCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := supervisor.Shutdown(waitCtx); err != nil {
t.Fatalf("Shutdown returned error: %v", err)
}
select {
case <-finished:
default:
t.Fatal("Shutdown returned before the task finished")
}
if supervisor.Go(func(context.Context) {}) {
t.Fatal("expected task submitted after shutdown to be rejected")
}
}
func TestSupervisorShutdownHonorsWaitContext(t *testing.T) {
supervisor := NewSupervisor(context.Background())
release := make(chan struct{})
if !supervisor.Go(func(context.Context) { <-release }) {
t.Fatal("expected task to be accepted")
}
waitCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := supervisor.Shutdown(waitCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("Shutdown error = %v, want context.Canceled", err)
}
close(release)
if err := supervisor.Shutdown(context.Background()); err != nil {
t.Fatalf("second Shutdown returned error: %v", err)
}
}

View File

@@ -17,11 +17,6 @@ type SampleSource interface {
CountSLABreach(ctx context.Context) (int, error)
}
// BackgroundRunner is implemented by the application lifecycle supervisor.
type BackgroundRunner interface {
Go(func(context.Context)) bool
}
// repoSource 把 repository 适配到 SampleSource。
type repoSource struct {
targets repository.StorageTargetRepository
@@ -95,10 +90,9 @@ func (s *repoSource) CountSLABreach(ctx context.Context) (int, error) {
// Collector 周期性采集 gauge 类指标存储用量、节点在线、SLA 违约)。
// 用后台 goroutine 驱动,避免在 /metrics 请求路径做慢 IO。
type Collector struct {
metrics *Metrics
source SampleSource
interval time.Duration
background BackgroundRunner
metrics *Metrics
source SampleSource
interval time.Duration
}
// NewCollector 创建周期采集器。interval=0 走默认 30s。
@@ -109,37 +103,25 @@ func NewCollector(m *Metrics, source SampleSource, interval time.Duration) *Coll
return &Collector{metrics: m, source: source, interval: interval}
}
func (c *Collector) SetBackgroundRunner(runner BackgroundRunner) {
c.background = runner
}
// Start 在后台运行采集循环;随 ctx 取消而终止。
// 启动时立即采一次,之后按 interval 轮询。
func (c *Collector) Start(ctx context.Context) {
if c == nil || c.metrics == nil || c.source == nil {
return
}
run := func(runCtx context.Context) {
c.collect(runCtx)
go func() {
c.collect(ctx)
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for {
select {
case <-runCtx.Done():
case <-ctx.Done():
return
case <-ticker.C:
c.collect(runCtx)
c.collect(ctx)
}
}
}
if c.background != nil {
c.background.Go(run)
return
}
if ctx == nil {
ctx = context.Background()
}
go run(ctx)
}()
}
// collect 执行一次采样;单轮失败不影响下次。

View File

@@ -1,64 +0,0 @@
package metrics
import (
"context"
"testing"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
type lifecycleSampleSource struct{}
func (lifecycleSampleSource) ListStorageTargets(context.Context) ([]model.StorageTarget, error) {
return nil, nil
}
func (lifecycleSampleSource) StorageUsage(context.Context) ([]repository.BackupStorageUsageItem, error) {
return nil, nil
}
func (lifecycleSampleSource) ListNodes(context.Context) ([]model.Node, error) {
return nil, nil
}
func (lifecycleSampleSource) AgentQueueSummaries(context.Context) (map[uint]repository.AgentCommandQueueSummary, error) {
return nil, nil
}
func (lifecycleSampleSource) CountSLABreach(context.Context) (int, error) {
return 0, nil
}
type capturingCollectorRunner struct {
task func(context.Context)
}
func (r *capturingCollectorRunner) Go(task func(context.Context)) bool {
r.task = task
return true
}
func TestCollectorUsesConfiguredBackgroundRunner(t *testing.T) {
runner := &capturingCollectorRunner{}
collector := NewCollector(New("test"), lifecycleSampleSource{}, time.Hour)
collector.SetBackgroundRunner(runner)
collector.Start(context.Background())
if runner.task == nil {
t.Fatal("collector did not register with the background runner")
}
runCtx, cancel := context.WithCancel(context.Background())
cancel()
done := make(chan struct{})
go func() {
runner.task(runCtx)
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("collector did not stop when background runner context was canceled")
}
}

View File

@@ -59,7 +59,7 @@ func NewBackupRecordRepository(db *gorm.DB) *GormBackupRecordRepository {
func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRecordListOptions) ([]model.BackupRecord, error) {
// Omit("Manifest"):列表不需要可能很大的清单 JSON避免每行拖出该 TEXT 列。
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Omit("Manifest").Preload("Task").Preload("StorageTarget").Preload("Task.StorageTarget").Order("started_at desc")
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc")
if options.TaskID != nil {
query = query.Where("task_id = ?", *options.TaskID)
}
@@ -87,7 +87,7 @@ func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRec
func (r *GormBackupRecordRepository) FindByID(ctx context.Context, id uint) (*model.BackupRecord, error) {
var item model.BackupRecord
if err := r.db.WithContext(ctx).Preload("Task").Preload("StorageTarget").Preload("Task.StorageTarget").First(&item, id).Error; err != nil {
if err := r.db.WithContext(ctx).Preload("Task").Preload("Task.StorageTarget").First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@@ -115,9 +115,7 @@ func (r *GormBackupRecordRepository) Create(ctx context.Context, item *model.Bac
}
func (r *GormBackupRecordRepository) Update(ctx context.Context, item *model.BackupRecord) error {
// Task 与 StorageTarget 是查询时预加载的只读关联。更新记录字段时忽略它们,
// 避免已加载的旧关联把刚修改的外键(例如首个成功上传目标)覆盖回去。
return r.db.WithContext(ctx).Omit("Task", "StorageTarget").Save(item).Error
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormBackupRecordRepository) Delete(ctx context.Context, id uint) error {
@@ -129,7 +127,7 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int)
limit = 10
}
var items []model.BackupRecord
if err := r.db.WithContext(ctx).Omit("Manifest").Preload("Task").Preload("StorageTarget").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
if err := r.db.WithContext(ctx).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
return nil, err
}
return items, nil

View File

@@ -65,9 +65,6 @@ func TestBackupRecordRepositoryQueries(t *testing.T) {
if stored == nil || stored.FileName != "website.tar.gz" {
t.Fatalf("unexpected stored record: %#v", stored)
}
if stored.StorageTarget.Name != "local" {
t.Fatalf("expected record storage target to be preloaded, got %#v", stored.StorageTarget)
}
listed, err := repo.List(ctx, BackupRecordListOptions{TaskID: &record.TaskID, Status: "success"})
if err != nil {
t.Fatalf("List returned error: %v", err)
@@ -75,9 +72,6 @@ func TestBackupRecordRepositoryQueries(t *testing.T) {
if len(listed) != 1 {
t.Fatalf("expected one listed record, got %d", len(listed))
}
if listed[0].StorageTarget.Name != "local" {
t.Fatalf("expected listed storage target to be preloaded, got %#v", listed[0].StorageTarget)
}
recent, err := repo.ListRecent(ctx, 5)
if err != nil {
t.Fatalf("ListRecent returned error: %v", err)
@@ -85,24 +79,6 @@ func TestBackupRecordRepositoryQueries(t *testing.T) {
if len(recent) != 1 {
t.Fatalf("expected one recent record, got %d", len(recent))
}
if recent[0].StorageTarget.Name != "local" {
t.Fatalf("expected recent storage target to be preloaded, got %#v", recent[0].StorageTarget)
}
secondTarget := &model.StorageTarget{Name: "secondary", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}
if err := repo.db.Create(secondTarget).Error; err != nil {
t.Fatalf("seed second storage target error: %v", err)
}
stored.StorageTargetID = secondTarget.ID
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update storage target ID returned error: %v", err)
}
updated, err := repo.FindByID(ctx, record.ID)
if err != nil {
t.Fatalf("FindByID after update returned error: %v", err)
}
if updated == nil || updated.StorageTargetID != secondTarget.ID || updated.StorageTarget.Name != "secondary" {
t.Fatalf("expected updated storage target to remain %d, got %#v", secondTarget.ID, updated)
}
total, err := repo.Count(ctx)
if err != nil {
t.Fatalf("Count returned error: %v", err)

View File

@@ -38,7 +38,6 @@ type Service struct {
verifyRunner VerifyRunner
logger *zap.Logger
audit AuditRecorder
runCtx context.Context
entries map[uint]cron.EntryID // 备份 cron 条目
verifyEntries map[uint]cron.EntryID // 验证 cron 条目
}
@@ -50,7 +49,6 @@ func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger
tasks: tasks,
runner: runner,
logger: logger,
runCtx: context.Background(),
entries: make(map[uint]cron.EntryID),
verifyEntries: make(map[uint]cron.EntryID),
}
@@ -74,12 +72,6 @@ func (s *Service) SetNodeRepository(nodes repository.NodeRepository) {
}
func (s *Service) Start(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
s.runCtx = ctx
s.mu.Unlock()
if err := s.Reload(ctx); err != nil {
return err
}
@@ -171,11 +163,10 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
taskNodeID := task.NodeID
cronExpr := task.CronExpr
maintenanceWindows := task.MaintenanceWindows
runCtx := s.runCtx
entryID, err := s.cron.AddFunc(cronExpr, func() {
// 集群感知:若任务绑定了离线的远程节点,跳过本轮触发避免堆积 failed 记录
if taskNodeID > 0 && s.nodes != nil {
node, err := s.nodes.FindByID(runCtx, taskNodeID)
node, err := s.nodes.FindByID(context.Background(), taskNodeID)
// 用实时推导的状态判定,避免后台监控刷新前把任务下发给刚失联的节点。
if err == nil && node != nil && !node.IsLocal && node.EffectiveStatus(time.Now().UTC()) != model.NodeStatusOnline {
if s.logger != nil {
@@ -222,7 +213,7 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
TargetName: taskName, Detail: fmt.Sprintf("定时调度触发备份任务: %s (cron: %s)", taskName, cronExpr),
})
}
if _, runErr := s.runner.RunTaskByID(runCtx, taskID); runErr != nil && s.logger != nil {
if _, runErr := s.runner.RunTaskByID(context.Background(), taskID); runErr != nil && s.logger != nil {
s.logger.Warn("scheduled backup run failed", zap.Uint("task_id", taskID), zap.Error(runErr))
}
})
@@ -254,7 +245,6 @@ func (s *Service) syncVerifyTaskLocked(task *model.BackupTask) error {
taskName := task.Name
mode := task.VerifyMode
verifyCron := task.VerifyCronExpr
runCtx := s.runCtx
entryID, err := s.cron.AddFunc(verifyCron, func() {
if s.audit != nil {
s.audit.Record(servicepkg.AuditEntry{
@@ -263,7 +253,7 @@ func (s *Service) syncVerifyTaskLocked(task *model.BackupTask) error {
TargetName: taskName, Detail: fmt.Sprintf("定时验证演练: %s (cron: %s, mode: %s)", taskName, verifyCron, mode),
})
}
if _, runErr := s.verifyRunner.StartByTask(runCtx, taskID, mode, "system"); runErr != nil && s.logger != nil {
if _, runErr := s.verifyRunner.StartByTask(context.Background(), taskID, mode, "system"); runErr != nil && s.logger != nil {
s.logger.Warn("scheduled verify run failed", zap.Uint("task_id", taskID), zap.Error(runErr))
}
})

View File

@@ -0,0 +1,60 @@
//go:build ignore
package security
import (
"fmt"
"time"
"backupx/server/internal/model"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type JWTManager struct {
secret []byte
duration time.Duration
}
func NewJWTManager(secret string, duration time.Duration) *JWTManager {
return &JWTManager{secret: []byte(secret), duration: duration}
}
func (m *JWTManager) IssueToken(user *model.User) (string, error) {
now := time.Now().UTC()
claims := Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", user.ID),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(m.duration)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
func (m *JWTManager) Parse(tokenValue string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenValue, &Claims{}, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method")
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}

View File

@@ -0,0 +1,25 @@
//go:build ignore
package security
import (
"testing"
"time"
"backupx/server/internal/model"
)
func TestJWTManagerIssueAndParse(t *testing.T) {
manager := NewJWTManager("test-secret", time.Hour)
token, err := manager.IssueToken(&model.User{ID: 7, Username: "admin", Role: "admin"})
if err != nil {
t.Fatalf("IssueToken() error = %v", err)
}
claims, err := manager.Parse(token)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if claims.UserID != 7 || claims.Username != "admin" {
t.Fatalf("unexpected claims: %+v", claims)
}
}

View File

@@ -0,0 +1,54 @@
//go:build ignore
package security
import (
"sync"
"time"
)
type limiterEntry struct {
Count int
ResetAt time.Time
}
type LoginLimiter struct {
mu sync.Mutex
window time.Duration
max int
records map[string]limiterEntry
}
func NewLoginLimiter(max int, window time.Duration) *LoginLimiter {
return &LoginLimiter{window: window, max: max, records: make(map[string]limiterEntry)}
}
func (l *LoginLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
entry, ok := l.records[key]
if !ok || time.Now().After(entry.ResetAt) {
delete(l.records, key)
return true
}
return entry.Count < l.max
}
func (l *LoginLimiter) RegisterFailure(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
entry, ok := l.records[key]
if !ok || now.After(entry.ResetAt) {
l.records[key] = limiterEntry{Count: 1, ResetAt: now.Add(l.window)}
return
}
entry.Count++
l.records[key] = entry
}
func (l *LoginLimiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.records, key)
}

View File

@@ -0,0 +1,93 @@
//go:build ignore
package security
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"backupx/server/internal/config"
)
type PersistedSecrets struct {
JWTSecret string `json:"jwtSecret"`
EncryptionKey string `json:"encryptionKey"`
}
func EnsureSecrets(cfg *config.Config) error {
if cfg.Security.JWTSecret != "" && cfg.Security.EncryptionKey != "" {
return nil
}
storePath := filepath.Join(filepath.Dir(cfg.Database.Path), "backupx.secrets.json")
current, err := loadSecrets(storePath)
if err != nil {
return err
}
if current == nil {
current = &PersistedSecrets{}
}
if current.JWTSecret == "" {
current.JWTSecret, err = randomHex(32)
if err != nil {
return err
}
}
if current.EncryptionKey == "" {
current.EncryptionKey, err = randomHex(32)
if err != nil {
return err
}
}
if err := saveSecrets(storePath, current); err != nil {
return err
}
if cfg.Security.JWTSecret == "" {
cfg.Security.JWTSecret = current.JWTSecret
}
if cfg.Security.EncryptionKey == "" {
cfg.Security.EncryptionKey = current.EncryptionKey
}
return nil
}
func loadSecrets(path string) (*PersistedSecrets, error) {
content, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read secrets: %w", err)
}
var secrets PersistedSecrets
if err := json.Unmarshal(content, &secrets); err != nil {
return nil, fmt.Errorf("decode secrets: %w", err)
}
return &secrets, nil
}
func saveSecrets(path string, secrets *PersistedSecrets) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create secrets dir: %w", err)
}
content, err := json.MarshalIndent(secrets, "", " ")
if err != nil {
return fmt.Errorf("encode secrets: %w", err)
}
if err := os.WriteFile(path, content, 0o600); err != nil {
return fmt.Errorf("write secrets: %w", err)
}
return nil
}
func randomHex(size int) (string, error) {
bytes := make([]byte, size)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("generate random secret: %w", err)
}
return hex.EncodeToString(bytes), nil
}

View File

@@ -19,7 +19,6 @@ import (
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
"go.uber.org/zap"
)
// AgentService 实现 Master 端 Agent 协议,提供给远程 Agent 通过 HTTP 调用。
@@ -33,8 +32,6 @@ type AgentService struct {
restoreRepo repository.RestoreRecordRepository
registry *storage.Registry
cipher *codec.ConfigCipher
logger *zap.Logger
background BackgroundRunner
}
func NewAgentService(
@@ -54,24 +51,9 @@ func NewAgentService(
cmdRepo: cmdRepo,
registry: registry,
cipher: cipher,
logger: zap.NewNop(),
}
}
// SetLogger attaches the application logger used by background command
// reconciliation. The no-op default keeps the service safe in tests.
func (s *AgentService) SetLogger(logger *zap.Logger) {
if logger != nil {
s.logger = logger
}
}
// SetBackgroundRunner makes the command timeout monitor part of the
// application lifecycle so shutdown waits for an in-flight reconciliation.
func (s *AgentService) SetBackgroundRunner(background BackgroundRunner) {
s.background = background
}
// SetRestoreRepository 注入恢复记录仓储,用于命令超时时联动 restore_record 状态。
// 可选注入:未注入时恢复命令超时仅标记命令 timeout记录需另行查验。
func (s *AgentService) SetRestoreRepository(repo repository.RestoreRecordRepository) {
@@ -135,16 +117,6 @@ func (s *AgentService) SubmitCommandResult(ctx context.Context, node *model.Node
if cmd.NodeID != node.ID {
return apperror.Unauthorized("AGENT_COMMAND_FORBIDDEN", "命令不属于当前节点", nil)
}
// A failed terminal report may be retried after the command row was already
// completed but before its linked business record was updated. Re-run that
// idempotent convergence step so a transient database error cannot leave a
// backup or restore record stuck in running forever.
if cmd.Status == model.AgentCommandStatusFailed {
if result.Success {
return nil
}
return s.failLinkedRecord(ctx, cmd, agentCommandFailureMessage(result.ErrorMessage))
}
now := time.Now().UTC()
if result.Success {
cmd.Status = model.AgentCommandStatusSucceeded
@@ -156,21 +128,8 @@ func (s *AgentService) SubmitCommandResult(ctx context.Context, node *model.Node
cmd.Result = string(result.Result)
}
cmd.CompletedAt = &now
completed, err := s.cmdRepo.CompleteDispatched(ctx, cmd)
if err != nil || !completed || result.Success {
return err
}
persistCtx, cancel := finalizationContext(ctx)
defer cancel()
return s.failLinkedRecord(persistCtx, cmd, agentCommandFailureMessage(result.ErrorMessage))
}
func agentCommandFailureMessage(message string) string {
message = strings.TrimSpace(message)
if message == "" {
return "Agent 命令执行失败"
}
return "Agent 命令执行失败:" + message
_, err = s.cmdRepo.CompleteDispatched(ctx, cmd)
return err
}
// AgentTaskSpec 给 Agent 返回的任务规格,包含解密后的存储配置,供 Agent 直接执行。
@@ -657,29 +616,19 @@ func (s *AgentService) StartCommandTimeoutMonitor(ctx context.Context, interval
if timeout <= 0 {
timeout = 10 * time.Minute
}
monitor := func(runCtx context.Context) {
ticker := time.NewTicker(interval)
ticker := time.NewTicker(interval)
go func() {
defer ticker.Stop()
for {
select {
case <-runCtx.Done():
case <-ctx.Done():
return
case <-ticker.C:
threshold := time.Now().UTC().Add(-timeout)
s.processStaleCommands(runCtx, threshold)
s.processStaleCommands(ctx, threshold)
}
}
}
if s.background != nil {
if !s.background.Go(monitor) {
s.logger.Warn("agent command timeout monitor not started: application is shutting down")
}
return
}
if ctx == nil {
ctx = context.Background()
}
go monitor(ctx)
}()
}
// processStaleCommands 扫描已超时的 pending/dispatched 命令并联动关联记录。
@@ -687,24 +636,12 @@ func (s *AgentService) StartCommandTimeoutMonitor(ctx context.Context, interval
// 单条失败不影响后续处理。
func (s *AgentService) processStaleCommands(ctx context.Context, threshold time.Time) {
commands, err := s.cmdRepo.ListStaleActive(ctx, threshold)
if err != nil {
s.logger.Error("list stale agent commands failed", zap.Error(err))
return
}
if len(commands) == 0 {
if err != nil || len(commands) == 0 {
return
}
for i := range commands {
cmd := commands[i]
stillActive, activeErr := s.commandStillActive(ctx, &cmd, threshold)
if activeErr != nil {
s.logger.Warn("check stale agent command activity failed",
zap.Uint("command_id", cmd.ID),
zap.String("command_type", cmd.Type),
zap.Error(activeErr))
continue
}
if stillActive {
if s.commandStillActive(ctx, &cmd, threshold) {
continue
}
now := time.Now().UTC()
@@ -712,33 +649,18 @@ func (s *AgentService) processStaleCommands(ctx context.Context, threshold time.
cmd.ErrorMessage = "agent did not report result before timeout"
cmd.CompletedAt = &now
timedOut, err := s.cmdRepo.TimeoutActive(ctx, &cmd)
if err != nil {
s.logger.Error("mark agent command timed out failed",
zap.Uint("command_id", cmd.ID),
zap.String("command_type", cmd.Type),
zap.Error(err))
if err != nil || !timedOut {
continue
}
if !timedOut {
continue
}
persistCtx, cancel := finalizationContext(ctx)
failErr := s.failLinkedRecord(persistCtx, &cmd)
cancel()
if failErr != nil {
s.logger.Error("mark timed-out agent command record failed",
zap.Uint("command_id", cmd.ID),
zap.String("command_type", cmd.Type),
zap.Error(failErr))
}
s.failLinkedRecord(ctx, &cmd)
}
}
// commandStillActive 用关联记录状态、记录更新时间和节点心跳作为长任务续租信号。
// 仅 run_task / restore_record 允许续租,避免短 RPC 命令被在线节点长期保留。
func (s *AgentService) commandStillActive(ctx context.Context, cmd *model.AgentCommand, threshold time.Time) (bool, error) {
func (s *AgentService) commandStillActive(ctx context.Context, cmd *model.AgentCommand, threshold time.Time) bool {
if cmd.Status != model.AgentCommandStatusDispatched {
return false, nil
return false
}
switch cmd.Type {
case model.AgentCommandTypeRunTask:
@@ -746,121 +668,90 @@ func (s *AgentService) commandStillActive(ctx context.Context, cmd *model.AgentC
RecordID uint `json:"recordId"`
}
if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil || payload.RecordID == 0 {
return false, nil
return false
}
record, err := s.recordRepo.FindByID(ctx, payload.RecordID)
if err != nil {
return false, fmt.Errorf("find backup record %d: %w", payload.RecordID, err)
if err != nil || record == nil || record.Status != model.BackupRecordStatusRunning {
return false
}
if record == nil || record.Status != model.BackupRecordStatusRunning {
return false, nil
if s.nodeRecentlySeen(ctx, cmd.NodeID, threshold) {
return true
}
nodeActive, err := s.nodeRecentlySeen(ctx, cmd.NodeID, threshold)
if err != nil {
return false, err
}
return nodeActive || record.UpdatedAt.After(threshold), nil
return record.UpdatedAt.After(threshold)
case model.AgentCommandTypeRestoreRecord:
if s.restoreRepo == nil {
return false, nil
return false
}
var payload struct {
RestoreRecordID uint `json:"restoreRecordId"`
}
if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil || payload.RestoreRecordID == 0 {
return false, nil
return false
}
restore, err := s.restoreRepo.FindByID(ctx, payload.RestoreRecordID)
if err != nil {
return false, fmt.Errorf("find restore record %d: %w", payload.RestoreRecordID, err)
if err != nil || restore == nil || restore.Status != model.RestoreRecordStatusRunning {
return false
}
if restore == nil || restore.Status != model.RestoreRecordStatusRunning {
return false, nil
if s.nodeRecentlySeen(ctx, cmd.NodeID, threshold) {
return true
}
nodeActive, err := s.nodeRecentlySeen(ctx, cmd.NodeID, threshold)
if err != nil {
return false, err
}
return nodeActive || restore.UpdatedAt.After(threshold), nil
return restore.UpdatedAt.After(threshold)
default:
return false, nil
return false
}
}
func (s *AgentService) nodeRecentlySeen(ctx context.Context, nodeID uint, threshold time.Time) (bool, error) {
func (s *AgentService) nodeRecentlySeen(ctx context.Context, nodeID uint, threshold time.Time) bool {
node, err := s.nodeRepo.FindByID(ctx, nodeID)
if err != nil {
return false, fmt.Errorf("find agent node %d: %w", nodeID, err)
if err != nil || node == nil {
return false
}
if node == nil {
return false, nil
}
return node.Status == model.NodeStatusOnline && node.LastSeen.After(threshold), nil
return node.Status == model.NodeStatusOnline && node.LastSeen.After(threshold)
}
// failLinkedRecord 根据命令类型把关联记录标记为 failed。
// 只对仍然处于 running 状态的记录生效,避免覆盖已完成的结果。
func (s *AgentService) failLinkedRecord(ctx context.Context, cmd *model.AgentCommand, messages ...string) error {
failureMessage := "Agent 未在超时前回传状态(节点可能已离线或崩溃)"
if len(messages) > 0 && strings.TrimSpace(messages[0]) != "" {
failureMessage = strings.TrimSpace(messages[0])
}
func (s *AgentService) failLinkedRecord(ctx context.Context, cmd *model.AgentCommand) {
const failureMessage = "Agent 未在超时前回传状态(节点可能已离线或崩溃)"
switch cmd.Type {
case model.AgentCommandTypeRunTask:
var payload struct {
RecordID uint `json:"recordId"`
}
if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil {
return fmt.Errorf("decode run_task payload: %w", err)
}
if payload.RecordID == 0 {
return errors.New("run_task payload has no recordId")
if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil || payload.RecordID == 0 {
return
}
record, err := s.recordRepo.FindByID(ctx, payload.RecordID)
if err != nil {
return fmt.Errorf("find backup record %d: %w", payload.RecordID, err)
}
if record == nil || record.Status != model.BackupRecordStatusRunning {
return nil
if err != nil || record == nil || record.Status != model.BackupRecordStatusRunning {
return
}
completedAt := time.Now().UTC()
record.Status = model.BackupRecordStatusFailed
record.ErrorMessage = failureMessage
record.CompletedAt = &completedAt
record.DurationSeconds = int(completedAt.Sub(record.StartedAt).Seconds())
if err := s.recordRepo.Update(ctx, record); err != nil {
return fmt.Errorf("update backup record %d: %w", record.ID, err)
}
_ = s.recordRepo.Update(ctx, record)
case model.AgentCommandTypeRestoreRecord:
if s.restoreRepo == nil {
return errors.New("restore record repository is not configured")
return
}
var payload struct {
RestoreRecordID uint `json:"restoreRecordId"`
}
if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil {
return fmt.Errorf("decode restore_record payload: %w", err)
}
if payload.RestoreRecordID == 0 {
return errors.New("restore_record payload has no restoreRecordId")
if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil || payload.RestoreRecordID == 0 {
return
}
restore, err := s.restoreRepo.FindByID(ctx, payload.RestoreRecordID)
if err != nil {
return fmt.Errorf("find restore record %d: %w", payload.RestoreRecordID, err)
}
if restore == nil || restore.Status != model.RestoreRecordStatusRunning {
return nil
if err != nil || restore == nil || restore.Status != model.RestoreRecordStatusRunning {
return
}
completedAt := time.Now().UTC()
restore.Status = model.RestoreRecordStatusFailed
restore.ErrorMessage = failureMessage
restore.CompletedAt = &completedAt
restore.DurationSeconds = int(completedAt.Sub(restore.StartedAt).Seconds())
if err := s.restoreRepo.Update(ctx, restore); err != nil {
return fmt.Errorf("update restore record %d: %w", restore.ID, err)
}
_ = s.restoreRepo.Update(ctx, restore)
}
return nil
}
// AgentSelfStatus 是 /api/v1/agent/self 端点返回给 Agent 的轻量状态摘要。

View File

@@ -24,15 +24,6 @@ import (
"gorm.io/gorm"
)
type failingUpdateBackupRecordRepository struct {
repository.BackupRecordRepository
updateErr error
}
func (r *failingUpdateBackupRecordRepository) Update(context.Context, *model.BackupRecord) error {
return r.updateErr
}
func newAgentServicePoolTestHarness(t *testing.T) (*AgentService, *gorm.DB, repository.BackupRecordRepository, repository.AgentCommandRepository, *model.Node, *model.Node) {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
@@ -43,7 +34,6 @@ func newAgentServicePoolTestHarness(t *testing.T) (*AgentService, *gorm.DB, repo
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
closeTestDatabase(t, db)
cipher := codec.NewConfigCipher("agent-service-secret")
nodeRepo := repository.NewNodeRepository(db)
taskRepo := repository.NewBackupTaskRepository(db)
@@ -97,23 +87,6 @@ func newAgentServicePoolTestHarness(t *testing.T) (*AgentService, *gorm.DB, repo
return NewAgentService(nodeRepo, taskRepo, recordRepo, storageRepo, cmdRepo, cipher, storageRegistry), db, recordRepo, cmdRepo, owner, other
}
func TestAgentServiceFailLinkedRecordPropagatesTerminalUpdateError(t *testing.T) {
svc, _, records, _, _, _ := newAgentServicePoolTestHarness(t)
wantErr := errors.New("record update failed")
svc.recordRepo = &failingUpdateBackupRecordRepository{
BackupRecordRepository: records,
updateErr: wantErr,
}
err := svc.failLinkedRecord(context.Background(), &model.AgentCommand{
Type: model.AgentCommandTypeRunTask,
Payload: `{"recordId":1}`,
})
if err == nil || !errors.Is(err, wantErr) {
t.Fatalf("failLinkedRecord error = %v, want wrapped update error", err)
}
}
func TestAgentServicePooledTaskUsesRecordNodeForSpecAndRecordUpdates(t *testing.T) {
svc, _, records, _, owner, other := newAgentServicePoolTestHarness(t)
ctx := context.Background()
@@ -779,44 +752,6 @@ func TestAgentServiceSubmitCommandResultDoesNotOverwriteTerminalCommand(t *testi
}
}
func TestAgentServiceSubmitFailedCommandConvergesLinkedRecord(t *testing.T) {
svc, _, records, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
dispatchedAt := time.Now().UTC()
command := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeRunTask,
Status: model.AgentCommandStatusDispatched,
Payload: `{"recordId":1}`,
DispatchedAt: &dispatchedAt,
}
if err := commands.Create(ctx, command); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
if err := svc.SubmitCommandResult(ctx, owner, command.ID, AgentCommandResult{
Success: false,
ErrorMessage: "terminal update could not reach Master",
}); err != nil {
t.Fatalf("SubmitCommandResult returned error: %v", err)
}
record, err := records.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID record returned error: %v", err)
}
if record.Status != model.BackupRecordStatusFailed || !strings.Contains(record.ErrorMessage, "terminal update") {
t.Fatalf("linked record did not converge: %#v", record)
}
updatedCommand, err := commands.FindByID(ctx, command.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusFailed {
t.Fatalf("command status = %q, want failed", updatedCommand.Status)
}
}
func TestAgentServiceUpdateRecordDoesNotOverwriteTerminalRecord(t *testing.T) {
svc, _, records, _, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()

View File

@@ -24,7 +24,6 @@ func newApiKeyTestService(t *testing.T) *ApiKeyService {
if err != nil {
t.Fatalf("database.Open: %v", err)
}
closeTestDatabase(t, db)
return NewApiKeyService(repository.NewApiKeyRepository(db))
}

View File

@@ -23,11 +23,6 @@ func TestAuditRetention(t *testing.T) {
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
auditRepo := repository.NewAuditLogRepository(db)
configRepo := repository.NewSystemConfigRepository(db)
svc := NewAuditService(auditRepo)

View File

@@ -8,6 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
@@ -17,7 +18,6 @@ import (
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"go.uber.org/zap"
)
// AuditEntry 是记录审计日志的输入结构
@@ -41,35 +41,14 @@ type AuditService struct {
webhookURL string
webhookSecret string
httpClient *http.Client
async func(func(context.Context)) bool
logger *zap.Logger
inFlight chan struct{}
}
const maxAuditInFlight = 64
func NewAuditService(repo repository.AuditLogRepository) *AuditService {
return &AuditService{
repo: repo,
httpClient: &http.Client{
Timeout: 3 * time.Second, // 短超时:审计 webhook 不应拖慢业务
},
async: runDetached,
logger: zap.NewNop(),
inFlight: make(chan struct{}, maxAuditInFlight),
}
}
func (s *AuditService) SetLogger(logger *zap.Logger) {
if logger != nil {
s.logger = logger
}
}
// SetBackgroundRunner binds audit persistence and webhook delivery to the application lifecycle.
func (s *AuditService) SetBackgroundRunner(runner BackgroundRunner) {
if runner != nil {
s.async = runner.Go
}
}
@@ -92,55 +71,37 @@ func (s *AuditService) StartRetentionMonitor(ctx context.Context, configs reposi
if interval <= 0 {
interval = 6 * time.Hour
}
if ctx == nil {
ctx = context.Background()
}
accepted := s.async(func(workerCtx context.Context) {
monitorCtx, cancel := context.WithCancel(workerCtx)
defer cancel()
stopLink := context.AfterFunc(ctx, cancel)
defer stopLink()
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
s.runRetentionOnce(monitorCtx, configs) // 启动后立即跑一次
s.runRetentionOnce(ctx, configs) // 启动后立即跑一次
for {
select {
case <-monitorCtx.Done():
case <-ctx.Done():
return
case <-ticker.C:
s.runRetentionOnce(monitorCtx, configs)
s.runRetentionOnce(ctx, configs)
}
}
})
if !accepted {
s.logger.Warn("audit retention monitor not started: application is shutting down")
}
}()
}
func (s *AuditService) runRetentionOnce(ctx context.Context, configs repository.SystemConfigRepository) {
cfg, err := configs.GetByKey(ctx, SettingKeyAuditRetentionDays)
if err != nil {
s.logger.Warn("read audit retention setting failed", zap.Error(err))
return
}
if cfg == nil {
if err != nil || cfg == nil {
return
}
days, err := strconv.Atoi(strings.TrimSpace(cfg.Value))
if err != nil {
s.logger.Warn("invalid audit retention setting", zap.String("value", cfg.Value), zap.Error(err))
return
}
if days <= 0 {
if err != nil || days <= 0 {
return
}
deleted, err := s.PurgeOlderThan(ctx, days)
if err != nil {
s.logger.Warn("audit retention purge failed", zap.Error(err))
log.Printf("[audit] retention purge failed: %v", err)
return
}
if deleted > 0 {
s.logger.Info("audit retention purge completed", zap.Int64("deleted", deleted), zap.Int("retention_days", days))
log.Printf("[audit] retention purge: deleted %d logs older than %d days", deleted, days)
}
}
@@ -162,24 +123,12 @@ func (s *AuditService) SetWebhook(url, secret string) {
s.webhookSecret = strings.TrimSpace(secret)
}
// Record asynchronously persists an audit event without blocking the request.
// Record 异步 fire-and-forget 写入审计日志,不阻塞业务逻辑
func (s *AuditService) Record(entry AuditEntry) {
if s == nil || s.repo == nil {
return
}
select {
case s.inFlight <- struct{}{}:
default:
s.logger.Error("audit event rejected: in-flight limit reached",
zap.Int("limit", cap(s.inFlight)),
zap.String("category", entry.Category),
zap.String("action", entry.Action))
return
}
accepted := s.async(func(workerCtx context.Context) {
defer func() { <-s.inFlight }()
persistCtx, cancel := finalizationContext(workerCtx)
defer cancel()
go func() {
record := &model.AuditLog{
UserID: entry.UserID,
Username: entry.Username,
@@ -191,30 +140,24 @@ func (s *AuditService) Record(entry AuditEntry) {
Detail: entry.Detail,
ClientIP: entry.ClientIP,
}
if err := s.repo.Create(persistCtx, record); err != nil {
s.logger.Error("failed to write audit log", zap.String("category", entry.Category), zap.String("action", entry.Action), zap.Error(err))
if err := s.repo.Create(context.Background(), record); err != nil {
log.Printf("[audit] failed to write audit log: %v", err)
}
if err := s.fireWebhook(persistCtx, record); err != nil {
s.logger.Warn("audit webhook delivery failed", zap.String("category", entry.Category), zap.String("action", entry.Action), zap.Error(err))
}
})
if !accepted {
<-s.inFlight
s.logger.Warn("audit event rejected: application is shutting down", zap.String("category", entry.Category), zap.String("action", entry.Action))
}
s.fireWebhook(record)
}()
}
// fireWebhook forwards an audit event. The caller owns asynchronous execution.
func (s *AuditService) fireWebhook(ctx context.Context, record *model.AuditLog) error {
// fireWebhook 异步向外部系统转发审计事件。失败降级到本地日志,永不影响主流程。
func (s *AuditService) fireWebhook(record *model.AuditLog) {
if s == nil {
return nil
return
}
s.webhookMu.RLock()
url := s.webhookURL
secret := s.webhookSecret
s.webhookMu.RUnlock()
if url == "" {
return nil
return
}
payload := map[string]any{
"eventType": "audit.log",
@@ -233,11 +176,13 @@ func (s *AuditService) fireWebhook(ctx context.Context, record *model.AuditLog)
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal audit webhook: %w", err)
log.Printf("[audit] webhook marshal failed: %v", err)
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build audit webhook request: %w", err)
log.Printf("[audit] webhook build request failed: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "BackupX-Audit/1.0")
@@ -248,13 +193,13 @@ func (s *AuditService) fireWebhook(ctx context.Context, record *model.AuditLog)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("post audit webhook: %w", err)
log.Printf("[audit] webhook POST failed: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("audit webhook returned status %d", resp.StatusCode)
log.Printf("[audit] webhook returned status %d", resp.StatusCode)
}
return nil
}
// List 分页查询审计日志

View File

@@ -14,7 +14,6 @@ import (
"testing"
"time"
"backupx/server/internal/lifecycle"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
@@ -132,40 +131,3 @@ func TestAuditService_WebhookDisabledWhenURLEmpty(t *testing.T) {
time.Sleep(100 * time.Millisecond)
// 无显式断言:能不 panic 即算通过
}
func TestAuditServiceSupervisorShutdownFlushesAcceptedRecord(t *testing.T) {
repo := newFakeAuditRepo()
supervisor := lifecycle.NewSupervisor(context.Background())
svc := NewAuditService(repo)
svc.SetBackgroundRunner(supervisor)
svc.Record(AuditEntry{Username: "alice", Category: "auth", Action: "logout"})
waitCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := supervisor.Shutdown(waitCtx); err != nil {
t.Fatalf("Shutdown: %v", err)
}
select {
case <-repo.created:
default:
t.Fatal("accepted audit record was not flushed during shutdown")
}
}
func TestAuditServiceBoundsInFlightWork(t *testing.T) {
repo := newFakeAuditRepo()
svc := NewAuditService(repo)
svc.inFlight = make(chan struct{}, 1)
accepted := 0
svc.async = func(func(context.Context)) bool {
accepted++
return true
}
svc.Record(AuditEntry{Category: "auth", Action: "first"})
svc.Record(AuditEntry{Category: "auth", Action: "second"})
if accepted != 1 {
t.Fatalf("accepted tasks = %d, want 1", accepted)
}
}

View File

@@ -1,84 +0,0 @@
package service
import (
"context"
"testing"
"time"
)
type capturingMonitorRunner struct {
tasks []func(context.Context)
}
func (r *capturingMonitorRunner) Go(task func(context.Context)) bool {
r.tasks = append(r.tasks, task)
return true
}
func TestLongRunningMonitorsUseConfiguredBackgroundRunner(t *testing.T) {
runner := &capturingMonitorRunner{}
nodes := NewNodeService(nil, "test")
nodes.SetBackgroundRunner(runner)
nodes.StartOfflineMonitor(context.Background(), time.Hour)
installTokens := NewInstallTokenService(nil, nil)
installTokens.SetBackgroundRunner(runner)
installTokens.StartGC(context.Background(), time.Hour)
dashboard := NewDashboardService(nil, nil, nil)
dashboard.SetBackgroundRunner(runner)
dashboard.StartSLAMonitor(context.Background(), nil, time.Hour, time.Hour)
versions := NewClusterVersionMonitor(nil, "test")
versions.SetBackgroundRunner(runner)
versions.Start(context.Background(), time.Hour, time.Hour)
storageTargets := NewStorageTargetService(nil, nil, nil, nil)
storageTargets.SetBackgroundRunner(runner)
storageTargets.StartHealthMonitor(context.Background(), nil, time.Hour)
if len(runner.tasks) != 5 {
t.Fatalf("background runner received %d tasks, want 5", len(runner.tasks))
}
// The monitor must listen to the supervisor-provided context, not retain
// the context passed to Start. Running one captured task is sufficient to
// lock this ownership contract for the shared helper.
runCtx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
runner.tasks[0](runCtx)
close(done)
}()
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("monitor did not stop when background runner context was canceled")
}
}
func TestBackgroundMonitorFallbackUsesCallerContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
started := make(chan struct{})
done := make(chan struct{})
if !startBackgroundMonitor(nil, ctx, func(runCtx context.Context) {
close(started)
<-runCtx.Done()
close(done)
}) {
t.Fatal("fallback monitor was rejected")
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("fallback monitor did not start")
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("fallback monitor did not stop when caller context was canceled")
}
}

View File

@@ -1,62 +0,0 @@
package service
import (
"context"
"net/http"
"time"
"backupx/server/internal/apperror"
)
// BackgroundRunner is the narrow lifecycle dependency used by asynchronous
// services. lifecycle.Supervisor implements it at the application boundary.
type BackgroundRunner interface {
Go(func(context.Context)) bool
}
func runDetached(task func(context.Context)) bool {
if task == nil {
return false
}
go task(context.Background())
return true
}
// startBackgroundMonitor keeps the legacy caller-owned context when no
// lifecycle runner is configured, while allowing the application supervisor
// to own and wait for long-running monitors in production.
func startBackgroundMonitor(runner BackgroundRunner, fallbackCtx context.Context, task func(context.Context)) bool {
if task == nil {
return false
}
if runner != nil {
return runner.Go(task)
}
if fallbackCtx == nil {
fallbackCtx = context.Background()
}
go task(fallbackCtx)
return true
}
func backgroundTaskUnavailable(code string) *apperror.AppError {
return apperror.New(http.StatusServiceUnavailable, code, "服务正在关闭,无法启动新的后台任务", context.Canceled)
}
// finalizationContext lets a canceled task persist its terminal state. It is
// intentionally short-lived so shutdown cannot wait forever on cleanup I/O.
func finalizationContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
}
func acquireBackgroundSlot(ctx context.Context, semaphore chan struct{}) bool {
select {
case semaphore <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}

View File

@@ -105,7 +105,7 @@ type BackupExecutionService struct {
agentDispatcher AgentDispatcher
replicationHook ReplicationTrigger
dependentsResolver DependentsResolver
async func(func(context.Context)) bool
async func(func())
now func() time.Time
tempDir string
semaphore chan struct{}
@@ -127,13 +127,6 @@ func (s *BackupExecutionService) SetMetrics(m *metrics.Metrics) {
s.metrics = m
}
// SetBackgroundRunner binds local asynchronous executions to the application lifecycle.
func (s *BackupExecutionService) SetBackgroundRunner(runner BackgroundRunner) {
if runner != nil {
s.async = runner.Go
}
}
// ReplicationTrigger 抽象备份成功后的副本派发实现者ReplicationService
type ReplicationTrigger interface {
TriggerAutoReplication(ctx context.Context, task *model.BackupTask, record *model.BackupRecord)
@@ -201,12 +194,14 @@ func NewBackupExecutionService(
retention: retention,
cipher: cipher,
notifier: notifier,
async: runDetached,
now: func() time.Time { return time.Now().UTC() },
tempDir: tempDir,
semaphore: make(chan struct{}, maxConcurrent),
retries: retries,
bandwidthLimit: bandwidthLimit,
async: func(job func()) {
go job()
},
now: func() time.Time { return time.Now().UTC() },
tempDir: tempDir,
semaphore: make(chan struct{}, maxConcurrent),
retries: retries,
bandwidthLimit: bandwidthLimit,
}
}
@@ -264,6 +259,62 @@ func (s *BackupExecutionService) DownloadRecord(ctx context.Context, recordID ui
return &DownloadedArtifact{FileName: fileName, Reader: reader}, nil
}
func (s *BackupExecutionService) RestoreRecord(ctx context.Context, recordID uint) error {
record, provider, err := s.loadRecordProvider(ctx, recordID)
if err != nil {
return err
}
task, err := s.tasks.FindByID(ctx, record.TaskID)
if err != nil {
return apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取关联备份任务", err)
}
if task == nil {
return apperror.New(404, "BACKUP_TASK_NOT_FOUND", "关联的备份任务不存在,无法执行恢复", fmt.Errorf("backup task %d not found", record.TaskID))
}
if record.BackupKind == model.BackupKindRepository {
spec, specErr := s.buildTaskSpec(task, record.StartedAt)
if specErr != nil {
return specErr
}
if err := backup.NewRepositoryStore(s.cipher.Key()).Restore(ctx, provider, record.StoragePath, record.Checksum, spec, backup.NopLogWriter{}); err != nil {
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "从 CDC 仓库恢复备份失败", err)
}
return nil
}
tempDir, err := os.MkdirTemp("", "backupx-restore-*")
if err != nil {
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法创建恢复目录", err)
}
defer os.RemoveAll(tempDir)
artifactPath := filepath.Join(tempDir, filepath.Base(record.FileName))
if strings.TrimSpace(filepath.Base(record.FileName)) == "" {
artifactPath = filepath.Join(tempDir, filepath.Base(record.StoragePath))
}
reader, err := provider.Download(ctx, record.StoragePath)
if err != nil {
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法下载备份文件", err)
}
if err := writeReaderToFile(artifactPath, reader); err != nil {
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法写入恢复文件", err)
}
preparedPath, err := s.prepareArtifactForRestore(artifactPath)
if err != nil {
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法准备恢复文件", err)
}
spec, err := s.buildTaskSpec(task, record.StartedAt)
if err != nil {
return err
}
runner, err := s.runnerRegistry.Runner(spec.Type)
if err != nil {
return apperror.BadRequest("BACKUP_TASK_INVALID", "不支持的备份任务类型", err)
}
if err := runner.Restore(ctx, spec, preparedPath, backup.NopLogWriter{}); err != nil {
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "恢复备份失败", err)
}
return nil
}
func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint) error {
record, err := s.records.FindByID(ctx, recordID)
if err != nil {
@@ -453,11 +504,6 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b
task.LastRunAt = &startedAt
task.LastStatus = "running"
if err := s.tasks.Update(ctx, task); err != nil {
finalizeErr := s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed,
"无法更新任务状态: "+err.Error(), "", "", 0, "", "", primaryTargetID)
if finalizeErr != nil {
err = errors.Join(err, finalizeErr)
}
return nil, apperror.Internal("BACKUP_TASK_UPDATE_FAILED", "无法更新任务状态", err)
}
// 多节点路由task.NodeID 指向远程节点时,把执行任务入队给 Agent
@@ -466,10 +512,8 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b
// 节点离线 → 立即把刚创建的 running 记录标记 failed返回明确错误
if remoteNode.Status != model.NodeStatusOnline {
offlineMsg := fmt.Sprintf("节点 %s 当前离线,无法执行备份任务", remoteNode.Name)
if finalizeErr := s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed,
offlineMsg, "", "", 0, "", "", primaryTargetID); finalizeErr != nil {
return nil, apperror.Internal("BACKUP_RECORD_FINALIZE_FAILED", "无法写回备份失败状态", finalizeErr)
}
_ = s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed,
offlineMsg, "", "", 0, "", "", primaryTargetID)
return nil, apperror.BadRequest("NODE_OFFLINE", offlineMsg, nil)
}
if _, enqueueErr := s.agentDispatcher.EnqueueCommand(ctx, resolvedNodeID, model.AgentCommandTypeRunTask, map[string]any{
@@ -477,28 +521,19 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b
"recordId": record.ID,
}); enqueueErr != nil {
// 入队失败 → 在记录中标记失败,继续返回详情
if finalizeErr := s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed,
"无法下发任务到远程节点: "+enqueueErr.Error(), "", "", 0, "", "", primaryTargetID); finalizeErr != nil {
enqueueErr = errors.Join(enqueueErr, finalizeErr)
}
_ = s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed,
"无法下发任务到远程节点: "+enqueueErr.Error(), "", "", 0, "", "", primaryTargetID)
return nil, apperror.Internal("AGENT_COMMAND_ENQUEUE_FAILED", "无法下发任务到远程节点", enqueueErr)
}
return s.getRecordDetail(ctx, record.ID)
}
run := func(runCtx context.Context) {
s.executeTask(runCtx, &runTask, record.ID, startedAt)
run := func() {
s.executeTask(context.Background(), &runTask, record.ID, startedAt)
}
if async {
if !s.async(run) {
message := "服务正在关闭,备份任务未启动"
if finalizeErr := s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed,
message, "", "", 0, "", "", primaryTargetID); finalizeErr != nil {
return nil, apperror.Internal("BACKUP_RECORD_FINALIZE_FAILED", "无法写回备份失败状态", finalizeErr)
}
return nil, backgroundTaskUnavailable("BACKUP_SERVICE_SHUTTING_DOWN")
}
s.async(run)
} else {
run(ctx)
run()
}
return s.getRecordDetail(ctx, record.ID)
}
@@ -809,23 +844,20 @@ func (s *BackupExecutionService) executeRepositoryTask(ctx context.Context, task
logger.Warnf("部分存储目标 CDC 仓库同步失败:%s", strings.Join(failures, "; "))
}
if s.dependentsResolver != nil {
accepted := s.async(func(runCtx context.Context) {
dependents, resolveErr := s.dependentsResolver.TriggerDependents(runCtx, task.ID)
go func(upstreamID uint, upstreamName string) {
dependents, resolveErr := s.dependentsResolver.TriggerDependents(context.Background(), upstreamID)
if resolveErr != nil {
logger.Warnf("解析任务 %s 的下游依赖失败:%v", task.Name, resolveErr)
logger.Warnf("解析任务 %s 的下游依赖失败:%v", upstreamName, resolveErr)
return
}
for _, dependentID := range dependents {
if _, runErr := s.RunTaskByID(runCtx, dependentID); runErr != nil {
logger.Warnf("触发下游任务 #%d 失败(上游: %s%v", dependentID, task.Name, runErr)
if _, runErr := s.RunTaskByID(context.Background(), dependentID); runErr != nil {
logger.Warnf("触发下游任务 #%d 失败(上游: %s%v", dependentID, upstreamName, runErr)
} else {
logger.Infof("已触发下游任务 #%d上游: %s", dependentID, task.Name)
logger.Infof("已触发下游任务 #%d上游: %s", dependentID, upstreamName)
}
}
})
if !accepted {
logger.Warnf("服务正在关闭,跳过触发任务 %s 的下游依赖", task.Name)
}
}(task.ID, task.Name)
}
return result, nil
}
@@ -839,6 +871,20 @@ func (s *BackupExecutionService) acquireRepositoryLock(targetID uint) func() {
}
func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time) {
// 节点级并发限流:当任务绑定节点且节点配置了 MaxConcurrent>0
// 该节点上所有任务共享一个节点专属 semaphore互相排队
nodeSem := s.acquireNodeSemaphore(ctx, task.NodeID)
if nodeSem != nil {
nodeSem <- struct{}{}
defer func() { <-nodeSem }()
}
s.semaphore <- struct{}{}
defer func() { <-s.semaphore }()
// Prometheus: running gauge + 完成时 observe 耗时/字节/状态
s.metrics.IncTaskRunning()
defer s.metrics.DecTaskRunning()
logger := backup.NewExecutionLogger(recordID, s.logHub)
status := "failed"
errMessage := ""
@@ -854,10 +900,8 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
var manifestJSON string
var repositoryProviders map[uint]storage.StorageProvider
completeRecord := func() {
persistCtx, cancel := finalizationContext(ctx)
defer cancel()
readyForRepositoryRetention := status == model.BackupRecordStatusSuccess
if finalizeErr := s.finalizeRecord(persistCtx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath, selectedStorageTargetID); finalizeErr != nil {
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath, selectedStorageTargetID); finalizeErr != nil {
logger.Errorf("写回备份记录失败:%v", finalizeErr)
readyForRepositoryRetention = false
}
@@ -869,7 +913,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
if marshalErr != nil {
logger.Warnf("序列化多目标上传结果失败:%v", marshalErr)
readyForRepositoryRetention = false
} else if record, findErr := s.records.FindByID(persistCtx, recordID); findErr != nil || record == nil {
} else if record, findErr := s.records.FindByID(ctx, recordID); findErr != nil || record == nil {
if findErr != nil {
logger.Warnf("读取备份记录以写回多目标结果失败:%v", findErr)
} else {
@@ -878,7 +922,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
readyForRepositoryRetention = false
} else {
record.StorageUploadResults = string(resultsJSON)
if updateErr := s.records.Update(persistCtx, record); updateErr != nil {
if updateErr := s.records.Update(ctx, record); updateErr != nil {
logger.Warnf("写回多目标上传结果失败:%v", updateErr)
readyForRepositoryRetention = false
}
@@ -886,11 +930,11 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
}
// 持久化差异链信息:全量记录其清单(供后续差异比对),差异记录其基线全量 ID。
if status == model.BackupRecordStatusSuccess && (backupKind != model.BackupKindFull || baseRecordID != 0 || manifestJSON != "") {
if record, findErr := s.records.FindByID(persistCtx, recordID); findErr == nil && record != nil {
if record, findErr := s.records.FindByID(ctx, recordID); findErr == nil && record != nil {
record.BackupKind = backupKind
record.BaseRecordID = baseRecordID
record.Manifest = manifestJSON
if updErr := s.records.Update(persistCtx, record); updErr != nil {
if updErr := s.records.Update(ctx, record); updErr != nil {
logger.Warnf("写回差异链信息失败:%v", updErr)
readyForRepositoryRetention = false
}
@@ -903,7 +947,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
readyForRepositoryRetention = false
}
}
if ctx.Err() == nil && readyForRepositoryRetention && backupKind == model.BackupKindRepository && s.retention != nil && len(repositoryProviders) > 0 {
if readyForRepositoryRetention && backupKind == model.BackupKindRepository && s.retention != nil && len(repositoryProviders) > 0 {
targetIDs := make([]uint, 0, len(repositoryProviders))
for targetID := range repositoryProviders {
targetIDs = append(targetIDs, targetID)
@@ -925,8 +969,8 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
}
}
}
if s.shouldNotify(persistCtx, task, status) {
if err := s.notifier.NotifyBackupResult(persistCtx, BackupExecutionNotification{Task: task, Record: &model.BackupRecord{ID: recordID, TaskID: task.ID, Status: status, FileName: fileName, FileSize: fileSize, StoragePath: storagePath, ErrorMessage: errMessage, StartedAt: startedAt}, Error: buildOptionalError(errMessage)}); err != nil {
if s.shouldNotify(ctx, task, status) {
if err := s.notifier.NotifyBackupResult(ctx, BackupExecutionNotification{Task: task, Record: &model.BackupRecord{ID: recordID, TaskID: task.ID, Status: status, FileName: fileName, FileSize: fileSize, StoragePath: storagePath, ErrorMessage: errMessage, StartedAt: startedAt}, Error: buildOptionalError(errMessage)}); err != nil {
logger.Warnf("发送备份通知失败:%v", err)
}
} else {
@@ -936,28 +980,6 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
}
defer completeRecord()
// 节点级并发限流:当任务绑定节点且节点配置了 MaxConcurrent>0
// 该节点上所有任务共享一个节点专属 semaphore互相排队。
nodeSem := s.acquireNodeSemaphore(ctx, task.NodeID)
if nodeSem != nil {
if !acquireBackgroundSlot(ctx, nodeSem) {
errMessage = ctx.Err().Error()
logger.Warnf("等待节点执行槽时任务被取消:%v", ctx.Err())
return
}
defer func() { <-nodeSem }()
}
if !acquireBackgroundSlot(ctx, s.semaphore) {
errMessage = ctx.Err().Error()
logger.Warnf("等待全局执行槽时任务被取消:%v", ctx.Err())
return
}
defer func() { <-s.semaphore }()
// Prometheus: running gauge + 完成时 observe 耗时/字节/状态
s.metrics.IncTaskRunning()
defer s.metrics.DecTaskRunning()
spec, err := s.buildTaskSpec(task, startedAt)
if err != nil {
errMessage = err.Error()
@@ -1004,8 +1026,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
return
}
defer os.RemoveAll(result.TempDir)
// 依据运行器产出判定实际类型:文件全量会产出清单;只有明确启用差异模式且
// 未产出清单时才是差异备份。数据库运行器本身不产出文件清单,但仍属于全量。
// 依据运行器产出判定实际类型:产出清单 → 全量(记录清单供后续差异比对);否则为差异。
if result.Manifest != nil {
backupKind = model.BackupKindFull
if data, encErr := backup.EncodeManifest(*result.Manifest); encErr == nil {
@@ -1013,10 +1034,8 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
} else {
logger.Warnf("备份清单序列化失败(不影响本次备份,但将禁用后续差异):%v", encErr)
}
} else if spec.Differential {
backupKind = model.BackupKindDifferential
} else {
backupKind = model.BackupKindFull
backupKind = model.BackupKindDifferential
}
finalPath := result.ArtifactPath
if strings.EqualFold(task.Compression, "gzip") && !strings.HasSuffix(strings.ToLower(finalPath), ".gz") {
@@ -1195,24 +1214,20 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
// 自动派发复制3-2-1任务配置 ReplicationTargetIDs 且本次有任意目标成功时生效
// 触发下游依赖任务best-effort失败仅 warn
if s.dependentsResolver != nil {
accepted := s.async(func(runCtx context.Context) {
dependents, err := s.dependentsResolver.TriggerDependents(runCtx, task.ID)
go func(upstreamID uint, upstreamName string) {
dependents, err := s.dependentsResolver.TriggerDependents(context.Background(), upstreamID)
if err != nil {
logger.Warnf("解析任务 %s 的下游依赖失败:%v", task.Name, err)
return
}
for _, depID := range dependents {
_, runErr := s.RunTaskByID(runCtx, depID)
_, runErr := s.RunTaskByID(context.Background(), depID)
if runErr != nil {
logger.Warnf("触发下游任务 #%d 失败(上游: %s: %v", depID, task.Name, runErr)
logger.Warnf("触发下游任务 #%d 失败(上游: %s: %v", depID, upstreamName, runErr)
} else {
logger.Infof("已触发下游任务 #%d上游: %s", depID, task.Name)
logger.Infof("已触发下游任务 #%d上游: %s", depID, upstreamName)
}
}
})
if !accepted {
logger.Warnf("服务正在关闭,跳过触发任务 %s 的下游依赖", task.Name)
}
}(task.ID, task.Name)
}
if s.replicationHook != nil && strings.TrimSpace(task.ReplicationTargetIDs) != "" {
record := &model.BackupRecord{
@@ -1235,7 +1250,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
}
}
logger.Infof("触发自动复制3-2-1 规则):%s", task.ReplicationTargetIDs)
s.replicationHook.TriggerAutoReplication(ctx, task, record)
s.replicationHook.TriggerAutoReplication(context.Background(), task, record)
}
} else {
errMessage = strings.Join(failedMessages, "; ")
@@ -1338,6 +1353,28 @@ func applyHANAExtraConfig(spec *backup.DatabaseSpec, extra map[string]any) {
}
}
func (s *BackupExecutionService) loadRecordProvider(ctx context.Context, recordID uint) (*model.BackupRecord, storage.StorageProvider, error) {
record, err := s.records.FindByID(ctx, recordID)
if err != nil {
return nil, nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err)
}
if record == nil {
return nil, nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", fmt.Errorf("backup record %d not found", recordID))
}
if err := s.validateClusterAccessible(ctx, record); err != nil {
return nil, nil, err
}
provider, err := s.resolveProvider(ctx, record.StorageTargetID)
if err != nil {
return nil, nil, err
}
return record, provider, nil
}
func (s *BackupExecutionService) prepareArtifactForRestore(artifactPath string) (string, error) {
return prepareBackupArtifact(s.cipher, artifactPath, nil)
}
func (s *BackupExecutionService) getRecordDetail(ctx context.Context, recordID uint) (*BackupRecordDetail, error) {
record, err := s.records.FindByID(ctx, recordID)
if err != nil {
@@ -1370,6 +1407,25 @@ func buildOptionalError(message string) error {
return fmt.Errorf("%s", message)
}
func buildStorageProviderFromRepos(ctx context.Context, storageTargetID uint, storageTargets repository.StorageTargetRepository, storageRegistry *storage.Registry, cipher *codec.ConfigCipher) (storage.StorageProvider, *model.StorageTarget, error) {
target, err := storageTargets.FindByID(ctx, storageTargetID)
if err != nil {
return nil, nil, apperror.Internal("BACKUP_STORAGE_TARGET_LOOKUP_FAILED", "无法读取存储目标", err)
}
if target == nil {
return nil, nil, apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "存储目标不存在", nil)
}
var configMap map[string]any
if err := cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
return nil, nil, apperror.Internal("BACKUP_STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
}
provider, err := storageRegistry.Create(ctx, storage.ParseProviderType(target.Type), configMap)
if err != nil {
return nil, nil, err
}
return provider, target, nil
}
// hashingReader 在上传过程中同步计算字节数和 SHA-256零额外 I/O
type hashingReader struct {
reader io.Reader

View File

@@ -33,8 +33,6 @@ func (f *testStorageFactory) Type() storage.ProviderType {
return "test_storage"
}
func (f *testStorageFactory) SensitiveFields() []string { return nil }
func (f *testStorageFactory) New(_ context.Context, config map[string]any) (storage.StorageProvider, error) {
name, _ := config["name"].(string)
provider := f.providers[name]
@@ -110,11 +108,6 @@ func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRec
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db.DB returned error: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
cipher := codec.NewConfigCipher("execution-secret")
tasks := repository.NewBackupTaskRepository(db)
targets := repository.NewStorageTargetRepository(db)
@@ -163,34 +156,6 @@ func TestBackupExecutionServiceRunTaskByIDSync(t *testing.T) {
}
}
func TestBackupExecutionServiceSQLiteBackupRemainsFull(t *testing.T) {
executionService, _, tasks, _, records, sourceDir, _ := newExecutionTestServices(t)
dbPath := filepath.Join(sourceDir, "finance.db")
if err := os.WriteFile(dbPath, []byte("sqlite-demo"), 0o644); err != nil {
t.Fatalf("WriteFile sqlite fixture returned error: %v", err)
}
task := &model.BackupTask{
Name: "finance-db", Type: model.BackupTaskTypeSQLite, Enabled: true,
DBPath: dbPath, StorageTargetID: 1, RetentionDays: 30,
Compression: "none", MaxBackups: 10, LastStatus: "idle",
}
if err := tasks.Create(context.Background(), task); err != nil {
t.Fatalf("Create sqlite task returned error: %v", err)
}
detail, err := executionService.RunTaskByIDSync(context.Background(), task.ID)
if err != nil {
t.Fatalf("RunTaskByIDSync sqlite returned error: %v", err)
}
stored, err := records.FindByID(context.Background(), detail.ID)
if err != nil {
t.Fatalf("FindByID sqlite record returned error: %v", err)
}
if stored == nil || stored.BackupKind != model.BackupKindFull {
t.Fatalf("expected sqlite backup kind full, got %#v", stored)
}
}
func TestBackupExecutionServiceRepositoryModeRoundTrip(t *testing.T) {
executionService, recordService, tasks, _, records, sourceDir, storageDir := newExecutionTestServices(t)
ctx := context.Background()
@@ -252,6 +217,20 @@ func TestBackupExecutionServiceRepositoryModeRoundTrip(t *testing.T) {
t.Fatalf("unexpected repository export: name=%s size=%d", download.FileName, len(exported))
}
if err := os.WriteFile(largePath, bytes.Repeat([]byte{0}, len(large)), 0o640); err != nil {
t.Fatalf("damage source before restore: %v", err)
}
if err := executionService.RestoreRecord(ctx, second.ID); err != nil {
t.Fatalf("restore repository record returned error: %v", err)
}
restored, err := os.ReadFile(largePath)
if err != nil {
t.Fatalf("read restored source: %v", err)
}
if !bytes.Equal(restored, large) {
t.Fatalf("repository restore did not reproduce the source")
}
if err := recordService.Delete(ctx, first.ID); err != nil {
t.Fatalf("delete first repository record: %v", err)
}
@@ -416,6 +395,40 @@ func TestBackupExecutionServiceDeleteRecordDispatchesRemoteLocalDiskCleanup(t *t
}
}
func TestBackupExecutionServiceRestoreRecordRejectsRemoteLocalDisk(t *testing.T) {
executionService, _, tasks, _, records, _, _ := newExecutionTestServices(t)
ctx := context.Background()
executionService.SetClusterDependencies(&nodeRepoStub{nodes: []model.Node{
{ID: 10, Name: "edge-a", Token: "edge-a-token", Status: model.NodeStatusOnline},
}}, &fakeDispatcher{})
task, err := tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID task returned error: %v", err)
}
completedAt := time.Now().UTC()
record := &model.BackupRecord{
TaskID: task.ID,
StorageTargetID: task.StorageTargetID,
NodeID: 10,
Status: model.BackupRecordStatusSuccess,
FileName: "remote.tar.gz",
StoragePath: "file/2026/05/09/remote.tar.gz",
StartedAt: completedAt.Add(-time.Second),
CompletedAt: &completedAt,
}
if err := records.Create(ctx, record); err != nil {
t.Fatalf("Create record returned error: %v", err)
}
err = executionService.RestoreRecord(ctx, record.ID)
if err == nil {
t.Fatal("expected remote local_disk restore to be rejected")
}
if !strings.Contains(err.Error(), "Master 无法跨节点访问") {
t.Fatalf("expected cross-node local_disk error, got %v", err)
}
}
func TestBackupExecutionServiceDownloadsMasterRelayedLocalDiskRecord(t *testing.T) {
executionService, _, tasks, _, records, _, storageDir := newExecutionTestServices(t)
ctx := context.Background()
@@ -666,6 +679,27 @@ func TestBackupExecutionServiceContinuesWhenStorageUsageSnapshotFails(t *testing
}
}
func TestBackupRecordServiceRestore(t *testing.T) {
executionService, recordService, _, _, _, sourceDir, _ := newExecutionTestServices(t)
detail, err := executionService.RunTaskByIDSync(context.Background(), 1)
if err != nil {
t.Fatalf("RunTaskByIDSync returned error: %v", err)
}
if err := os.RemoveAll(sourceDir); err != nil {
t.Fatalf("RemoveAll returned error: %v", err)
}
if err := recordService.Restore(context.Background(), detail.ID); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
content, err := os.ReadFile(filepath.Join(sourceDir, "index.html"))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if string(content) != "hello" {
t.Fatalf("unexpected restored content: %s", string(content))
}
}
type storageUsageCountingRecordRepo struct {
repository.BackupRecordRepository
mu sync.Mutex

View File

@@ -37,7 +37,6 @@ func newLockTestHarness(t *testing.T) (*BackupRecordService, *BackupExecutionSer
if err != nil {
t.Fatal(err)
}
closeTestDatabase(t, db)
cipher := codec.NewConfigCipher("lock-secret")
targets := repository.NewStorageTargetRepository(db)
tasks := repository.NewBackupTaskRepository(db)

View File

@@ -156,6 +156,10 @@ func (s *BackupRecordService) Download(ctx context.Context, id uint) (*Downloade
return s.execution.DownloadRecord(ctx, id)
}
func (s *BackupRecordService) Restore(ctx context.Context, id uint) error {
return s.execution.RestoreRecord(ctx, id)
}
func (s *BackupRecordService) Delete(ctx context.Context, id uint) error {
return s.execution.DeleteRecord(ctx, id)
}

View File

@@ -3,7 +3,6 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
@@ -390,15 +389,7 @@ func (s *BackupTaskService) Delete(ctx context.Context, id uint) (*DeleteResult,
return nil, apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
}
if s.scheduler != nil {
if err := s.scheduler.RemoveTask(ctx, id); err != nil {
rollbackCtx, cancel := finalizationContext(ctx)
rollbackErr := s.scheduler.SyncTask(rollbackCtx, existing)
cancel()
if rollbackErr != nil {
err = errors.Join(err, fmt.Errorf("restore task schedule: %w", rollbackErr))
}
return nil, apperror.Internal("BACKUP_TASK_UNSCHEDULE_FAILED", "无法移除备份任务调度", err)
}
_ = s.scheduler.RemoveTask(ctx, id)
}
// 清理远端存储文件(尽力而为,不阻止删除)
@@ -406,14 +397,6 @@ func (s *BackupTaskService) Delete(ctx context.Context, id uint) (*DeleteResult,
result.RecordCount, result.CleanedFiles = s.cleanupRemoteFiles(ctx, id)
if err := s.tasks.Delete(ctx, id); err != nil {
if s.scheduler != nil {
rollbackCtx, cancel := finalizationContext(ctx)
rollbackErr := s.scheduler.SyncTask(rollbackCtx, existing)
cancel()
if rollbackErr != nil {
err = errors.Join(err, fmt.Errorf("restore task schedule: %w", rollbackErr))
}
}
return nil, apperror.Internal("BACKUP_TASK_DELETE_FAILED", "无法删除备份任务", err)
}
return result, nil

View File

@@ -2,12 +2,10 @@ package service
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"backupx/server/internal/apperror"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
@@ -16,34 +14,6 @@ import (
"backupx/server/internal/storage/codec"
)
type backupTaskSchedulerStub struct {
removeErr error
syncErr error
removedIDs []uint
syncedTasks []model.BackupTask
}
func (s *backupTaskSchedulerStub) SyncTask(_ context.Context, task *model.BackupTask) error {
if task != nil {
s.syncedTasks = append(s.syncedTasks, *task)
}
return s.syncErr
}
func (s *backupTaskSchedulerStub) RemoveTask(_ context.Context, taskID uint) error {
s.removedIDs = append(s.removedIDs, taskID)
return s.removeErr
}
type failingDeleteBackupTaskRepository struct {
repository.BackupTaskRepository
deleteErr error
}
func (r *failingDeleteBackupTaskRepository) Delete(context.Context, uint) error {
return r.deleteErr
}
func newBackupTaskServiceForTest(t *testing.T) (*BackupTaskService, repository.StorageTargetRepository, repository.BackupTaskRepository) {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
@@ -54,7 +24,6 @@ func newBackupTaskServiceForTest(t *testing.T) (*BackupTaskService, repository.S
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
closeTestDatabase(t, db)
targets := repository.NewStorageTargetRepository(db)
tasks := repository.NewBackupTaskRepository(db)
service := NewBackupTaskService(tasks, targets, codec.NewConfigCipher("task-service-secret"))
@@ -169,76 +138,6 @@ func TestBackupTaskServiceCreateAndGet(t *testing.T) {
}
}
func TestBackupTaskServiceDeleteKeepsTaskWhenUnscheduleFails(t *testing.T) {
ctx := context.Background()
service, targets, tasks := newBackupTaskServiceForTest(t)
if err := targets.Create(ctx, &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "ciphertext", ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatal(err)
}
created, err := service.Create(ctx, BackupTaskUpsertInput{
Name: "unschedule-failure", Type: "file", Enabled: true, SourcePath: "/srv/data",
StorageTargetID: 1, RetentionDays: 7, Compression: "gzip", MaxBackups: 3,
})
if err != nil {
t.Fatal(err)
}
scheduler := &backupTaskSchedulerStub{removeErr: errors.New("remove failed")}
service.SetScheduler(scheduler)
if _, err := service.Delete(ctx, created.ID); err == nil {
t.Fatal("Delete should fail when the scheduler cannot remove the task")
} else {
var appErr *apperror.AppError
if !errors.As(err, &appErr) || appErr.Code != "BACKUP_TASK_UNSCHEDULE_FAILED" {
t.Fatalf("Delete error = %#v", err)
}
}
stored, err := tasks.FindByID(ctx, created.ID)
if err != nil || stored == nil {
t.Fatalf("task should remain after unschedule failure: task=%#v err=%v", stored, err)
}
if len(scheduler.removedIDs) != 1 || len(scheduler.syncedTasks) != 1 {
t.Fatalf("scheduler calls = remove:%v sync:%d", scheduler.removedIDs, len(scheduler.syncedTasks))
}
}
func TestBackupTaskServiceDeleteRestoresScheduleWhenPersistenceFails(t *testing.T) {
ctx := context.Background()
service, targets, tasks := newBackupTaskServiceForTest(t)
if err := targets.Create(ctx, &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "ciphertext", ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatal(err)
}
created, err := service.Create(ctx, BackupTaskUpsertInput{
Name: "delete-failure", Type: "file", Enabled: true, SourcePath: "/srv/data",
StorageTargetID: 1, RetentionDays: 7, Compression: "gzip", MaxBackups: 3,
})
if err != nil {
t.Fatal(err)
}
service.tasks = &failingDeleteBackupTaskRepository{
BackupTaskRepository: tasks,
deleteErr: errors.New("database delete failed"),
}
scheduler := &backupTaskSchedulerStub{}
service.SetScheduler(scheduler)
if _, err := service.Delete(ctx, created.ID); err == nil {
t.Fatal("Delete should fail when persistence fails")
} else {
var appErr *apperror.AppError
if !errors.As(err, &appErr) || appErr.Code != "BACKUP_TASK_DELETE_FAILED" {
t.Fatalf("Delete error = %#v", err)
}
}
if len(scheduler.removedIDs) != 1 || len(scheduler.syncedTasks) != 1 || scheduler.syncedTasks[0].ID != created.ID {
t.Fatalf("scheduler rollback calls = remove:%v sync:%#v", scheduler.removedIDs, scheduler.syncedTasks)
}
stored, err := tasks.FindByID(ctx, created.ID)
if err != nil || stored == nil {
t.Fatalf("task should remain after persistence failure: task=%#v err=%v", stored, err)
}
}
func TestBackupTaskServiceKeepsMaskedPasswordOnUpdate(t *testing.T) {
ctx := context.Background()
service, targets, tasks := newBackupTaskServiceForTest(t)

View File

@@ -21,7 +21,6 @@ type ClusterVersionMonitor struct {
nodeRepo repository.NodeRepository
eventDispatcher EventDispatcher
masterVersion string
background BackgroundRunner
mu sync.Mutex
notified map[uint]time.Time
}
@@ -38,10 +37,6 @@ func (m *ClusterVersionMonitor) SetEventDispatcher(dispatcher EventDispatcher) {
m.eventDispatcher = dispatcher
}
func (m *ClusterVersionMonitor) SetBackgroundRunner(runner BackgroundRunner) {
m.background = runner
}
// Start 启动后台扫描。ctx 取消时退出。
// scanInterval 建议 30 分钟resetInterval 建议 24 小时。
func (m *ClusterVersionMonitor) Start(ctx context.Context, scanInterval, resetInterval time.Duration) {
@@ -52,19 +47,19 @@ func (m *ClusterVersionMonitor) Start(ctx context.Context, scanInterval, resetIn
resetInterval = 24 * time.Hour
}
// 启动立即跑一次,让控制台尽快看到
startBackgroundMonitor(m.background, ctx, func(runCtx context.Context) {
m.scan(runCtx, resetInterval)
go func() {
m.scan(ctx, resetInterval)
ticker := time.NewTicker(scanInterval)
defer ticker.Stop()
for {
select {
case <-runCtx.Done():
case <-ctx.Done():
return
case <-ticker.C:
m.scan(runCtx, resetInterval)
m.scan(ctx, resetInterval)
}
}
})
}()
}
func (m *ClusterVersionMonitor) scan(ctx context.Context, resetInterval time.Duration) {

View File

@@ -45,7 +45,6 @@ func newDashboardNotificationTestDeps(t *testing.T) (*DashboardService, *Notific
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
closeTestDatabase(t, db)
tasks := repository.NewBackupTaskRepository(db)
records := repository.NewBackupRecordRepository(db)
targets := repository.NewStorageTargetRepository(db)

View File

@@ -34,7 +34,6 @@ type DashboardService struct {
targets repository.StorageTargetRepository
nodes repository.NodeRepository
masterVersion string
background BackgroundRunner
// slaMonitor 内部跟踪已告警的违约任务,避免每次扫描重复派发事件
slaNotified map[uint]time.Time
slaMu sync.Mutex
@@ -44,10 +43,6 @@ func NewDashboardService(tasks repository.BackupTaskRepository, records reposito
return &DashboardService{tasks: tasks, records: records, targets: targets, slaNotified: map[uint]time.Time{}}
}
func (s *DashboardService) SetBackgroundRunner(runner BackgroundRunner) {
s.background = runner
}
// SetClusterDependencies 注入节点仓储与 Master 版本,启用集群概览。
func (s *DashboardService) SetClusterDependencies(nodes repository.NodeRepository, masterVersion string) {
s.nodes = nodes
@@ -566,18 +561,18 @@ func (s *DashboardService) StartSLAMonitor(ctx context.Context, dispatcher Event
if resetInterval <= 0 {
resetInterval = 6 * time.Hour
}
startBackgroundMonitor(s.background, ctx, func(runCtx context.Context) {
ticker := time.NewTicker(scanInterval)
ticker := time.NewTicker(scanInterval)
go func() {
defer ticker.Stop()
for {
select {
case <-runCtx.Done():
case <-ctx.Done():
return
case <-ticker.C:
s.scanAndDispatchSLA(runCtx, dispatcher, resetInterval)
s.scanAndDispatchSLA(ctx, dispatcher, resetInterval)
}
}
})
}()
}
// scanAndDispatchSLA 执行一次 SLA 违约扫描并按需派发事件。

View File

@@ -27,7 +27,6 @@ func TestGoogleDriveOAuthServiceStartAndComplete(t *testing.T) {
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
closeTestDatabase(t, db)
sessions := repository.NewOAuthSessionRepository(db)
service := NewGoogleDriveOAuthService(sessions, codec.New("encryption-secret"))
service.now = func() time.Time { return time.Date(2026, 3, 7, 0, 0, 0, 0, time.UTC) }

View File

@@ -18,19 +18,14 @@ import (
// InstallTokenService 负责一次性安装令牌的创建/消费/校验。
type InstallTokenService struct {
repo repository.AgentInstallTokenRepository
nodeRepo repository.NodeRepository
background BackgroundRunner
repo repository.AgentInstallTokenRepository
nodeRepo repository.NodeRepository
}
func NewInstallTokenService(repo repository.AgentInstallTokenRepository, nodeRepo repository.NodeRepository) *InstallTokenService {
return &InstallTokenService{repo: repo, nodeRepo: nodeRepo}
}
func (s *InstallTokenService) SetBackgroundRunner(runner BackgroundRunner) {
s.background = runner
}
// InstallTokenInput 生成一次性安装令牌的输入。
type InstallTokenInput struct {
NodeID uint
@@ -252,18 +247,18 @@ func (s *InstallTokenService) StartGC(ctx context.Context, interval time.Duratio
if interval <= 0 {
interval = time.Hour
}
startBackgroundMonitor(s.background, ctx, func(runCtx context.Context) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-runCtx.Done():
case <-ctx.Done():
return
case <-ticker.C:
_, _ = s.repo.DeleteExpiredBefore(runCtx, time.Now().UTC().Add(-7*24*time.Hour))
_, _ = s.repo.DeleteExpiredBefore(ctx, time.Now().UTC().Add(-7*24*time.Hour))
}
}
})
}()
}
func (s *InstallTokenService) validate(in InstallTokenInput) error {

View File

@@ -22,7 +22,6 @@ func openInstallTokenTestDB(t *testing.T) *gorm.DB {
if err != nil {
t.Fatalf("open: %v", err)
}
closeTestDatabase(t, db)
if err := db.AutoMigrate(&model.AgentInstallToken{}, &model.Node{}); err != nil {
t.Fatalf("migrate: %v", err)
}

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