mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-05 15:37:03 +08:00
feat: 解决 CDC 去重、集中备份与首次初始化问题 (#105)
实现 CDC 内容寻址仓库、远程 Agent 中央中转备份与首次初始化体验,并补充安全校验、测试及双语文档。 Closes #94 Closes #101 Closes #104
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Button, Divider, Drawer, Input, InputNumber, Select, Space, Steps, Switch, Typography, Grid } from '@arco-design/web-react'
|
||||
import { IconDelete, IconPlus } from '@arco-design/web-react/icon'
|
||||
import { IconDelete, IconPlus } from '../icons'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { CronInput } from '../CronInput'
|
||||
import type { StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
@@ -9,6 +9,8 @@ import type { NodeSummary } from '../../types/nodes'
|
||||
import { DatabasePicker } from '../common/DatabasePicker'
|
||||
import { DirectoryPicker } from '../common/DirectoryPicker'
|
||||
import { StorageTargetFormDrawer } from '../storage-targets/StorageTargetFormDrawer'
|
||||
import { StorageTargetName } from '../storage-targets/StorageTargetName'
|
||||
import { SourceServerSelector } from './SourceServerSelector'
|
||||
import {
|
||||
backupCompressionOptions,
|
||||
backupTaskTypeOptions,
|
||||
@@ -168,7 +170,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
return 0
|
||||
})
|
||||
return sorted.map((item) => ({
|
||||
label: item.starred ? `★ ${item.name}` : item.name,
|
||||
label: <StorageTargetName name={item.name} starred={item.starred} />,
|
||||
value: item.id,
|
||||
disabled: !item.enabled,
|
||||
}))
|
||||
@@ -176,21 +178,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 }))
|
||||
}
|
||||
@@ -251,6 +238,12 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
if (validPaths.length === 0 && !value.sourcePath.trim()) {
|
||||
return '请输入至少一个源路径'
|
||||
}
|
||||
if (value.backupMode === 'repository' && (((value.nodeId ?? 0) > 0 && value.nodeId !== localNodeId) || value.nodePoolTag?.trim())) {
|
||||
return 'CDC 仓库模式当前仅支持 Master 本机执行'
|
||||
}
|
||||
if (value.backupMode === 'repository' && value.replicationTargetIds.length > 0) {
|
||||
return 'CDC 仓库模式请直接多选存储目标,不能使用对象级副本复制'
|
||||
}
|
||||
}
|
||||
if (isSQLiteBackupTask(value.type) && !value.dbPath.trim()) {
|
||||
return '请输入 SQLite 数据库路径'
|
||||
@@ -306,33 +299,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: '' } : { 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 })}
|
||||
/>
|
||||
<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 })} />
|
||||
@@ -587,6 +573,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 }}>
|
||||
远程源服务器会直传 S3、WebDAV 等网络存储;本地磁盘目标启用 Master 中转后,文件经 Agent 认证 API 流式写入中央目录。跨公网部署请为 Master 配置 HTTPS。
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>压缩策略</Typography.Text>
|
||||
@@ -600,8 +591,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
options={[
|
||||
{ label: '全量备份', value: 'full' },
|
||||
{ label: '差异备份(仅文件、本机)', value: 'differential' },
|
||||
{ label: 'CDC 去重仓库(仅文件、本机)', value: 'repository' },
|
||||
]}
|
||||
onChange={(value) => updateDraft({ backupMode: value as BackupMode })}
|
||||
onChange={(value) => updateDraft(value === 'repository'
|
||||
? { backupMode: value as BackupMode, nodeId: 0, nodePoolTag: '', replicationTargetIds: [] }
|
||||
: { backupMode: value as BackupMode })}
|
||||
/>
|
||||
{draft.backupMode === 'differential' && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
@@ -618,6 +612,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{draft.backupMode === 'repository' && (
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}>
|
||||
文件按内容边界切块并写入全局分块池;相同数据跨文件、跨快照只上传一次。新块会合并为 pack,恢复时通过索引按需读取。当前版本采用单写者索引,因此固定在 Master 本机执行;需要多副本时请直接多选上方存储目标。
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -736,10 +735,13 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
value={draft.replicationTargetIds}
|
||||
placeholder="选择副本目标(不选 = 不启用复制)"
|
||||
options={storageTargetOptions.filter((opt) => !(draft.storageTargetIds ?? []).includes(opt.value as number))}
|
||||
disabled={draft.backupMode === 'repository'}
|
||||
onChange={(values: number[]) => updateDraft({ replicationTargetIds: values })}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
备份成功后自动镜像到副本存储。满足 3-2-1 规则:至少 2 份副本、至少 1 份异地。建议选不同 provider 的目标。
|
||||
{draft.backupMode === 'repository'
|
||||
? 'CDC 仓库包含共享 pack 与索引,不能只复制单个快照对象;请在“存储目标”中直接多选以生成完整仓库副本。'
|
||||
: '备份成功后自动镜像到副本存储。满足 3-2-1 规则:至少 2 份副本、至少 1 份异地。建议选不同 provider 的目标。'}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Input, Message, Modal, Space, Spin, Tree, Typography, Empty } from '@arco-design/web-react'
|
||||
import { IconFolder, IconFile, IconFolderAdd } from '@arco-design/web-react/icon'
|
||||
import { IconFolder, IconFile, IconFolderAdd } from '../icons'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { listNodeDirectory } from '../../services/nodes'
|
||||
import type { DirEntry } from '../../types/nodes'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Button, Drawer, Empty, Notification, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconNotification } from '@arco-design/web-react/icon'
|
||||
import { IconNotification } from '../icons'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEventStream, type SystemEvent } from '../../hooks/useEventStream'
|
||||
import { useEventStore } from '../../stores/events'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Empty, Input, Modal, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconSearch } from '@arco-design/web-react/icon'
|
||||
import { IconSearch } from '../icons'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { globalSearch, type SearchKind, type SearchResult, type SearchResultItem } from '../../services/search'
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Select } from '@arco-design/web-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { languageOptions, normalizeLanguage, setApplicationLanguage, type SupportedLanguage } from '../../i18n'
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage)
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label={t('auth.language')}
|
||||
value={currentLanguage}
|
||||
options={languageOptions}
|
||||
style={{ width: 120 }}
|
||||
onChange={(value) => void setApplicationLanguage(value as SupportedLanguage)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
export function BackupServerIllustration(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="320"
|
||||
height="320"
|
||||
viewBox="0 0 320 320"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="160" cy="160" r="120" fill="white" fillOpacity="0.05">
|
||||
<animate attributeName="r" values="115;125;115" dur="4s" repeatCount="indefinite" />
|
||||
<animate attributeName="fill-opacity" values="0.03;0.08;0.03" dur="4s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="160" cy="160" r="80" fill="white" fillOpacity="0.1">
|
||||
<animate attributeName="r" values="75;85;75" dur="3s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 0,-8; 0,0" dur="5s" repeatCount="indefinite" />
|
||||
<path d="M120 120C120 111.163 137.909 104 160 104C182.091 104 200 111.163 200 120V144C200 152.837 182.091 160 160 160C137.909 160 120 152.837 120 144V120Z" fill="white" fillOpacity="0.95" />
|
||||
<ellipse cx="160" cy="120" rx="40" ry="16" fill="white" />
|
||||
<path d="M120 152C120 143.163 137.909 136 160 136C182.091 136 200 143.163 200 152V176C200 184.837 182.091 192 160 192C137.909 192 120 184.837 120 176V152Z" fill="white" fillOpacity="0.75" />
|
||||
<ellipse cx="160" cy="152" rx="40" ry="16" fill="white" fillOpacity="0.9" />
|
||||
<path d="M120 184C120 175.163 137.909 168 160 168C182.091 168 200 175.163 200 184V208C200 216.837 182.091 224 160 224C137.909 224 120 216.837 120 208V184Z" fill="white" fillOpacity="0.5" />
|
||||
<ellipse cx="160" cy="184" rx="40" ry="16" fill="white" fillOpacity="0.6" />
|
||||
|
||||
<g fill="var(--color-primary-6, #165dff)">
|
||||
<circle cx="140" cy="120" r="4">
|
||||
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="0s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="140" cy="152" r="4">
|
||||
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="0.6s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="140" cy="184" r="4">
|
||||
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="1.2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
<path d="M160 120V152V184" stroke="var(--color-primary-6, #165dff)" strokeWidth="2" strokeDasharray="4 4" opacity="0.6">
|
||||
<animate attributeName="stroke-dashoffset" from="16" to="0" dur="1s" repeatCount="indefinite" />
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export {
|
||||
IconBook,
|
||||
IconCheckCircle,
|
||||
IconCloud,
|
||||
IconCloudDownload,
|
||||
IconCommand,
|
||||
IconCopy,
|
||||
IconDashboard,
|
||||
IconDelete,
|
||||
IconDesktop,
|
||||
IconDown,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconFile,
|
||||
IconFilePdf,
|
||||
IconFolder,
|
||||
IconFolderAdd,
|
||||
IconHistory,
|
||||
IconInfoCircle,
|
||||
IconList,
|
||||
IconLock,
|
||||
IconMenuFold,
|
||||
IconMenuUnfold,
|
||||
IconMore,
|
||||
IconNotification,
|
||||
IconPlus,
|
||||
IconPoweroff,
|
||||
IconRefresh,
|
||||
IconSafe,
|
||||
IconSave,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconStarFill,
|
||||
IconStorage,
|
||||
IconUser,
|
||||
} from '@arco-design/web-react/icon'
|
||||
|
||||
export { BackupServerIllustration } from './BackupServerIllustration'
|
||||
@@ -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)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { StorageTargetName } from './StorageTargetName'
|
||||
|
||||
describe('StorageTargetName', () => {
|
||||
it('uses an SVG icon for starred targets without character symbols', () => {
|
||||
const { container } = render(<StorageTargetName name="Central storage" starred />)
|
||||
|
||||
expect(screen.getByText('Central storage')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Central storage,已收藏')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
expect(container.textContent).not.toContain(String.fromCodePoint(0x2605))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IconStarFill } from '../icons'
|
||||
|
||||
interface StorageTargetNameProps {
|
||||
name: string
|
||||
starred?: boolean
|
||||
}
|
||||
|
||||
export function StorageTargetName({ name, starred = false }: StorageTargetNameProps) {
|
||||
return (
|
||||
<span
|
||||
aria-label={starred ? `${name},已收藏` : undefined}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
{starred ? <IconStarFill /> : null}
|
||||
<span>{name}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user