feat(cluster): 支持远程源服务器集中备份

为 Master 本地磁盘增加可选流式中转,远程 Agent 可直接把产物写入中央存储并通过反向通道恢复。

持久化并校验传输模式,兼容既有 Agent 本机磁盘目标,补齐鉴权、完整性、配额、访问保护、前端配置及双向链路测试。

Closes #101
This commit is contained in:
Awuqing
2026-08-07 05:54:34 +08:00
parent 05dd1baa61
commit cc50637b4b
28 changed files with 1012 additions and 180 deletions
@@ -280,6 +280,10 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
{ label: '文件名', value: record.fileName || '-' },
{ label: '文件大小', value: formatBytes(record.fileSize) },
{ label: '存储路径', value: record.storagePath || '-' },
...(record.storageTransferMode ? [{
label: '传输路径',
value: record.storageTransferMode === 'master_relay' ? 'Master 流式中转' : 'Agent 直传',
}] : []),
{ label: '开始时间', value: formatDateTime(record.startedAt) },
{ label: '完成时间', value: formatDateTime(record.completedAt) },
{ label: '耗时', value: formatDuration(record.durationSeconds) },
@@ -316,14 +320,16 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
</Button>
)}
</Space>
{record.storageUploadResults && record.storageUploadResults.length > 1 && (
{record.storageUploadResults && (record.storageUploadResults.length > 1 || record.storageUploadResults.some((result) => result.transferMode)) && (
<div>
<Typography.Title heading={6}></Typography.Title>
<Descriptions
column={1}
data={record.storageUploadResults.map((r: StorageUploadResultItem) => ({
label: r.storageTargetName,
value: r.status === 'success' ? '上传成功' : `上传失败: ${r.error || '未知错误'}`,
value: r.status === 'success'
? `上传成功${r.transferMode === 'master_relay' ? ' · Master 流式中转' : r.transferMode === 'direct' ? ' · Agent 直传' : ''}`
: `上传失败: ${r.error || '未知错误'}`,
}))}
/>
</div>
@@ -9,6 +9,7 @@ import type { NodeSummary } from '../../types/nodes'
import { DatabasePicker } from '../common/DatabasePicker'
import { DirectoryPicker } from '../common/DirectoryPicker'
import { StorageTargetFormDrawer } from '../storage-targets/StorageTargetFormDrawer'
import { SourceServerSelector } from './SourceServerSelector'
import {
backupCompressionOptions,
backupTaskTypeOptions,
@@ -176,21 +177,6 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
[storageTargets],
)
// 执行节点选项:本地节点显示 "本机 (local)",远程节点带状态后缀
const nodeOptions = useMemo(() => {
const list = nodes ?? []
return [
{ label: '本机 (Master)', value: 0 },
...list
.filter((item) => !item.isLocal)
.map((item) => ({
label: `${item.name}${item.status === 'online' ? '' : '(离线)'}`,
value: item.id,
disabled: item.status !== 'online',
})),
]
}, [nodes])
function updateDraft(patch: Partial<BackupTaskPayload>) {
setDraft((current) => ({ ...current, ...patch }))
}
@@ -312,42 +298,26 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
<Typography.Text></Typography.Text>
<Select value={draft.type} options={backupTaskTypeOptions as unknown as { label: string; value: string }[]} onChange={(value) => updateTaskType(value as BackupTaskType)} />
</div>
<div>
<Typography.Text></Typography.Text>
<Select
value={draft.nodeId ?? 0}
options={nodeOptions}
onChange={(value) => {
const nodeId = Number(value ?? 0)
// 固定节点与节点池互斥:切到固定节点时清空 NodePoolTag
updateDraft(nodeId > 0
? {
nodeId,
nodePoolTag: '',
backupMode: nodeId !== localNodeId && draft.backupMode === 'repository' ? 'full' : draft.backupMode,
}
: { nodeId })
}}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
/"节点管理" Agent
</Typography.Paragraph>
</div>
<div>
<Typography.Text></Typography.Text>
<Input
placeholder="填写标签后从节点池动态调度(与固定节点互斥)"
value={draft.nodePoolTag ?? ''}
disabled={(draft.nodeId ?? 0) > 0}
onChange={(value) => updateDraft({
nodePoolTag: value,
backupMode: value.trim() && draft.backupMode === 'repository' ? 'full' : draft.backupMode,
})}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
"本机 / 未指定" Labels tag 线
</Typography.Paragraph>
</div>
<SourceServerSelector
nodeId={draft.nodeId ?? 0}
nodePoolTag={draft.nodePoolTag ?? ''}
localNodeId={localNodeId}
nodes={nodes}
onNodeChange={(nodeId) => {
// 固定源服务器与服务器池互斥;CDC 仓库仍固定在 Master 单写者。
updateDraft(nodeId > 0
? {
nodeId,
nodePoolTag: '',
backupMode: nodeId !== localNodeId && draft.backupMode === 'repository' ? 'full' : draft.backupMode,
}
: { nodeId })
}}
onNodePoolTagChange={(value) => updateDraft({
nodePoolTag: value,
backupMode: value.trim() && draft.backupMode === 'repository' ? 'full' : draft.backupMode,
})}
/>
<div>
<Typography.Text>Cron </Typography.Text>
<CronInput value={draft.cronExpr} onChange={(value) => updateDraft({ cronExpr: value })} />
@@ -602,6 +572,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
</Button>
)}
</Space>
{((draft.nodeId ?? 0) > 0 && draft.nodeId !== localNodeId) || draft.nodePoolTag?.trim() ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
S3WebDAV Master Agent API Master HTTPS
</Typography.Paragraph>
) : null}
</div>
<div>
<Typography.Text></Typography.Text>
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { NodeSummary } from '../../types/nodes'
import { buildSourceServerOptions } from './SourceServerSelector'
function node(id: number, name: string, status: NodeSummary['status'], isLocal = false): NodeSummary {
return {
id,
name,
status,
isLocal,
hostname: '',
ipAddress: '',
os: '',
arch: '',
agentVersion: '',
lastSeen: '',
createdAt: '',
}
}
describe('buildSourceServerOptions', () => {
it('keeps Master first and disables offline remote sources', () => {
const options = buildSourceServerOptions([
node(1, 'local', 'online', true),
node(2, 'source-b', 'online'),
node(3, 'source-c', 'offline'),
])
expect(options).toEqual([
{ label: 'Master 本机', value: 0, disabled: false },
{ label: 'source-b', value: 2, disabled: false },
{ label: 'source-c(离线)', value: 3, disabled: true },
])
})
})
@@ -0,0 +1,57 @@
import { Input, Select, Typography } from '@arco-design/web-react'
import { useMemo } from 'react'
import type { NodeSummary } from '../../types/nodes'
interface SourceServerSelectorProps {
nodeId: number
nodePoolTag: string
localNodeId?: number
nodes?: NodeSummary[]
onNodeChange: (nodeId: number) => void
onNodePoolTagChange: (tag: string) => void
}
export function buildSourceServerOptions(nodes: NodeSummary[] = []) {
return [
{ label: 'Master 本机', value: 0, disabled: false },
...nodes
.filter((node) => !node.isLocal)
.map((node) => ({
label: `${node.name}${node.status === 'online' ? '' : '(离线)'}`,
value: node.id,
disabled: node.status !== 'online',
})),
]
}
export function SourceServerSelector({ nodeId, nodePoolTag, localNodeId, nodes, onNodeChange, onNodePoolTagChange }: SourceServerSelectorProps) {
const options = useMemo(() => buildSourceServerOptions(nodes), [nodes])
const selectedNode = nodes?.find((node) => node.id === nodeId)
const isRemote = nodeId > 0 && nodeId !== localNodeId
return (
<>
<div>
<Typography.Text></Typography.Text>
<Select value={nodeId} options={options} onChange={(value) => onNodeChange(Number(value ?? 0))} />
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
{isRemote
? `源路径与数据库在 ${selectedNode?.name ?? '远程服务器'} 上解析,由 Agent 就地生成备份。网络存储由 Agent 直传;启用 Master 中转的本地磁盘目标会通过认证连接写入中央目录。`
: '源路径与数据库在 Master 本机解析。要集中备份其他服务器,请先在“节点管理”安装 Agent,再在这里选择对应源服务器。'}
</Typography.Paragraph>
</div>
<div>
<Typography.Text></Typography.Text>
<Input
placeholder="按标签从在线源服务器中动态选择(与固定源服务器互斥)"
value={nodePoolTag}
disabled={nodeId > 0}
onChange={onNodePoolTagChange}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
Master Labels 线 Agent
</Typography.Paragraph>
</div>
</>
)
}
@@ -16,7 +16,14 @@ interface StorageTargetFormDrawerProps {
}
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
return { name: '', type, description: '', enabled: true, config: {}, quotaBytes: 0 }
return {
name: '',
type,
description: '',
enabled: true,
config: type === 'local_disk' ? { masterRelay: true } : {},
quotaBytes: 0,
}
}
export function StorageTargetFormDrawer({
@@ -207,7 +214,8 @@ export function StorageTargetFormDrawer({
return label.toLowerCase().includes(input.toLowerCase())
}}
onChange={(value) => {
setDraft((c) => ({ ...c, type: value as string, config: {} }))
const config: StorageTargetPayload['config'] = value === 'local_disk' ? { masterRelay: true } : {}
setDraft((c) => ({ ...c, type: value as string, config }))
setTestResult(null)
}}
>
@@ -4,8 +4,9 @@ import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel } from './field
describe('storage target field config', () => {
it('returns local disk field config', () => {
const fields = getStorageTargetFieldConfigs('local_disk')
expect(fields).toHaveLength(1)
expect(fields).toHaveLength(2)
expect(fields[0]?.key).toBe('basePath')
expect(fields[1]).toMatchObject({ key: 'masterRelay', type: 'switch' })
})
it('returns readable type labels', () => {
@@ -7,6 +7,7 @@ import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/st
const BUILTIN_FIELD_CONFIG: Record<string, StorageTargetFieldConfig[]> = {
local_disk: [
{ key: 'basePath', label: '基础目录', type: 'input', required: true, placeholder: '/data/backups', description: 'BackupX 将在该目录下创建和管理备份文件。' },
{ key: 'masterRelay', label: '远程备份经 Master 中转', type: 'switch', description: '开启后,远程 Agent 会把产物流式传给 Master 并写入上述目录;关闭则沿用 Agent 本机目录。' },
],
s3: [
{ key: 'endpoint', label: 'Endpoint', type: 'input', required: true, placeholder: 'https://s3.amazonaws.com' },
+2
View File
@@ -21,6 +21,7 @@ export interface BackupRecordSummary {
fileSize: number
checksum: string
storagePath: string
storageTransferMode?: 'direct' | 'master_relay'
durationSeconds: number
errorMessage: string
startedAt: string
@@ -49,6 +50,7 @@ export interface StorageUploadResultItem {
status: 'success' | 'failed'
storagePath?: string
fileSize?: number
transferMode?: 'direct' | 'master_relay'
error?: string
}