mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-22 08:43:02 +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>
|
||||
|
||||
|
||||
35
web/src/components/backup-tasks/SourceServerSelector.test.ts
Normal file
35
web/src/components/backup-tasks/SourceServerSelector.test.ts
Normal file
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
57
web/src/components/backup-tasks/SourceServerSelector.tsx
Normal file
57
web/src/components/backup-tasks/SourceServerSelector.tsx
Normal file
@@ -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'
|
||||
|
||||
18
web/src/components/common/LanguageSwitcher.tsx
Normal file
18
web/src/components/common/LanguageSwitcher.tsx
Normal file
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
50
web/src/components/icons/BackupServerIllustration.tsx
Normal file
50
web/src/components/icons/BackupServerIllustration.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
38
web/src/components/icons/index.ts
Normal file
38
web/src/components/icons/index.ts
Normal file
@@ -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))
|
||||
})
|
||||
})
|
||||
18
web/src/components/storage-targets/StorageTargetName.tsx
Normal file
18
web/src/components/storage-targets/StorageTargetName.tsx
Normal file
@@ -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' },
|
||||
|
||||
21
web/src/i18n.test.ts
Normal file
21
web/src/i18n.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import i18n, { normalizeLanguage, setApplicationLanguage } from './i18n'
|
||||
|
||||
describe('application language', () => {
|
||||
afterEach(async () => {
|
||||
await setApplicationLanguage('zh-CN')
|
||||
})
|
||||
|
||||
it('normalizes supported English variants', () => {
|
||||
expect(normalizeLanguage('en')).toBe('en-US')
|
||||
expect(normalizeLanguage('en-GB')).toBe('en-US')
|
||||
expect(normalizeLanguage('zh-CN')).toBe('zh-CN')
|
||||
})
|
||||
|
||||
it('persists the language selected before login', async () => {
|
||||
await setApplicationLanguage('en-US')
|
||||
|
||||
expect(localStorage.getItem('backupx-language')).toBe('en-US')
|
||||
expect(document.documentElement.lang).toBe('en-US')
|
||||
expect(i18n.t('auth.setupTitle')).toBe('System setup')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,22 @@ import { initReactI18next } from 'react-i18next'
|
||||
import zhCN from './locales/zh-CN.json'
|
||||
import enUS from './locales/en-US.json'
|
||||
|
||||
const savedLanguage = localStorage.getItem('backupx-language') || 'zh-CN'
|
||||
export type SupportedLanguage = 'zh-CN' | 'en-US'
|
||||
|
||||
export const languageOptions: Array<{ label: string; value: SupportedLanguage }> = [
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' },
|
||||
]
|
||||
|
||||
export function normalizeLanguage(value?: string | null): SupportedLanguage {
|
||||
return value?.toLowerCase().startsWith('en') ? 'en-US' : 'zh-CN'
|
||||
}
|
||||
|
||||
const savedLanguage = normalizeLanguage(typeof window === 'undefined' ? null : window.localStorage.getItem('backupx-language'))
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = savedLanguage
|
||||
}
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
@@ -17,4 +32,14 @@ i18n.use(initReactI18next).init({
|
||||
},
|
||||
})
|
||||
|
||||
export async function setApplicationLanguage(language: SupportedLanguage) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('backupx-language', language)
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = language
|
||||
}
|
||||
await i18n.changeLanguage(language)
|
||||
}
|
||||
|
||||
export default i18n
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
IconDesktop,
|
||||
IconList,
|
||||
IconFilePdf,
|
||||
} from '@arco-design/web-react/icon'
|
||||
} from '../components/icons'
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
IconDashboard,
|
||||
IconInfoCircle,
|
||||
IconPoweroff,
|
||||
} from '@arco-design/web-react/icon';
|
||||
} from '../components/icons';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
@@ -40,7 +40,62 @@
|
||||
"oldPassword": "Old Password",
|
||||
"newPassword": "New Password",
|
||||
"loginTitle": "Sign in to BackupX",
|
||||
"loginSubtitle": "Linux Server Backup Manager"
|
||||
"loginSubtitle": "Linux Server Backup Manager",
|
||||
"language": "Language",
|
||||
"bannerTitle": "Protect your data",
|
||||
"bannerSubtitle": "Secure and reliable server backup management",
|
||||
"setupStatusTitle": "Connect to BackupX",
|
||||
"checkingStatus": "Checking system initialization status...",
|
||||
"statusErrorTitle": "Unable to check initialization status",
|
||||
"statusErrorDescription": "The web console could not reach the BackupX setup API. Confirm that the service is running, then retry.",
|
||||
"retry": "Retry",
|
||||
"setupTitle": "System setup",
|
||||
"welcomeTitle": "Welcome back",
|
||||
"setupSubtitle": "Create the first administrator account.",
|
||||
"welcomeSubtitle": "Enter an administrator account to open the console.",
|
||||
"displayName": "Display name",
|
||||
"displayNamePlaceholder": "Administrator display name",
|
||||
"usernamePlaceholder": "Administrator username",
|
||||
"passwordPlaceholder": "Password",
|
||||
"setupPasswordPlaceholder": "At least 8 characters",
|
||||
"setupSubmit": "Create administrator and sign in",
|
||||
"setupSuccess": "Setup complete. Opening the console...",
|
||||
"loginSuccess": "Signed in",
|
||||
"credentialsRequired": "Enter your username and password first",
|
||||
"mfaCode": "Verification or recovery code",
|
||||
"mfaCodePlaceholder": "TOTP, recovery, email, or SMS code",
|
||||
"sendEmailCode": "Send email code",
|
||||
"sendSmsCode": "Send SMS code",
|
||||
"emailCodeSent": "Email verification code sent",
|
||||
"smsCodeSent": "SMS verification code sent",
|
||||
"usePasskey": "Use passkey",
|
||||
"trustDevice": "Trust this device for 30 days",
|
||||
"verifyAndLogin": "Verify and sign in",
|
||||
"requestFailed": "The request failed. Please try again.",
|
||||
"validation": {
|
||||
"displayNameRequired": "Enter a display name",
|
||||
"usernameRequired": "Enter a username",
|
||||
"usernameLength": "Username must contain at least 3 characters",
|
||||
"passwordRequired": "Enter a password",
|
||||
"passwordLength": "Password must contain at least 8 characters",
|
||||
"mfaRequired": "Enter a verification or recovery code",
|
||||
"mfaLength": "Code must contain 6 to 32 characters"
|
||||
},
|
||||
"errors": {
|
||||
"AUTH_INVALID_CREDENTIALS": "Invalid username or password",
|
||||
"AUTH_WRONG_PASSWORD": "Invalid username or password",
|
||||
"AUTH_USER_DISABLED": "This account is disabled",
|
||||
"AUTH_RATE_LIMITED": "Too many attempts. Please try again later.",
|
||||
"AUTH_2FA_REQUIRED": "Complete two-factor authentication to continue",
|
||||
"AUTH_2FA_INVALID": "The verification or recovery code is invalid",
|
||||
"AUTH_SETUP_DISABLED": "BackupX is already initialized. Sign in instead.",
|
||||
"AUTH_USERNAME_EXISTS": "This username already exists",
|
||||
"AUTH_EMAIL_OTP_DISABLED": "Email verification is not enabled",
|
||||
"AUTH_SMS_OTP_DISABLED": "SMS verification is not enabled",
|
||||
"AUTH_EMAIL_REQUIRED": "No email address is configured for this account",
|
||||
"AUTH_PHONE_REQUIRED": "No phone number is configured for this account",
|
||||
"AUTH_WEBAUTHN_NOT_ENABLED": "No passkey is configured for this account"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
@@ -40,7 +40,62 @@
|
||||
"oldPassword": "旧密码",
|
||||
"newPassword": "新密码",
|
||||
"loginTitle": "登录 BackupX",
|
||||
"loginSubtitle": "Linux 服务器备份管理系统"
|
||||
"loginSubtitle": "Linux 服务器备份管理系统",
|
||||
"language": "语言",
|
||||
"bannerTitle": "守护您的数据资产",
|
||||
"bannerSubtitle": "安全、可靠的服务器备份管理平台",
|
||||
"setupStatusTitle": "连接 BackupX",
|
||||
"checkingStatus": "正在检查系统初始化状态...",
|
||||
"statusErrorTitle": "无法检查初始化状态",
|
||||
"statusErrorDescription": "Web 控制台无法访问 BackupX 初始化接口。请确认服务已启动,然后重试。",
|
||||
"retry": "重试",
|
||||
"setupTitle": "系统初始化",
|
||||
"welcomeTitle": "欢迎回来",
|
||||
"setupSubtitle": "请创建首个管理员账户以完成初始化。",
|
||||
"welcomeSubtitle": "请输入管理员账户信息登录控制台。",
|
||||
"displayName": "显示名称",
|
||||
"displayNamePlaceholder": "请输入管理员显示名称",
|
||||
"usernamePlaceholder": "请输入管理员用户名",
|
||||
"passwordPlaceholder": "请输入密码",
|
||||
"setupPasswordPlaceholder": "请输入至少 8 位密码",
|
||||
"setupSubmit": "创建管理员并登录",
|
||||
"setupSuccess": "初始化完成,正在进入控制台...",
|
||||
"loginSuccess": "登录成功",
|
||||
"credentialsRequired": "请先输入用户名和密码",
|
||||
"mfaCode": "验证码或恢复码",
|
||||
"mfaCodePlaceholder": "请输入 TOTP、恢复码、邮件或短信验证码",
|
||||
"sendEmailCode": "发送邮件验证码",
|
||||
"sendSmsCode": "发送短信验证码",
|
||||
"emailCodeSent": "邮件验证码已发送",
|
||||
"smsCodeSent": "短信验证码已发送",
|
||||
"usePasskey": "使用通行密钥",
|
||||
"trustDevice": "信任此设备 30 天",
|
||||
"verifyAndLogin": "验证并登录",
|
||||
"requestFailed": "请求失败,请稍后重试",
|
||||
"validation": {
|
||||
"displayNameRequired": "请输入显示名称",
|
||||
"usernameRequired": "请输入用户名",
|
||||
"usernameLength": "用户名至少需要 3 个字符",
|
||||
"passwordRequired": "请输入密码",
|
||||
"passwordLength": "密码至少需要 8 个字符",
|
||||
"mfaRequired": "请输入验证码或恢复码",
|
||||
"mfaLength": "验证码或恢复码需为 6 至 32 个字符"
|
||||
},
|
||||
"errors": {
|
||||
"AUTH_INVALID_CREDENTIALS": "用户名或密码错误",
|
||||
"AUTH_WRONG_PASSWORD": "用户名或密码错误",
|
||||
"AUTH_USER_DISABLED": "该账户已被停用",
|
||||
"AUTH_RATE_LIMITED": "尝试次数过多,请稍后再试",
|
||||
"AUTH_2FA_REQUIRED": "请完成双因素验证后继续",
|
||||
"AUTH_2FA_INVALID": "验证码或恢复码无效",
|
||||
"AUTH_SETUP_DISABLED": "系统已完成初始化,请直接登录",
|
||||
"AUTH_USERNAME_EXISTS": "该用户名已存在",
|
||||
"AUTH_EMAIL_OTP_DISABLED": "邮件验证码未启用",
|
||||
"AUTH_SMS_OTP_DISABLED": "短信验证码未启用",
|
||||
"AUTH_EMAIL_REQUIRED": "该账户未配置邮箱",
|
||||
"AUTH_PHONE_REQUIRED": "该账户未配置手机号",
|
||||
"AUTH_WEBAUTHN_NOT_ENABLED": "该账户未配置通行密钥"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
|
||||
@@ -114,6 +114,7 @@ export function BackupRecordsPage() {
|
||||
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
||||
{record.locked && <Tag color="orange" size="small" bordered>已锁定</Tag>}
|
||||
{record.backupKind === 'differential' && <Tag color="purple" size="small" bordered>差异</Tag>}
|
||||
{record.backupKind === 'repository' && <Tag color="blue" size="small" bordered>CDC</Tag>}
|
||||
</Space>
|
||||
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
||||
{record.checksum && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Avatar, Card, Empty, Grid, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCheckCircle, IconDesktop, IconHistory, IconSafe, IconSave, IconStorage } from '@arco-design/web-react/icon'
|
||||
import { IconCheckCircle, IconDesktop, IconHistory, IconSafe, IconSave, IconStorage } from '../../components/icons'
|
||||
import ReactEChartsCore from 'echarts-for-react/lib/core'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts'
|
||||
|
||||
70
web/src/pages/login/LoginPage.test.tsx
Normal file
70
web/src/pages/login/LoginPage.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { setApplicationLanguage } from '../../i18n'
|
||||
import { LoginPage } from './LoginPage'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchSetupStatus: vi.fn(),
|
||||
login: vi.fn(),
|
||||
setup: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/auth', () => ({
|
||||
beginWebAuthnLogin: vi.fn(),
|
||||
fetchSetupStatus: mocks.fetchSetupStatus,
|
||||
sendLoginOtp: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../stores/auth', () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => selector({
|
||||
status: 'anonymous',
|
||||
login: mocks.login,
|
||||
setup: mocks.setup,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/webauthn', () => ({
|
||||
getWebAuthnAssertion: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('LoginPage initialization', () => {
|
||||
beforeEach(async () => {
|
||||
mocks.fetchSetupStatus.mockReset()
|
||||
mocks.login.mockReset()
|
||||
mocks.setup.mockReset()
|
||||
await act(() => setApplicationLanguage('en-US'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await act(() => setApplicationLanguage('zh-CN'))
|
||||
})
|
||||
|
||||
it('shows the first-administrator form in English', async () => {
|
||||
mocks.fetchSetupStatus.mockResolvedValue({ initialized: false })
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('System setup')).toBeInTheDocument()
|
||||
expect(screen.getByText('Create the first administrator account.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Create administrator and sign in' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not mistake an unreachable fresh install for an initialized system', async () => {
|
||||
mocks.fetchSetupStatus.mockRejectedValue(new Error('connection refused'))
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Unable to check initialization status')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Welcome back')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button, Checkbox, Form, Input, Space, Typography, Message } from '@arco-design/web-react'
|
||||
import { IconCloud, IconLock, IconSafe, IconUser } from '@arco-design/web-react/icon'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BackupServerIllustration, IconCloud, IconLock, IconSafe, IconUser } from '../../components/icons'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { LanguageSwitcher } from '../../components/common/LanguageSwitcher'
|
||||
import { beginWebAuthnLogin, fetchSetupStatus, sendLoginOtp } from '../../services/auth'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { getWebAuthnAssertion } from '../../utils/webauthn'
|
||||
@@ -20,17 +22,8 @@ interface LoginFormValues {
|
||||
rememberDevice?: boolean
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return error.response?.data?.message ?? '请求失败,请稍后重试'
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const doLogin = useAuthStore((state) => state.login)
|
||||
@@ -40,6 +33,25 @@ export function LoginPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [mfaActionLoading, setMfaActionLoading] = useState('')
|
||||
const [twoFactorRequired, setTwoFactorRequired] = useState(false)
|
||||
const [setupStatusFailed, setSetupStatusFailed] = useState(false)
|
||||
const setupStatusRequest = useRef(0)
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const code = error.response?.data?.code
|
||||
const translationKey = code ? `auth.errors.${code}` : ''
|
||||
if (translationKey && i18n.exists(translationKey)) {
|
||||
return t(translationKey)
|
||||
}
|
||||
if (i18n.resolvedLanguage === 'zh-CN' && error.response?.data?.message) {
|
||||
return error.response.data.message
|
||||
}
|
||||
}
|
||||
if (error instanceof Error && i18n.resolvedLanguage === 'zh-CN') {
|
||||
return error.message
|
||||
}
|
||||
return t('auth.requestFailed')
|
||||
}
|
||||
|
||||
function resetTwoFactorPrompt() {
|
||||
if (!twoFactorRequired) {
|
||||
@@ -56,30 +68,36 @@ export function LoginPage() {
|
||||
}
|
||||
}, [authStatus, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await fetchSetupStatus()
|
||||
if (mounted) {
|
||||
setInitialized(result.initialized)
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setInitialized(true)
|
||||
}
|
||||
const loadSetupStatus = useCallback(async () => {
|
||||
const requestID = ++setupStatusRequest.current
|
||||
setInitialized(null)
|
||||
setSetupStatusFailed(false)
|
||||
try {
|
||||
const result = await fetchSetupStatus()
|
||||
if (requestID === setupStatusRequest.current) {
|
||||
setInitialized(result.initialized)
|
||||
}
|
||||
} catch {
|
||||
// Do not guess that an unreachable fresh install is initialized. That
|
||||
// would hide the first-administrator form behind an impossible login.
|
||||
if (requestID === setupStatusRequest.current) {
|
||||
setSetupStatusFailed(true)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadSetupStatus()
|
||||
return () => {
|
||||
setupStatusRequest.current++
|
||||
}
|
||||
}, [loadSetupStatus])
|
||||
|
||||
const handleSetup = async (values: SetupFormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await doSetup(values)
|
||||
Message.success('初始化完成,正在进入控制台')
|
||||
Message.success(t('auth.setupSuccess'))
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
Message.error(resolveErrorMessage(error))
|
||||
@@ -96,7 +114,7 @@ export function LoginPage() {
|
||||
trustedDeviceName: values.rememberDevice ? navigator.userAgent.slice(0, 120) : undefined,
|
||||
})
|
||||
setTwoFactorRequired(false)
|
||||
Message.success('登录成功')
|
||||
Message.success(t('auth.loginSuccess'))
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -116,7 +134,7 @@ export function LoginPage() {
|
||||
function readLoginCredentials(): (LoginFormValues & { username: string; password: string }) | null {
|
||||
const values = loginForm.getFieldsValue()
|
||||
if (!values.username?.trim() || !values.password?.trim()) {
|
||||
Message.error('请先输入用户名和密码')
|
||||
Message.error(t('auth.credentialsRequired'))
|
||||
return null
|
||||
}
|
||||
return {
|
||||
@@ -132,7 +150,7 @@ export function LoginPage() {
|
||||
setMfaActionLoading(channel)
|
||||
try {
|
||||
await sendLoginOtp({ username: values.username, password: values.password, channel })
|
||||
Message.success(channel === 'email' ? '邮件验证码已发送' : '短信验证码已发送')
|
||||
Message.success(channel === 'email' ? t('auth.emailCodeSent') : t('auth.smsCodeSent'))
|
||||
} catch (error) {
|
||||
Message.error(resolveErrorMessage(error))
|
||||
} finally {
|
||||
@@ -156,7 +174,7 @@ export function LoginPage() {
|
||||
trustedDeviceName: navigator.userAgent.slice(0, 120),
|
||||
})
|
||||
setTwoFactorRequired(false)
|
||||
Message.success('登录成功')
|
||||
Message.success(t('auth.loginSuccess'))
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
Message.error(resolveErrorMessage(error))
|
||||
@@ -165,128 +183,124 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = initialized === null
|
||||
? t('auth.setupStatusTitle')
|
||||
: initialized
|
||||
? t('auth.welcomeTitle')
|
||||
: t('auth.setupTitle')
|
||||
const pageSubtitle = initialized === null
|
||||
? setupStatusFailed ? t('auth.statusErrorDescription') : t('auth.checkingStatus')
|
||||
: initialized
|
||||
? t('auth.welcomeSubtitle')
|
||||
: t('auth.setupSubtitle')
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-bg" />
|
||||
<div className="login-container">
|
||||
<div className="login-banner">
|
||||
{/* Background decorative circles for the banner */}
|
||||
<div style={{ position: 'absolute', width: 400, height: 400, borderRadius: '50%', background: 'rgba(255,255,255,0.05)', top: -100, right: -100 }} />
|
||||
<div style={{ position: 'absolute', width: 300, height: 300, borderRadius: '50%', background: 'rgba(255,255,255,0.05)', bottom: -50, left: -50 }} />
|
||||
|
||||
<div className="login-banner-inner">
|
||||
<svg width="320" height="320" viewBox="0 0 320 320" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginBottom: 16 }}>
|
||||
{/* Outer pulsing rings */}
|
||||
<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"/>
|
||||
{/* Layer 1 (Top) */}
|
||||
<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"/>
|
||||
|
||||
{/* Layer 2 (Middle) */}
|
||||
<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"/>
|
||||
<BackupServerIllustration style={{ marginBottom: 16 }} />
|
||||
|
||||
{/* Layer 3 (Bottom) */}
|
||||
<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"/>
|
||||
|
||||
{/* Glowing Dots Output - Animated */}
|
||||
<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>
|
||||
|
||||
{/* Connecting Data Line */}
|
||||
<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>
|
||||
|
||||
<Typography.Title heading={2} style={{ color: 'white', marginTop: 0, marginBottom: 12, fontWeight: 700 }}>
|
||||
守护您的数据资产
|
||||
<Typography.Title heading={2} style={{ color: 'white', marginTop: 0, marginBottom: 12 }}>
|
||||
{t('auth.bannerTitle')}
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.75)', fontSize: 16 }}>
|
||||
安全、可靠、高效的企业级服务器备份管理平台
|
||||
{t('auth.bannerSubtitle')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="login-form-wrapper">
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, borderRadius: 10, background: 'linear-gradient(135deg, var(--color-primary-5) 0%, var(--color-primary-7) 100%)', marginRight: 12 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, borderRadius: 4, background: 'var(--color-primary-6)', marginRight: 12 }}>
|
||||
<IconCloud style={{ fontSize: 20, color: 'white' }} />
|
||||
</div>
|
||||
<Typography.Title heading={4} style={{ margin: 0, fontWeight: 700 }}>
|
||||
<Typography.Title heading={4} style={{ margin: 0 }}>
|
||||
BackupX
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<Typography.Title heading={3} style={{ marginTop: 0, marginBottom: 8, fontWeight: 600 }}>
|
||||
{initialized === false ? '系统初始化' : '欢迎回来'}
|
||||
<Typography.Title heading={3} style={{ marginTop: 0, marginBottom: 8 }}>
|
||||
{pageTitle}
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 14 }}>
|
||||
{initialized === false ? '请设定首个管理员账户以启动系统。' : '请输入管理员账户信息登录控制台。'}
|
||||
{pageSubtitle}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
{initialized === false ? (
|
||||
{initialized === null ? (
|
||||
setupStatusFailed ? (
|
||||
<div>
|
||||
<Typography.Text>{t('auth.statusErrorTitle')}</Typography.Text>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Button type="primary" loading={loading} onClick={() => void loadSetupStatus()}>
|
||||
{t('auth.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">{t('auth.checkingStatus')}</Typography.Text>
|
||||
)
|
||||
) : initialized === false ? (
|
||||
<Form<SetupFormValues> layout="vertical" onSubmit={handleSetup}>
|
||||
<Form.Item field="displayName" label="显示名称" rules={[{ required: true, minLength: 1 }]}>
|
||||
<Input placeholder="请输入显示名称" prefix={<IconUser />} size="large" />
|
||||
<Form.Item field="displayName" label={t('auth.displayName')} rules={[{ required: true, minLength: 1, message: t('auth.validation.displayNameRequired') }]}>
|
||||
<Input autoComplete="name" placeholder={t('auth.displayNamePlaceholder')} prefix={<IconUser />} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item field="username" label="用户名" rules={[{ required: true, minLength: 3 }]}>
|
||||
<Input placeholder="请输入管理员用户名" prefix={<IconUser />} size="large" />
|
||||
<Form.Item field="username" label={t('auth.username')} rules={[
|
||||
{ required: true, message: t('auth.validation.usernameRequired') },
|
||||
{ minLength: 3, message: t('auth.validation.usernameLength') },
|
||||
]}>
|
||||
<Input autoComplete="username" placeholder={t('auth.usernamePlaceholder')} prefix={<IconUser />} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item field="password" label="密码" rules={[{ required: true, minLength: 8 }]}>
|
||||
<Input.Password placeholder="请输入至少 8 位密码" prefix={<IconLock />} size="large" />
|
||||
<Form.Item field="password" label={t('auth.password')} rules={[
|
||||
{ required: true, message: t('auth.validation.passwordRequired') },
|
||||
{ minLength: 8, message: t('auth.validation.passwordLength') },
|
||||
]}>
|
||||
<Input.Password autoComplete="new-password" placeholder={t('auth.setupPasswordPlaceholder')} prefix={<IconLock />} size="large" />
|
||||
</Form.Item>
|
||||
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 8, height: 44, marginTop: 8 }}>
|
||||
初始化并登录
|
||||
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 4, height: 44, marginTop: 8 }}>
|
||||
{t('auth.setupSubmit')}
|
||||
</Button>
|
||||
</Form>
|
||||
) : (
|
||||
<Form<LoginFormValues> form={loginForm} layout="vertical" onSubmit={handleLogin}>
|
||||
<Form.Item field="username" label="用户名" rules={[{ required: true, minLength: 3 }]}>
|
||||
<Input placeholder="请输入用户名" prefix={<IconUser />} size="large" onChange={resetTwoFactorPrompt} />
|
||||
<Form.Item field="username" label={t('auth.username')} rules={[
|
||||
{ required: true, message: t('auth.validation.usernameRequired') },
|
||||
{ minLength: 3, message: t('auth.validation.usernameLength') },
|
||||
]}>
|
||||
<Input autoComplete="username" placeholder={t('auth.usernamePlaceholder')} prefix={<IconUser />} size="large" onChange={resetTwoFactorPrompt} />
|
||||
</Form.Item>
|
||||
<Form.Item field="password" label="密码" rules={[{ required: true, minLength: 8 }]}>
|
||||
<Input.Password placeholder="请输入密码" prefix={<IconLock />} size="large" onChange={resetTwoFactorPrompt} />
|
||||
<Form.Item field="password" label={t('auth.password')} rules={[
|
||||
{ required: true, message: t('auth.validation.passwordRequired') },
|
||||
{ minLength: 8, message: t('auth.validation.passwordLength') },
|
||||
]}>
|
||||
<Input.Password autoComplete="current-password" placeholder={t('auth.passwordPlaceholder')} prefix={<IconLock />} size="large" onChange={resetTwoFactorPrompt} />
|
||||
</Form.Item>
|
||||
{twoFactorRequired && (
|
||||
<>
|
||||
<Form.Item field="twoFactorCode" label="验证码或恢复码" rules={[{ required: true, minLength: 6, maxLength: 32 }]}>
|
||||
<Input placeholder="请输入 TOTP、恢复码、邮件或短信验证码" prefix={<IconSafe />} size="large" maxLength={32} />
|
||||
<Form.Item field="twoFactorCode" label={t('auth.mfaCode')} rules={[
|
||||
{ required: true, message: t('auth.validation.mfaRequired') },
|
||||
{ minLength: 6, maxLength: 32, message: t('auth.validation.mfaLength') },
|
||||
]}>
|
||||
<Input autoComplete="one-time-code" placeholder={t('auth.mfaCodePlaceholder')} prefix={<IconSafe />} size="large" maxLength={32} />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ marginTop: -8, marginBottom: 8 }}>
|
||||
<Button loading={mfaActionLoading === 'email'} onClick={() => void handleSendOTP('email')}>发送邮件验证码</Button>
|
||||
<Button loading={mfaActionLoading === 'sms'} onClick={() => void handleSendOTP('sms')}>发送短信验证码</Button>
|
||||
<Button loading={mfaActionLoading === 'webauthn'} onClick={() => void handleWebAuthnLogin()}>使用通行密钥</Button>
|
||||
<Button loading={mfaActionLoading === 'email'} onClick={() => void handleSendOTP('email')}>{t('auth.sendEmailCode')}</Button>
|
||||
<Button loading={mfaActionLoading === 'sms'} onClick={() => void handleSendOTP('sms')}>{t('auth.sendSmsCode')}</Button>
|
||||
<Button loading={mfaActionLoading === 'webauthn'} onClick={() => void handleWebAuthnLogin()}>{t('auth.usePasskey')}</Button>
|
||||
</Space>
|
||||
<Form.Item field="rememberDevice" triggerPropName="checked">
|
||||
<Checkbox>信任此设备 30 天</Checkbox>
|
||||
<Checkbox>{t('auth.trustDevice')}</Checkbox>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 8, height: 44, marginTop: 16 }}>
|
||||
{twoFactorRequired ? '验证并登录' : '登录'}
|
||||
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 4, height: 44, marginTop: 16 }}>
|
||||
{twoFactorRequired ? t('auth.verifyAndLogin') : t('auth.login')}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Table, Button, Space, Message, Typography, Tag } from '@arco-design/web-react'
|
||||
import { IconCopy, IconDownload, IconRefresh } from '@arco-design/web-react/icon'
|
||||
import { IconCopy, IconDownload, IconRefresh } from '../../components/icons'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@arco-design/web-react'
|
||||
import {
|
||||
IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit, IconMore,
|
||||
} from '@arco-design/web-react/icon'
|
||||
} from '../../components/icons'
|
||||
import type { NodeSummary } from '../../types/nodes'
|
||||
import { listNodes, deleteNode, updateNode, rotateNodeToken } from '../../services/nodes'
|
||||
import { fetchSystemInfo } from '../../services/system'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-design/web-react'
|
||||
import { IconCopy, IconRefresh } from '@arco-design/web-react/icon'
|
||||
import { IconCopy, IconRefresh } from '../../../components/icons'
|
||||
import { fetchScriptPreview } from '../../../services/nodes'
|
||||
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
|
||||
import { buildAgentDownloadCommand, buildAgentInstallCommand, buildEmbeddedAgentInstallCommand } from '../installCommands'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Card, Grid, Message, Select, Space, Statistic, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconDownload, IconRefresh } from '@arco-design/web-react/icon'
|
||||
import { IconDownload, IconRefresh } from '../../components/icons'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { downloadComplianceCSV, fetchComplianceReport } from '../../services/reports'
|
||||
import type { ComplianceReport, ComplianceRisk, ComplianceTaskRow } from '../../types/reports'
|
||||
|
||||
@@ -18,6 +18,7 @@ import { formatBytes } from '../../utils/format'
|
||||
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
import { getStorageTargetTypeLabel } from '../../components/storage-targets/field-config'
|
||||
import { StorageTargetFormDrawer } from '../../components/storage-targets/StorageTargetFormDrawer'
|
||||
import { StorageTargetName } from '../../components/storage-targets/StorageTargetName'
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -224,7 +225,7 @@ export function StorageTargetsPage() {
|
||||
<Space size="large" align="start" style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ marginBottom: 4 }}>
|
||||
{target.starred ? '★ ' : ''}{target.name}
|
||||
<StorageTargetName name={target.name} starred={target.starred} />
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
{getStorageTargetTypeLabel(target.type) && <Tag color="arcoblue" bordered>{getStorageTargetTypeLabel(target.type)}</Tag>}
|
||||
|
||||
@@ -42,48 +42,25 @@ body {
|
||||
.login-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, #111a2c 0%, #1f2d47 100%);
|
||||
background: #111a2c;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-bg::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(52,145,250,0.08) 0%, transparent 70%);
|
||||
top: -300px;
|
||||
right: -200px;
|
||||
}
|
||||
|
||||
.login-bg::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(114,46,209,0.06) 0%, transparent 70%);
|
||||
bottom: -200px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
width: 1000px;
|
||||
max-width: 90vw;
|
||||
min-height: 560px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
z-index: 1;
|
||||
animation: slideUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.login-banner {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, var(--color-primary-6, #165dff) 0%, var(--color-primary-8, #0e42d2) 100%);
|
||||
background: var(--color-primary-6, #165dff);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -24,3 +24,17 @@ Object.defineProperty(window, 'localStorage', {
|
||||
value: storage,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -21,12 +21,13 @@ export interface BackupRecordSummary {
|
||||
fileSize: number
|
||||
checksum: string
|
||||
storagePath: string
|
||||
storageTransferMode?: 'direct' | 'master_relay'
|
||||
durationSeconds: number
|
||||
errorMessage: string
|
||||
startedAt: string
|
||||
completedAt?: string
|
||||
locked: boolean
|
||||
backupKind: 'full' | 'differential'
|
||||
backupKind: 'full' | 'differential' | 'repository'
|
||||
}
|
||||
|
||||
export interface BackupRecordContentEntry {
|
||||
@@ -49,6 +50,7 @@ export interface StorageUploadResultItem {
|
||||
status: 'success' | 'failed'
|
||||
storagePath?: string
|
||||
fileSize?: number
|
||||
transferMode?: 'direct' | 'master_relay'
|
||||
error?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type BackupTaskType = 'file' | 'mysql' | 'sqlite' | 'postgresql' | 'saphana' | 'mongodb'
|
||||
export type BackupTaskStatus = 'idle' | 'running' | 'success' | 'failed'
|
||||
export type BackupCompression = 'gzip' | 'zstd' | 'none'
|
||||
export type BackupMode = 'full' | 'differential'
|
||||
export type BackupMode = 'full' | 'differential' | 'repository'
|
||||
|
||||
export interface BackupTaskSummary {
|
||||
id: number
|
||||
|
||||
Reference in New Issue
Block a user