mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-07-02 06:51:22 +08:00
功能: 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
98
web/src/components/backup-tasks/TaskDependencyGraph.tsx
Normal file
98
web/src/components/backup-tasks/TaskDependencyGraph.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
131
web/src/components/common/EventCenter.tsx
Normal file
131
web/src/components/common/EventCenter.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
169
web/src/components/common/GlobalSearch.tsx
Normal file
169
web/src/components/common/GlobalSearch.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
91
web/src/components/restore-records/RestoreConfirmModal.tsx
Normal file
91
web/src/components/restore-records/RestoreConfirmModal.tsx
Normal 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 '-'
|
||||
}
|
||||
163
web/src/components/restore-records/RestoreRecordLogDrawer.tsx
Normal file
163
web/src/components/restore-records/RestoreRecordLogDrawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>容量配额(GB,0 = 不限制)</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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user