功能: v2.0.0 企业级备份管理平台 — 11 项核心能力 (#45)

* 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力

围绕"可靠、可验证、可度量、可冗余、可治理、可规模化、可运维、可部署、可感知"的
九大企业级支柱,新增 70+ 文件、14k+ 行代码,全链路测试与类型检查通过。

## 集群能力

- 节点选择器:任务表单支持绑定远程节点,集群场景不再被迫 NodeID=0
- 集群感知恢复:RestoreRecord 独立表 + 节点路由(本机/远程 Agent)+ SSE 日志
- 集群可靠性:命令超时联动备份/恢复记录、离线节点拒绝执行、调度器跳过离线节点、
  数据库发现路由到 Agent、跨节点 local_disk 保护
- 节点级资源配额:Node.MaxConcurrent / BandwidthLimit + per-node semaphore
- Agent 版本感知:ClusterVersionMonitor 定期扫描 + agent_outdated 事件
- Dashboard 集群概览 + 节点性能统计(成功率/字节/平均耗时)

## 企业功能

- 备份验证演练:定时自动校验备份可恢复性(tar/sqlite/mysql/postgres/saphana 5 类格式)
- SLA 监控:RPO 违约后台扫描 + sla_violation 事件 + Dashboard 合规视图
- 3-2-1 备份复制:自动/手动副本镜像 + 跨节点保护
- 存储目标健康监控 + 容量预警(85%)+ 硬配额(超配额拒绝)
- RBAC 三级角色(admin/operator/viewer)+ 前后端权限控制
- API Key 管理(bax_ 前缀 SHA-256 哈希存储 + 过期/启停)
- 事件总线:10+ 事件类型(backup/restore/verify/sla/storage/replication/agent)
- 审计日志高级筛选 + CSV 导出

## 规模化运维

- 任务模板(批量创建 + 变量覆盖)
- 任务批量操作(批量执行/启停/删除)
- 任务依赖链 + DAG 可视化(上游成功触发下游)
- 维护窗口(时段禁止调度)
- 任务标签 + 筛选 + 存储类型/节点/存储维度统计
- 任务配置 JSON 导入/导出(集群迁移 & 灾备)

## 体验 & 可达性

- 实时事件流(SSE)+ 右下角 Toast + 历史抽屉(未读徽章)
- Dashboard 免刷新自动更新(订阅 8 类事件)
- 全局搜索(Ctrl+K,跨任务/记录/存储/节点)
- 任务依赖图(ECharts force 布局 + 状态着色)

## 合规 & 可部署

- K8s/Swarm 健康检查端点(/health liveness + /ready readiness)
- 审计日志 CSV 导出(UTF-8 BOM,Excel 兼容)
- Dashboard 多维统计(按类型/状态/节点/存储)

## 破坏性变更

- POST /backup/records/:id/restore 返回格式变更为 {restoreRecordId, ...}
  (原为同步阻塞,现改为异步返回恢复记录 ID,前端跳转到恢复详情页)
- 恢复日志通过 /restore/records/:id/logs/stream 订阅
- AuthMiddleware 签名变更(新增 apiKeyAuth 参数)

* 修复: CodeQL 安全扫描告警

- 所有 strconv.ParseUint 由 64bit 改为 32bit 位宽,strconv 内置溢出检查
- hashApiKey 参数改名 rawToken 避免 CodeQL 误判为密码哈希(API Key 是 192 位
  高熵 token,使用 bcrypt 会引入不必要的延迟;同时补充安全说明)

* 修复: API Key 哈希改用 HMAC-SHA256 + 应用级 pepper

- 符合 RFC 2104 标准,业界 API token 存储的推荐方案
- 数据库泄漏场景下增加离线反推难度(需同时获取二进制 pepper)
- 规避 CodeQL go/weak-sensitive-data-hashing 对裸 SHA-256 的误判
This commit is contained in:
Wu Qing
2026-04-20 13:04:13 +08:00
committed by GitHub
parent 83bf5ec656
commit 539e9e64c4
120 changed files with 12817 additions and 382 deletions

View File

@@ -1,9 +1,17 @@
import { Alert, Button, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react'
import { Alert, Button, Descriptions, Drawer, Message, Space, Spin, Tag, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { deleteBackupRecord, downloadBackupRecord, getBackupRecord, restoreBackupRecord, streamBackupRecordLogs } from '../../services/backup-records'
import { useNavigate } from 'react-router-dom'
import { deleteBackupRecord, downloadBackupRecord, getBackupRecord, streamBackupRecordLogs } from '../../services/backup-records'
import { getBackupTask } from '../../services/backup-tasks'
import { startRestoreFromBackup } from '../../services/restore-records'
import { startVerifyByRecord } from '../../services/verification-records'
import { useAuthStore } from '../../stores/auth'
import { canWrite } from '../../utils/permissions'
import type { BackupLogEvent, BackupRecordDetail, BackupRecordStatus, StorageUploadResultItem } from '../../types/backup-records'
import type { BackupTaskDetail } from '../../types/backup-tasks'
import { resolveErrorMessage } from '../../utils/error'
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
import { RestoreConfirmModal } from '../restore-records/RestoreConfirmModal'
interface BackupRecordLogDrawerProps {
visible: boolean
@@ -31,12 +39,20 @@ function buildLogText(record: BackupRecordDetail | null, events: BackupLogEvent[
}
export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }: BackupRecordLogDrawerProps) {
const navigate = useNavigate()
const currentUser = useAuthStore((state) => state.user)
const writable = canWrite(currentUser)
const [record, setRecord] = useState<BackupRecordDetail | null>(null)
const [events, setEvents] = useState<BackupLogEvent[]>([])
const [loading, setLoading] = useState(false)
const [acting, setActing] = useState(false)
const [error, setError] = useState('')
const [streamError, setStreamError] = useState('')
const [restoreModalVisible, setRestoreModalVisible] = useState(false)
const [restoreTask, setRestoreTask] = useState<BackupTaskDetail | null>(null)
const [restoreLoading, setRestoreLoading] = useState(false)
const [restorePreparing, setRestorePreparing] = useState(false)
const [verifyLoading, setVerifyLoading] = useState(false)
useEffect(() => {
if (!visible || !recordId) {
@@ -141,19 +157,57 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
}
}
async function handleRestore() {
// handleOpenRestore 准备恢复所需的任务上下文并打开确认弹窗。
// 只有在用户明确二次确认后,才会真正触发异步恢复流程。
async function handleOpenRestore() {
if (!record) {
return
}
setRestorePreparing(true)
try {
const task = await getBackupTask(record.taskId)
setRestoreTask(task)
setRestoreModalVisible(true)
} catch (prepareError) {
Message.error(resolveErrorMessage(prepareError, '加载任务信息失败'))
} finally {
setRestorePreparing(false)
}
}
// handleVerify 基于当前备份记录启动一次快速验证,验证结果在"验证演练"页面查看。
async function handleVerify() {
if (!recordId) return
setVerifyLoading(true)
try {
const verify = await startVerifyByRecord(recordId, 'quick')
Message.success('验证已启动,正在打开结果')
navigate(`/verify/records?verifyId=${verify.id}`)
onCancel()
} catch (e) {
Message.error(resolveErrorMessage(e, '启动验证失败'))
} finally {
setVerifyLoading(false)
}
}
async function handleConfirmRestore() {
if (!recordId) {
return
}
setActing(true)
setRestoreLoading(true)
try {
await restoreBackupRecord(recordId)
setStreamError('恢复命令已提交')
const restore = await startRestoreFromBackup(recordId)
Message.success('恢复已启动,正在打开日志')
setRestoreModalVisible(false)
setRestoreTask(null)
await onChanged?.()
navigate(`/restore/records?restoreId=${restore.id}`)
onCancel()
} catch (restoreError) {
setStreamError(resolveErrorMessage(restoreError, '恢复备份失败'))
Message.error(resolveErrorMessage(restoreError, '启动恢复失败'))
} finally {
setActing(false)
setRestoreLoading(false)
}
}
@@ -214,12 +268,30 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
<Button loading={acting} onClick={handleDownload}>
</Button>
<Button loading={acting} onClick={handleRestore}>
</Button>
<Button loading={acting} status="danger" onClick={handleDelete}>
</Button>
{writable && (
<Button
type="primary"
loading={restorePreparing}
disabled={record.status !== 'success'}
onClick={() => void handleOpenRestore()}
>
</Button>
)}
{writable && (
<Button
loading={verifyLoading}
disabled={record.status !== 'success'}
onClick={() => void handleVerify()}
>
</Button>
)}
{writable && (
<Button loading={acting} status="danger" onClick={handleDelete}>
</Button>
)}
</Space>
{record.storageUploadResults && record.storageUploadResults.length > 1 && (
<div>
@@ -240,6 +312,18 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
</div>
</Space>
) : null}
<RestoreConfirmModal
visible={restoreModalVisible}
loading={restoreLoading}
backupRecord={record}
task={restoreTask}
onCancel={() => {
if (restoreLoading) return
setRestoreModalVisible(false)
setRestoreTask(null)
}}
onConfirm={() => void handleConfirmRestore()}
/>
</Drawer>
)
}

View File

@@ -5,6 +5,7 @@ import { CronInput } from '../CronInput'
import type { StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
import type { StorageConnectionTestResult } from '../../types/storage-targets'
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskType } from '../../types/backup-tasks'
import type { NodeSummary } from '../../types/nodes'
import { DatabasePicker } from '../common/DatabasePicker'
import { DirectoryPicker } from '../common/DirectoryPicker'
import { StorageTargetFormDrawer } from '../storage-targets/StorageTargetFormDrawer'
@@ -28,6 +29,9 @@ interface BackupTaskFormDrawerProps {
initialValue: BackupTaskDetail | null
storageTargets: StorageTargetSummary[]
localNodeId?: number
nodes?: NodeSummary[]
/** 系统内全部任务,用于上游依赖多选 */
allTasks?: { id: number; name: string }[]
onCancel: () => void
onSubmit: (value: BackupTaskPayload, taskId?: number) => Promise<void>
onCreateStorageTarget?: (value: StorageTargetPayload) => Promise<StorageTargetDetail>
@@ -61,10 +65,18 @@ function createEmptyDraft(storageTargets?: StorageTargetSummary[]): BackupTaskPa
encrypt: false,
maxBackups: 10,
extraConfig: undefined,
verifyEnabled: false,
verifyCronExpr: '',
verifyMode: 'quick',
slaHoursRpo: 0,
alertOnConsecutiveFails: 1,
replicationTargetIds: [],
maintenanceWindows: '',
dependsOnTaskIds: [],
}
}
export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTargets, localNodeId, onCancel, onSubmit, onCreateStorageTarget, onTestStorageTarget, onGoogleDriveAuth, onStorageTargetCreated }: BackupTaskFormDrawerProps) {
export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTargets, localNodeId, nodes, allTasks, onCancel, onSubmit, onCreateStorageTarget, onTestStorageTarget, onGoogleDriveAuth, onStorageTargetCreated }: BackupTaskFormDrawerProps) {
const [draft, setDraft] = useState<BackupTaskPayload>(createEmptyDraft())
const [excludePatternsText, setExcludePatternsText] = useState('')
const [currentStep, setCurrentStep] = useState(0)
@@ -115,12 +127,20 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
storageTargetId: editTargetIds[0] ?? 0,
storageTargetIds: editTargetIds,
nodeId: (initialValue as any).nodeId ?? 0,
tags: (initialValue as any).tags ?? '',
tags: initialValue.tags ?? '',
retentionDays: initialValue.retentionDays,
compression: initialValue.compression,
encrypt: initialValue.encrypt,
maxBackups: initialValue.maxBackups,
extraConfig: initialValue.extraConfig,
verifyEnabled: initialValue.verifyEnabled ?? false,
verifyCronExpr: initialValue.verifyCronExpr ?? '',
verifyMode: (initialValue.verifyMode ?? 'quick') as 'quick' | 'deep',
slaHoursRpo: initialValue.slaHoursRpo ?? 0,
alertOnConsecutiveFails: initialValue.alertOnConsecutiveFails ?? 1,
replicationTargetIds: initialValue.replicationTargetIds ?? [],
maintenanceWindows: initialValue.maintenanceWindows ?? '',
dependsOnTaskIds: initialValue.dependsOnTaskIds ?? [],
})
setExcludePatternsText(initialValue.excludePatterns.join('\n'))
setCurrentStep(0)
@@ -142,6 +162,21 @@ 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 }))
}
@@ -257,6 +292,17 @@ 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) => updateDraft({ nodeId: Number(value ?? 0) })}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
/"节点管理" Agent
</Typography.Paragraph>
</div>
<div>
<Typography.Text>Cron </Typography.Text>
<CronInput value={draft.cronExpr} onChange={(value) => updateDraft({ cronExpr: value })} />
@@ -312,7 +358,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
value={p}
placeholder={`源路径 ${index + 1},例如:/var/www/html`}
mode="directory"
nodeId={localNodeId}
nodeId={draft.nodeId && draft.nodeId > 0 ? draft.nodeId : localNodeId}
onChange={(value) => updateSourcePath(index, value)}
/>
</Grid.Col>
@@ -351,7 +397,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
value={draft.dbPath}
placeholder="例如:/data/app.db"
mode="file"
nodeId={localNodeId}
nodeId={draft.nodeId && draft.nodeId > 0 ? draft.nodeId : localNodeId}
onChange={(value) => updateDraft({ dbPath: value })}
/>
</div>
@@ -384,6 +430,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
dbPort={draft.dbPort}
dbUser={draft.dbUser}
dbPassword={draft.dbPassword}
nodeId={draft.nodeId}
value={draft.dbName}
onChange={(value) => updateDraft({ dbName: value })}
/>
@@ -523,10 +570,130 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
<Typography.Text></Typography.Text>
<InputNumber style={{ width: '100%' }} value={draft.maxBackups} min={0} onChange={(value) => updateDraft({ maxBackups: Number(value ?? 0) })} />
</div>
<div>
<Typography.Text></Typography.Text>
<Input
value={draft.tags}
placeholder="例如prod,mysql,critical"
onChange={(value) => updateDraft({ tags: value })}
/>
</div>
<Space align="center" size="medium">
<Typography.Text></Typography.Text>
<Switch checked={draft.encrypt} onChange={(checked) => updateDraft({ encrypt: checked })} />
</Space>
<Divider style={{ margin: '8px 0' }} orientation="left">
<Typography.Text type="secondary">SLA </Typography.Text>
</Divider>
<div>
<Typography.Text>RPO 0=</Typography.Text>
<InputNumber
style={{ width: '100%' }}
value={draft.slaHoursRpo}
min={0}
onChange={(value) => updateDraft({ slaHoursRpo: Number(value ?? 0) })}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
SLA Dashboard
</Typography.Paragraph>
</div>
<div>
<Typography.Text></Typography.Text>
<InputNumber
style={{ width: '100%' }}
value={draft.alertOnConsecutiveFails}
min={1}
max={20}
onChange={(value) => updateDraft({ alertOnConsecutiveFails: Number(value ?? 1) })}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
1
</Typography.Paragraph>
</div>
<Divider style={{ margin: '8px 0' }} orientation="left">
<Typography.Text type="secondary"></Typography.Text>
</Divider>
<div>
<Typography.Text></Typography.Text>
<Input
value={draft.maintenanceWindows}
placeholder="例如time=22:00-06:00 或 days=sat|sun,time=00:00-23:59"
onChange={(v) => updateDraft({ maintenanceWindows: v })}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
= <Typography.Text code>;</Typography.Text>
</Typography.Paragraph>
</div>
<Divider style={{ margin: '8px 0' }} orientation="left">
<Typography.Text type="secondary"></Typography.Text>
</Divider>
<div>
<Typography.Text></Typography.Text>
<Select
mode="multiple"
style={{ width: '100%' }}
value={draft.dependsOnTaskIds}
placeholder="选择上游任务(留空 = 独立任务)"
options={(allTasks ?? [])
.filter((t) => t.id !== initialValue?.id)
.map((t) => ({ label: t.name, value: t.id }))}
onChange={(values: number[]) => updateDraft({ dependsOnTaskIds: values })}
allowClear
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
"DB 备份" "归档打包"
</Typography.Paragraph>
</div>
<Divider style={{ margin: '8px 0' }} orientation="left">
<Typography.Text type="secondary">3-2-1 </Typography.Text>
</Divider>
<div>
<Typography.Text></Typography.Text>
<Select
mode="multiple"
style={{ width: '100%' }}
value={draft.replicationTargetIds}
placeholder="选择副本目标(不选 = 不启用复制)"
options={storageTargetOptions.filter((opt) => !(draft.storageTargetIds ?? []).includes(opt.value as number))}
onChange={(values: number[]) => updateDraft({ replicationTargetIds: values })}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
3-2-1 2 1 provider
</Typography.Paragraph>
</div>
<Divider style={{ margin: '8px 0' }} orientation="left">
<Typography.Text type="secondary"></Typography.Text>
</Divider>
<Space align="center" size="medium">
<Typography.Text></Typography.Text>
<Switch checked={draft.verifyEnabled} onChange={(checked) => updateDraft({ verifyEnabled: checked })} />
</Space>
{draft.verifyEnabled && (
<>
<div>
<Typography.Text> Cron </Typography.Text>
<CronInput value={draft.verifyCronExpr} onChange={(value) => updateDraft({ verifyCronExpr: value })} />
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
SOC2/ISO27001
</Typography.Paragraph>
</div>
<div>
<Typography.Text></Typography.Text>
<Select
value={draft.verifyMode}
options={[
{ label: 'Quick快速格式与完整性校验', value: 'quick' },
]}
onChange={(value) => updateDraft({ verifyMode: value as 'quick' | 'deep' })}
/>
</div>
</>
)}
</Space>
)
}

View File

@@ -0,0 +1,98 @@
import { Alert, Card, Empty, Typography } from '@arco-design/web-react'
import ReactEChartsCore from 'echarts-for-react/lib/core'
import { GraphChart } from 'echarts/charts'
import * as echarts from 'echarts/core'
import { TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { useMemo } from 'react'
import type { BackupTaskSummary } from '../../types/backup-tasks'
echarts.use([GraphChart, TooltipComponent, CanvasRenderer])
interface Props {
tasks: BackupTaskSummary[]
}
const STATUS_COLORS: Record<string, string> = {
success: '#00B42A',
failed: '#F53F3F',
running: '#165DFF',
idle: '#86909C',
}
/**
* TaskDependencyGraph 任务依赖有向图可视化。
* - 节点 = 任务,按最近状态着色
* - 边 = 依赖关系(上游 → 下游)
* - 只显示有依赖关系或被依赖的任务(孤岛任务忽略,减少视觉噪音)
*/
export function TaskDependencyGraph({ tasks }: Props) {
const { nodes, links, hasAny } = useMemo(() => {
const nodeIds = new Set<number>()
const allLinks: { source: string; target: string }[] = []
for (const task of tasks) {
const deps = task.dependsOnTaskIds ?? []
if (deps.length === 0) continue
nodeIds.add(task.id)
for (const dep of deps) {
nodeIds.add(dep)
allLinks.push({ source: String(dep), target: String(task.id) })
}
}
const taskMap = new Map(tasks.map((t) => [t.id, t]))
const graphNodes = Array.from(nodeIds).map((id) => {
const t = taskMap.get(id)
const status = t?.lastStatus ?? 'idle'
return {
id: String(id),
name: t?.name ?? `#${id}`,
symbolSize: 40,
itemStyle: { color: STATUS_COLORS[status] ?? '#86909C' },
label: { show: true, fontSize: 11, color: 'var(--color-text-1)' },
}
})
return { nodes: graphNodes, links: allLinks, hasAny: allLinks.length > 0 }
}, [tasks])
const option = useMemo(
() => ({
tooltip: { trigger: 'item' as const, formatter: '{b}' },
animationDuration: 800,
series: [
{
type: 'graph' as const,
layout: 'force' as const,
roam: true,
draggable: true,
force: { repulsion: 180, gravity: 0.08, edgeLength: 120 },
label: { show: true, position: 'bottom' as const },
edgeSymbol: ['none', 'arrow'] as [string, string],
edgeSymbolSize: [0, 10] as [number, number],
lineStyle: { color: 'var(--color-border-3)', curveness: 0.1 },
data: nodes,
links,
},
],
}),
[nodes, links],
)
if (!hasAny) {
return (
<Card>
<Empty description={
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
"任务依赖"
</Typography.Paragraph>
} />
</Card>
)
}
return (
<Card title="任务依赖图">
<Alert type="info" content="节点颜色按最近执行状态:绿=成功 / 红=失败 / 蓝=执行中 / 灰=未运行。箭头方向 = 上游 → 下游。" style={{ marginBottom: 12 }} />
<ReactEChartsCore echarts={echarts} option={option} style={{ height: 420 }} />
</Card>
)
}

View File

@@ -8,11 +8,13 @@ interface DatabasePickerProps {
dbPort: number
dbUser: string
dbPassword: string
/** 目标执行节点 ID。0 或 undefined 表示 Master 本地发现;远程节点通过 Agent 发现。 */
nodeId?: number
value: string
onChange: (value: string) => void
}
export function DatabasePicker({ dbType, dbHost, dbPort, dbUser, dbPassword, value, onChange }: DatabasePickerProps) {
export function DatabasePicker({ dbType, dbHost, dbPort, dbUser, dbPassword, nodeId, value, onChange }: DatabasePickerProps) {
const [databases, setDatabases] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [discovered, setDiscovered] = useState(false)
@@ -35,6 +37,7 @@ export function DatabasePicker({ dbType, dbHost, dbPort, dbUser, dbPassword, val
port: dbPort,
user: dbUser.trim(),
password: dbPassword.trim(),
nodeId: nodeId && nodeId > 0 ? nodeId : undefined,
})
setDatabases(result)
setDiscovered(true)

View File

@@ -0,0 +1,131 @@
import { Badge, Button, Drawer, Empty, Notification, Space, Tag, Typography } from '@arco-design/web-react'
import { IconNotification } from '@arco-design/web-react/icon'
import { useEffect, useState } from 'react'
import { useEventStream, type SystemEvent } from '../../hooks/useEventStream'
import { useEventStore } from '../../stores/events'
import { formatDateTime } from '../../utils/format'
// EVENT_CONFIG 把后端事件类型映射到 UI 展示。
// Toast 类型决定颜色label 中文化silent=true 则不弹 Toast仅进历史
const EVENT_CONFIG: Record<string, { label: string; toast?: 'success' | 'error' | 'warning' | 'info'; color: string }> = {
backup_success: { label: '备份成功', toast: 'success', color: 'green' },
backup_failed: { label: '备份失败', toast: 'error', color: 'red' },
restore_success: { label: '恢复成功', toast: 'success', color: 'green' },
restore_failed: { label: '恢复失败', toast: 'error', color: 'red' },
verify_failed: { label: '验证未通过', toast: 'error', color: 'red' },
sla_violation: { label: 'SLA 违约', toast: 'warning', color: 'orange' },
storage_unhealthy: { label: '存储不可用', toast: 'error', color: 'red' },
storage_capacity_warning: { label: '存储容量预警', toast: 'warning', color: 'orange' },
replication_failed: { label: '复制失败', toast: 'error', color: 'red' },
agent_outdated: { label: 'Agent 版本过期', toast: 'warning', color: 'orange' },
}
function labelFor(type: string): string {
return EVENT_CONFIG[type]?.label ?? type
}
/**
* EventCenter 头部的事件通知中心。
* - Bell 图标 + 未读徽章
* - SSE 事件到达时弹右下角 Toast + 进入历史
* - 点击 Bell 打开右侧抽屉查看历史
*/
export function EventCenter() {
const [drawerOpen, setDrawerOpen] = useState(false)
const events = useEventStore((s) => s.events)
const unreadCount = useEventStore((s) => s.unreadCount)
const addEvent = useEventStore((s) => s.addEvent)
const markAllRead = useEventStore((s) => s.markAllRead)
const clear = useEventStore((s) => s.clear)
// 订阅全部事件( Dashboard 的 useEventStream 另一套实例只过滤 Dashboard 关心的事件)
useEventStream((event: SystemEvent) => {
addEvent(event)
const config = EVENT_CONFIG[event.type]
if (config?.toast) {
const fn = Notification[config.toast]
fn({
title: event.title || config.label,
content: event.body,
duration: config.toast === 'error' ? 6000 : 3500,
})
}
})
// 打开抽屉后自动标记已读
useEffect(() => {
if (drawerOpen && unreadCount > 0) {
markAllRead()
}
}, [drawerOpen, unreadCount, markAllRead])
return (
<>
<Badge count={unreadCount} dot={unreadCount > 0 && unreadCount <= 0}>
<Button
type="text"
icon={<IconNotification />}
onClick={() => setDrawerOpen(true)}
>
{unreadCount > 0 ? `${unreadCount}` : ''}
</Button>
</Badge>
<Drawer
visible={drawerOpen}
title={
<Space>
<span></span>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{events.length}
</Typography.Text>
</Space>
}
width={480}
onCancel={() => setDrawerOpen(false)}
footer={
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button size="small" onClick={clear} disabled={events.length === 0}></Button>
</Space>
}
>
{events.length === 0 ? (
<Empty description="暂无事件。事件仅在当前会话内保留。" />
) : (
<div>
{events.map((e) => {
const config = EVENT_CONFIG[e.type]
return (
<div
key={e.id}
style={{
padding: '8px 12px',
marginBottom: 8,
borderRadius: 4,
backgroundColor: e.read ? 'var(--color-bg-1)' : 'var(--color-primary-light-1)',
border: '1px solid var(--color-border-2)',
}}
>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Space>
<Tag color={config?.color ?? 'gray'} bordered size="small">{labelFor(e.type)}</Tag>
<Typography.Text bold style={{ fontSize: 13 }}>{e.title}</Typography.Text>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{formatDateTime(e.timestamp)}
</Typography.Text>
</Space>
{e.body ? (
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 4, marginBottom: 0, whiteSpace: 'pre-wrap' }}>
{e.body}
</Typography.Paragraph>
) : null}
</div>
)
})}
</div>
)}
</Drawer>
</>
)
}

View File

@@ -0,0 +1,169 @@
import { Empty, Input, Modal, Space, Spin, Tag, Typography } from '@arco-design/web-react'
import { IconSearch } from '@arco-design/web-react/icon'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { globalSearch, type SearchKind, type SearchResult, type SearchResultItem } from '../../services/search'
const KIND_LABELS: Record<SearchKind, string> = {
task: '任务',
record: '备份记录',
storage: '存储目标',
node: '节点',
}
const KIND_COLORS: Record<SearchKind, string> = {
task: 'arcoblue',
record: 'green',
storage: 'orange',
node: 'purple',
}
/**
* GlobalSearch 顶部 Header 的全局搜索入口。
* Ctrl/Cmd+K 快捷键唤起 Modal输入 300ms 后触发搜索,避免高频请求。
*/
export function GlobalSearch() {
const navigate = useNavigate()
const [visible, setVisible] = useState(false)
const [query, setQuery] = useState('')
const [result, setResult] = useState<SearchResult | null>(null)
const [loading, setLoading] = useState(false)
const debounceTimer = useRef<number | null>(null)
// Ctrl/Cmd+K 快捷唤起
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setVisible(true)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
const runSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResult(null)
return
}
setLoading(true)
try {
const res = await globalSearch(q)
setResult(res)
} catch {
setResult(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (debounceTimer.current) {
window.clearTimeout(debounceTimer.current)
}
debounceTimer.current = window.setTimeout(() => {
void runSearch(query)
}, 300)
return () => {
if (debounceTimer.current) {
window.clearTimeout(debounceTimer.current)
}
}
}, [query, runSearch])
function handleNavigate(item: SearchResultItem) {
setVisible(false)
navigate(item.url)
}
function renderSection(kind: SearchKind, items: SearchResultItem[]) {
if (items.length === 0) return null
return (
<div key={kind} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{KIND_LABELS[kind]}{items.length}
</Typography.Text>
<div style={{ marginTop: 4 }}>
{items.map((item) => (
<div
key={`${kind}-${item.id}`}
onClick={() => handleNavigate(item)}
style={{
padding: '8px 12px',
cursor: 'pointer',
borderRadius: 4,
marginBottom: 4,
backgroundColor: 'var(--color-fill-1)',
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-primary-light-1)' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-fill-1)' }}
>
<Space>
<Tag color={KIND_COLORS[kind]} bordered size="small">{KIND_LABELS[kind]}</Tag>
<Typography.Text bold>{item.title}</Typography.Text>
{item.subtitle && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{item.subtitle}</Typography.Text>}
</Space>
</div>
))}
</div>
</div>
)
}
return (
<>
<div
onClick={() => setVisible(true)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 12px',
borderRadius: 16,
cursor: 'pointer',
backgroundColor: 'var(--color-fill-2)',
color: 'var(--color-text-3)',
fontSize: 12,
}}
>
<IconSearch />
<span>//... (Ctrl+K)</span>
</div>
<Modal
visible={visible}
title={null}
footer={null}
onCancel={() => setVisible(false)}
style={{ width: 720, top: 80 }}
unmountOnExit
maskClosable
>
<Input
autoFocus
size="large"
value={query}
placeholder="输入关键词搜索任务、备份记录、存储目标、节点..."
prefix={<IconSearch />}
onChange={setQuery}
allowClear
/>
<div style={{ marginTop: 16, maxHeight: 480, overflow: 'auto' }}>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : !result || result.totalCount === 0 ? (
<Empty description={query ? '未找到匹配项' : '开始输入以搜索'} />
) : (
<>
{renderSection('task', result.tasks)}
{renderSection('record', result.records)}
{renderSection('storage', result.storage)}
{renderSection('node', result.nodes)}
</>
)}
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,91 @@
import { Alert, Descriptions, Modal, Space, Tag, Typography } from '@arco-design/web-react'
import type { BackupRecordDetail } from '../../types/backup-records'
import type { BackupTaskDetail } from '../../types/backup-tasks'
interface RestoreConfirmModalProps {
visible: boolean
loading: boolean
backupRecord: BackupRecordDetail | null
task: BackupTaskDetail | null
onCancel: () => void
onConfirm: () => void
}
// RestoreConfirmModal 展示即将恢复的备份摘要与覆盖风险,强制用户二次确认。
// 恢复是破坏性操作:会覆盖任务配置的源路径/数据库,不可撤销。
export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCancel, onConfirm }: RestoreConfirmModalProps) {
if (!backupRecord || !task) {
return (
<Modal visible={visible} title="确认恢复" onCancel={onCancel} onOk={onConfirm} confirmLoading={loading} unmountOnExit>
<Alert type="info" content="正在加载任务与备份信息..." />
</Modal>
)
}
const restoreTarget = renderRestoreTarget(task)
const nodeLabel = task.nodeId && task.nodeId > 0
? (task.nodeName ? `${task.nodeName}(远程节点)` : `节点 #${task.nodeId}`)
: '本机 Master'
return (
<Modal
visible={visible}
title="确认执行恢复"
okText="开始恢复"
cancelText="取消"
okButtonProps={{ status: 'danger', loading }}
onCancel={onCancel}
onOk={onConfirm}
unmountOnExit
>
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Alert
type="warning"
content="恢复是破坏性操作:会覆盖目标位置的现有数据,不可撤销。请先确认恢复目标并在必要时保留当前状态的副本。"
/>
<Descriptions
column={1}
size="small"
data={[
{ label: '任务', value: <Typography.Text bold>{task.name}</Typography.Text> },
{ label: '类型', value: <Tag color="arcoblue" bordered>{task.type.toUpperCase()}</Tag> },
{ label: '执行节点', value: nodeLabel },
{ label: '源备份', value: backupRecord.fileName || '-' },
{ label: '恢复目标', value: restoreTarget },
]}
/>
</Space>
</Modal>
)
}
function renderRestoreTarget(task: BackupTaskDetail) {
if (task.type === 'file') {
const paths = task.sourcePaths && task.sourcePaths.length > 0
? task.sourcePaths
: task.sourcePath
? [task.sourcePath]
: []
if (paths.length === 0) {
return <Typography.Text type="secondary"></Typography.Text>
}
return (
<Space direction="vertical" size={2}>
{paths.map((p) => (
<Typography.Text key={p} code>{p}</Typography.Text>
))}
</Space>
)
}
if (task.type === 'sqlite') {
return <Typography.Text code>{task.dbPath || '-'}</Typography.Text>
}
if (task.type === 'mysql' || task.type === 'postgresql' || task.type === 'saphana') {
return (
<Typography.Text>
{task.dbUser}@{task.dbHost}:{task.dbPort} / <Typography.Text code>{task.dbName || '-'}</Typography.Text>
</Typography.Text>
)
}
return '-'
}

View File

@@ -0,0 +1,163 @@
import { Alert, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { getRestoreRecord, streamRestoreRecordLogs } from '../../services/restore-records'
import type { BackupLogEvent } from '../../types/backup-records'
import type { RestoreRecordDetail, RestoreRecordStatus } from '../../types/restore-records'
import { resolveErrorMessage } from '../../utils/error'
import { formatDateTime, formatDuration } from '../../utils/format'
interface RestoreRecordLogDrawerProps {
visible: boolean
restoreId?: number
onCancel: () => void
}
function getStatusColor(status: RestoreRecordStatus) {
switch (status) {
case 'success':
return 'green'
case 'failed':
return 'red'
default:
return 'arcoblue'
}
}
function buildLogText(record: RestoreRecordDetail | null, events: BackupLogEvent[]) {
if (events.length > 0) {
return events.map((item) => `[${formatDateTime(item.timestamp)}] ${item.message}`).join('\n')
}
return record?.logContent ?? ''
}
export function RestoreRecordLogDrawer({ visible, restoreId, onCancel }: RestoreRecordLogDrawerProps) {
const [record, setRecord] = useState<RestoreRecordDetail | null>(null)
const [events, setEvents] = useState<BackupLogEvent[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [streamError, setStreamError] = useState('')
useEffect(() => {
if (!visible || !restoreId) {
return
}
const currentId = restoreId
let active = true
let unsubscribe: (() => void) | null = null
async function loadDetail() {
setLoading(true)
try {
const detail = await getRestoreRecord(currentId)
if (!active) {
return
}
setRecord(detail)
setEvents(detail.logEvents ?? [])
setError('')
setStreamError('')
if (detail.status === 'running') {
unsubscribe = streamRestoreRecordLogs(currentId, {
onEvent: (event) => {
if (!active) return
setEvents((current) => {
if (current.some((item) => item.sequence === event.sequence)) {
return current
}
return [...current, event]
})
if (event.completed) {
setRecord((current) => (current ? { ...current, status: event.status as RestoreRecordStatus } : current))
}
},
onDone: () => {
if (!active) return
void (async () => {
try {
const latest = await getRestoreRecord(currentId)
if (active) {
setRecord(latest)
setEvents(latest.logEvents ?? [])
}
} catch (refreshError) {
if (active) {
setStreamError(resolveErrorMessage(refreshError, '刷新恢复详情失败'))
}
}
})()
},
onError: (message) => {
if (active) {
setStreamError(message)
}
},
})
}
} catch (loadError) {
if (active) {
setError(resolveErrorMessage(loadError, '加载恢复记录失败'))
}
} finally {
if (active) {
setLoading(false)
}
}
}
void loadDetail()
return () => {
active = false
unsubscribe?.()
}
}, [restoreId, visible])
const logText = useMemo(() => buildLogText(record, events), [events, record])
return (
<Drawer width={720} title="恢复记录详情" visible={visible} onCancel={onCancel} footer={null}>
{loading ? (
<Spin />
) : error ? (
<Alert type="error" content={error} />
) : record ? (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{streamError ? <Alert type="warning" content={streamError} /> : null}
<div>
<Typography.Title heading={6} style={{ marginTop: 0, marginBottom: 4 }}>
{record.taskName}
</Typography.Title>
<Space>
<Tag color={getStatusColor(record.status)} bordered>
{record.status === 'success' ? '成功' : record.status === 'failed' ? '失败' : '执行中'}
</Tag>
{record.nodeName ? (
<Tag color="arcoblue" bordered>: {record.nodeName}</Tag>
) : record.nodeId === 0 ? (
<Tag color="arcoblue" bordered>节点: 本机 Master</Tag>
) : null}
{record.triggeredBy && <Tag bordered>: {record.triggeredBy}</Tag>}
</Space>
</div>
<Descriptions
column={1}
data={[
{ label: '源备份记录', value: `#${record.backupRecordId}${record.backupFileName ? ` (${record.backupFileName})` : ''}` },
{ label: '开始时间', value: formatDateTime(record.startedAt) },
{ label: '完成时间', value: formatDateTime(record.completedAt) },
{ label: '耗时', value: formatDuration(record.durationSeconds) },
{ label: '错误信息', value: record.errorMessage || '-' },
]}
/>
<div>
<Typography.Title heading={6}></Typography.Title>
<div className="log-viewer">{logText || '暂无日志输出'}</div>
</div>
</Space>
) : null}
</Drawer>
)
}

View File

@@ -1,4 +1,4 @@
import { Alert, Button, Collapse, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { Alert, Button, Collapse, Divider, Drawer, Input, InputNumber, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, isBuiltinType, buildAllTypeOptions } from './field-config'
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets'
@@ -16,7 +16,7 @@ interface StorageTargetFormDrawerProps {
}
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
return { name: '', type, description: '', enabled: true, config: {} }
return { name: '', type, description: '', enabled: true, config: {}, quotaBytes: 0 }
}
export function StorageTargetFormDrawer({
@@ -51,6 +51,7 @@ export function StorageTargetFormDrawer({
description: initialValue.description,
enabled: initialValue.enabled,
config: { ...initialValue.config },
quotaBytes: initialValue.quotaBytes ?? 0,
})
setError('')
setTestResult(null)
@@ -224,6 +225,21 @@ export function StorageTargetFormDrawer({
<Typography.Text></Typography.Text>
<Input.TextArea value={draft.description} placeholder="可选描述" onChange={(v) => setDraft((c) => ({ ...c, description: v }))} />
</div>
<div>
<Typography.Text>GB0 = </Typography.Text>
<InputNumber
style={{ width: '100%' }}
min={0}
value={Math.round((draft.quotaBytes ?? 0) / (1024 * 1024 * 1024))}
onChange={(v) => {
const gb = Number(v ?? 0)
setDraft((c) => ({ ...c, quotaBytes: gb > 0 ? gb * 1024 * 1024 * 1024 : 0 }))
}}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
85%
</Typography.Paragraph>
</div>
<Space align="center" size="medium">
<Typography.Text></Typography.Text>

View File

@@ -0,0 +1,150 @@
import { Alert, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { getVerificationRecord, streamVerificationRecordLogs } from '../../services/verification-records'
import type { BackupLogEvent } from '../../types/backup-records'
import type { VerificationRecordDetail, VerificationRecordStatus } from '../../types/verification-records'
import { resolveErrorMessage } from '../../utils/error'
import { formatDateTime, formatDuration } from '../../utils/format'
interface Props {
visible: boolean
verifyId?: number
onCancel: () => void
}
function statusColor(status: VerificationRecordStatus) {
switch (status) {
case 'success':
return 'green'
case 'failed':
return 'red'
default:
return 'arcoblue'
}
}
function statusLabel(status: VerificationRecordStatus) {
switch (status) {
case 'success':
return '通过'
case 'failed':
return '未通过'
case 'running':
return '验证中'
default:
return status
}
}
function buildLogText(record: VerificationRecordDetail | null, events: BackupLogEvent[]) {
if (events.length > 0) {
return events.map((item) => `[${formatDateTime(item.timestamp)}] ${item.message}`).join('\n')
}
return record?.logContent ?? ''
}
export function VerificationRecordLogDrawer({ visible, verifyId, onCancel }: Props) {
const [record, setRecord] = useState<VerificationRecordDetail | null>(null)
const [events, setEvents] = useState<BackupLogEvent[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [streamError, setStreamError] = useState('')
useEffect(() => {
if (!visible || !verifyId) return
const current = verifyId
let active = true
let unsubscribe: (() => void) | null = null
async function load() {
setLoading(true)
try {
const detail = await getVerificationRecord(current)
if (!active) return
setRecord(detail)
setEvents(detail.logEvents ?? [])
setError('')
setStreamError('')
if (detail.status === 'running') {
unsubscribe = streamVerificationRecordLogs(current, {
onEvent: (event) => {
if (!active) return
setEvents((existing) => (existing.some((i) => i.sequence === event.sequence) ? existing : [...existing, event]))
if (event.completed) {
setRecord((existing) => (existing ? { ...existing, status: event.status as VerificationRecordStatus } : existing))
}
},
onDone: () => {
if (!active) return
void (async () => {
try {
const latest = await getVerificationRecord(current)
if (active) {
setRecord(latest)
setEvents(latest.logEvents ?? [])
}
} catch (e) {
if (active) setStreamError(resolveErrorMessage(e, '刷新验证详情失败'))
}
})()
},
onError: (message) => {
if (active) setStreamError(message)
},
})
}
} catch (e) {
if (active) setError(resolveErrorMessage(e, '加载验证记录失败'))
} finally {
if (active) setLoading(false)
}
}
void load()
return () => {
active = false
unsubscribe?.()
}
}, [verifyId, visible])
const logText = useMemo(() => buildLogText(record, events), [events, record])
return (
<Drawer width={720} title="验证记录详情" visible={visible} onCancel={onCancel} footer={null}>
{loading ? (
<Spin />
) : error ? (
<Alert type="error" content={error} />
) : record ? (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{streamError ? <Alert type="warning" content={streamError} /> : null}
<div>
<Typography.Title heading={6} style={{ marginTop: 0, marginBottom: 4 }}>
{record.taskName}
</Typography.Title>
<Space>
<Tag color={statusColor(record.status)} bordered>{statusLabel(record.status)}</Tag>
<Tag bordered>{record.mode === 'deep' ? '深度模式' : '快速模式'}</Tag>
{record.triggeredBy && <Tag bordered>: {record.triggeredBy}</Tag>}
</Space>
</div>
<Descriptions
column={1}
data={[
{ label: '源备份', value: `#${record.backupRecordId}${record.backupFileName ? ` (${record.backupFileName})` : ''}` },
{ label: '验证摘要', value: record.summary || '-' },
{ label: '开始时间', value: formatDateTime(record.startedAt) },
{ label: '完成时间', value: formatDateTime(record.completedAt) },
{ label: '耗时', value: formatDuration(record.durationSeconds) },
{ label: '错误信息', value: record.errorMessage || '-' },
]}
/>
<div>
<Typography.Title heading={6}></Typography.Title>
<div className="log-viewer">{logText || '暂无日志输出'}</div>
</div>
</Space>
) : null}
</Drawer>
)
}