Files
BackupX/web/src/pages/backup-tasks/BackupTasksPage.tsx
Wu Qing 539e9e64c4 功能: 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 的误判
2026-04-20 13:04:13 +08:00

526 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Button, Card, Empty, Message, Modal, PageHeader, Select, Space, Table, Tag, Typography, Upload } from '@arco-design/web-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { BackupTaskDetailDrawer } from '../../components/backup-tasks/BackupTaskDetailDrawer'
import { BackupTaskFormDrawer } from '../../components/backup-tasks/BackupTaskFormDrawer'
import { TaskDependencyGraph } from '../../components/backup-tasks/TaskDependencyGraph'
import { getBackupTaskStatusColor, getBackupTaskStatusLabel, getBackupTaskTypeLabel } from '../../components/backup-tasks/field-config'
import { batchDeleteTasks, batchRunTasks, batchToggleTasks, createBackupTask, deleteBackupTask, exportBackupTasks, getBackupTask, importBackupTasks, listBackupTasks, runBackupTask, toggleBackupTask, updateBackupTask, type TaskImportResult } from '../../services/backup-tasks'
import { listNodes } from '../../services/nodes'
import { createStorageTarget, listStorageTargets, startGoogleDriveAuth, testStorageTarget } from '../../services/storage-targets'
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskSummary } from '../../types/backup-tasks'
import type { NodeSummary } from '../../types/nodes'
import type { StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
import { useAuthStore } from '../../stores/auth'
import { resolveErrorMessage } from '../../utils/error'
import { canWrite } from '../../utils/permissions'
import { formatDateTime } from '../../utils/format'
export function BackupTasksPage() {
const navigate = useNavigate()
const currentUser = useAuthStore((state) => state.user)
const writable = canWrite(currentUser)
const [tasks, setTasks] = useState<BackupTaskSummary[]>([])
const [storageTargets, setStorageTargets] = useState<StorageTargetSummary[]>([])
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [drawerVisible, setDrawerVisible] = useState(false)
const [detailVisible, setDetailVisible] = useState(false)
const [editingTask, setEditingTask] = useState<BackupTaskDetail | null>(null)
const [detailTask, setDetailTask] = useState<BackupTaskDetail | null>(null)
const [error, setError] = useState('')
const [localNodeId, setLocalNodeId] = useState<number | undefined>(undefined)
const [nodes, setNodes] = useState<NodeSummary[]>([])
const [tagFilter, setTagFilter] = useState<string[]>([])
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [batchLoading, setBatchLoading] = useState(false)
const [importResults, setImportResults] = useState<TaskImportResult[] | null>(null)
const enabledStorageTargets = useMemo(() => storageTargets.filter((item) => item.enabled), [storageTargets])
// 从全量任务中提取所有用过的标签,作为筛选器选项
const availableTags = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
if (!task.tags) continue
for (const tag of task.tags.split(',').map((t) => t.trim()).filter(Boolean)) {
set.add(tag)
}
}
return Array.from(set).sort()
}, [tasks])
// 按标签筛选
const filteredTasks = useMemo(() => {
if (tagFilter.length === 0) return tasks
return tasks.filter((task) => {
const taskTags = (task.tags ?? '').split(',').map((t) => t.trim()).filter(Boolean)
return tagFilter.every((filter) => taskTags.includes(filter))
})
}, [tasks, tagFilter])
const loadData = useCallback(async () => {
setLoading(true)
try {
const [taskList, targetList, nodeList] = await Promise.all([listBackupTasks(), listStorageTargets(), listNodes()])
setTasks(taskList)
setStorageTargets(targetList)
setNodes(nodeList)
const localNode = nodeList.find((n) => n.isLocal)
if (localNode) {
setLocalNodeId(localNode.id)
}
setError('')
} catch (loadError) {
setError(resolveErrorMessage(loadError, '加载备份任务失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadData()
}, [loadData])
async function openEdit(id: number) {
setSubmitting(true)
try {
const detail = await getBackupTask(id)
setEditingTask(detail)
setDrawerVisible(true)
} catch (loadError) {
Message.error(resolveErrorMessage(loadError, '加载任务详情失败'))
} finally {
setSubmitting(false)
}
}
async function openDetail(id: number) {
setSubmitting(true)
try {
const detail = await getBackupTask(id)
setDetailTask(detail)
setDetailVisible(true)
} catch (loadError) {
Message.error(resolveErrorMessage(loadError, '加载任务详情失败'))
} finally {
setSubmitting(false)
}
}
async function handleSubmit(value: BackupTaskPayload, taskId?: number) {
setSubmitting(true)
try {
if (taskId) {
await updateBackupTask(taskId, value)
Message.success('备份任务已更新')
} else {
await createBackupTask(value)
Message.success('备份任务已创建')
}
setDrawerVisible(false)
setEditingTask(null)
await loadData()
} catch (submitError) {
Message.error(resolveErrorMessage(submitError, '保存备份任务失败'))
throw submitError
} finally {
setSubmitting(false)
}
}
async function handleToggle(task: BackupTaskSummary) {
try {
await toggleBackupTask(task.id, { enabled: !task.enabled })
Message.success(task.enabled ? '任务已停用' : '任务已启用')
await loadData()
} catch (toggleError) {
Message.error(resolveErrorMessage(toggleError, '切换任务状态失败'))
}
}
async function handleRun(task: BackupTaskSummary) {
try {
const record = await runBackupTask(task.id)
Message.success('已触发备份任务,正在打开执行日志')
navigate(`/backup/records?taskId=${task.id}&recordId=${record.id}`)
} catch (runError) {
Message.error(resolveErrorMessage(runError, '触发备份任务失败'))
}
}
async function handleDelete(task: BackupTaskSummary) {
if (!window.confirm(`确定删除任务“${task.name}”吗?`)) {
return
}
try {
await deleteBackupTask(task.id)
Message.success('备份任务已删除')
await loadData()
} catch (deleteError) {
Message.error(resolveErrorMessage(deleteError, '删除备份任务失败'))
}
}
// 导出选中或全部任务为 JSON
async function handleExport() {
try {
await exportBackupTasks(selectedIds.length > 0 ? selectedIds : undefined)
Message.success(selectedIds.length > 0 ? `已导出 ${selectedIds.length} 个任务` : '已导出全部任务')
} catch (e) {
Message.error(resolveErrorMessage(e, '导出失败'))
}
}
// 上传 JSON 并导入任务
async function handleImport(file: File): Promise<boolean> {
try {
const text = await file.text()
const payload = JSON.parse(text)
const results = await importBackupTasks(payload)
setImportResults(results)
const succ = results.filter((r) => r.success && !r.skipped).length
const skipped = results.filter((r) => r.skipped).length
Message.success(`导入完成:创建 ${succ} / 跳过 ${skipped} / 失败 ${results.length - succ - skipped}`)
await loadData()
} catch (e) {
Message.error(resolveErrorMessage(e, '导入失败'))
}
return false // 阻止 Arco Upload 自动上传
}
// 批量操作辅助
async function runBatch(
action: 'run' | 'enable' | 'disable' | 'delete',
) {
if (selectedIds.length === 0) {
Message.info('请先选择要操作的任务')
return
}
if (action === 'delete' && !window.confirm(`确定删除 ${selectedIds.length} 个任务?操作不可撤销。`)) {
return
}
setBatchLoading(true)
try {
let results
switch (action) {
case 'run':
results = await batchRunTasks(selectedIds)
break
case 'enable':
results = await batchToggleTasks(selectedIds, true)
break
case 'disable':
results = await batchToggleTasks(selectedIds, false)
break
case 'delete':
results = await batchDeleteTasks(selectedIds)
break
}
const succ = results.filter((r) => r.success).length
const fail = results.length - succ
if (fail === 0) {
Message.success(`成功处理 ${succ} 个任务`)
} else {
Message.warning(`成功 ${succ} / 失败 ${fail},详情见通知`)
}
setSelectedIds([])
await loadData()
} catch (e) {
Message.error(resolveErrorMessage(e, '批量操作失败'))
} finally {
setBatchLoading(false)
}
}
async function handleCreateStorageTarget(value: StorageTargetPayload) {
const result = await createStorageTarget(value)
Message.success('存储目标已创建')
return result
}
async function handleTestStorageTarget(value: StorageTargetPayload) {
const result = await testStorageTarget(value)
Message.success(result.message)
return result
}
async function handleGoogleDriveAuth(value: StorageTargetPayload, targetId?: number) {
const result = await startGoogleDriveAuth(value, targetId)
window.open(result.authUrl, '_blank')
}
async function reloadStorageTargets() {
const targetList = await listStorageTargets()
setStorageTargets(targetList)
}
const columns = [
{
title: '任务名称',
dataIndex: 'name',
render: (_: unknown, record: BackupTaskSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text bold>{record.name}</Typography.Text>
<Space>
{getBackupTaskTypeLabel(record.type) && <Tag color="arcoblue" bordered>{getBackupTaskTypeLabel(record.type)}</Tag>}
{record.enabled !== undefined && (
<Tag color={record.enabled ? 'green' : 'gray'} bordered>{record.enabled ? '已启用' : '已停用'}</Tag>
)}
</Space>
</Space>
),
},
{
title: '调度',
dataIndex: 'cronExpr',
render: (value: string) => value || '仅手动执行',
},
{
title: '存储目标',
dataIndex: 'storageTargetNames',
render: (_: unknown, record: BackupTaskSummary) => {
const names = record.storageTargetNames?.length > 0 ? record.storageTargetNames : record.storageTargetName ? [record.storageTargetName] : []
if (names.length === 0) return '-'
return (
<Space size={4} wrap>
{names.map((name, i) => (
<Tag key={i} color="arcoblue" bordered>{name}</Tag>
))}
</Space>
)
},
},
{
title: '策略',
dataIndex: 'retentionDays',
render: (_: unknown, record: BackupTaskSummary) => `${record.retentionDays} 天 / ${record.maxBackups}`,
},
{
title: '标签',
dataIndex: 'tags',
render: (value: string) => {
const items = (value ?? '').split(',').map((t) => t.trim()).filter(Boolean)
if (items.length === 0) return <span style={{ color: 'var(--color-text-3)' }}>-</span>
return (
<Space size={4} wrap>
{items.map((tag) => <Tag key={tag} color="gray" bordered size="small">{tag}</Tag>)}
</Space>
)
},
},
{
title: 'SLA',
dataIndex: 'slaHoursRpo',
render: (value: number, record: BackupTaskSummary) => {
if (value <= 0) return <span style={{ color: 'var(--color-text-3)' }}></span>
// 简单着色:仅根据是否启用验证/SLA 显示徽章(实时 SLA 违约见 Dashboard
const bits = [<Tag key="rpo" color="arcoblue" bordered size="small">RPO {value}h</Tag>]
if (record.verifyEnabled) bits.push(<Tag key="verify" color="green" bordered size="small"></Tag>)
return <Space size={4} wrap>{bits}</Space>
},
},
{
title: '最近状态',
render: (value: BackupTaskSummary['lastStatus']) => {
const label = getBackupTaskStatusLabel(value)
return label ? <Tag color={getBackupTaskStatusColor(value)} bordered>{label}</Tag> : <span style={{ color: 'var(--color-text-3)' }}>-</span>
},
},
{
title: '最近执行',
dataIndex: 'lastRunAt',
render: (value?: string) => formatDateTime(value),
},
{
title: '操作',
dataIndex: 'actions',
width: 280,
render: (_: unknown, record: BackupTaskSummary) => (
<Space wrap size="mini">
<Button size="small" type="text" onClick={() => void openDetail(record.id)}>
</Button>
{writable && (
<Button size="small" type="text" onClick={() => void openEdit(record.id)} loading={submitting && editingTask?.id === record.id}>
</Button>
)}
{writable && (
<Button size="small" type="text" status="success" onClick={() => void handleRun(record)}>
</Button>
)}
{writable && (
<Button size="small" type="text" onClick={() => void handleToggle(record)}>
{record.enabled ? '停用' : '启用'}
</Button>
)}
{writable && (
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(record)}>
</Button>
)}
</Space>
),
},
]
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<PageHeader
style={{ paddingBottom: 16 }}
title="备份任务"
subTitle="管理文件目录、MySQL、SQLite 与 PostgreSQL 的备份计划,并支持立即执行"
extra={
<Space>
<Button size="small" onClick={() => void handleExport()}>
JSON
</Button>
{writable && (
<Upload
accept=".json"
showUploadList={false}
beforeUpload={(file) => handleImport(file)}
>
<Button size="small"> JSON</Button>
</Upload>
)}
{writable && (
<Button
type="primary"
disabled={enabledStorageTargets.length === 0}
onClick={() => {
setEditingTask(null)
setDrawerVisible(true)
}}
>
</Button>
)}
</Space>
}
/>
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
{enabledStorageTargets.length === 0 ? (
<Card>
<Empty description="请先启用至少一个存储目标,再创建备份任务。" />
</Card>
) : null}
<TaskDependencyGraph tasks={tasks} />
{availableTags.length > 0 && (
<Card size="small">
<Space wrap>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>:</Typography.Text>
<Select
mode="multiple"
placeholder="选择标签进行过滤(多标签取交集)"
style={{ minWidth: 300 }}
value={tagFilter}
options={availableTags.map((tag) => ({ label: tag, value: tag }))}
onChange={(values) => setTagFilter(values as string[])}
allowClear
/>
{tagFilter.length > 0 && (
<Button size="small" type="text" onClick={() => setTagFilter([])}>
</Button>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{filteredTasks.length} / {tasks.length}
</Typography.Text>
</Space>
</Card>
)}
{writable && selectedIds.length > 0 && (
<Card size="small" style={{ backgroundColor: 'var(--color-fill-2)' }}>
<Space wrap>
<Typography.Text bold> {selectedIds.length} </Typography.Text>
<Button size="small" type="primary" loading={batchLoading} onClick={() => void runBatch('run')}></Button>
<Button size="small" loading={batchLoading} onClick={() => void runBatch('enable')}></Button>
<Button size="small" loading={batchLoading} onClick={() => void runBatch('disable')}></Button>
<Button size="small" status="danger" loading={batchLoading} onClick={() => void runBatch('delete')}></Button>
<Button size="small" type="text" onClick={() => setSelectedIds([])}></Button>
</Space>
</Card>
)}
<Card>
<Table
rowKey="id"
loading={loading}
columns={columns}
data={filteredTasks}
pagination={{ pageSize: 10 }}
stripe
noDataElement={<Empty description={tagFilter.length > 0 ? "当前筛选下无任务" : "暂无备份任务,请先点击右上角创建任务"} />}
rowSelection={writable ? {
type: 'checkbox',
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map((k) => Number(k))),
} : undefined}
/>
</Card>
<BackupTaskFormDrawer
visible={drawerVisible}
loading={submitting}
initialValue={editingTask}
storageTargets={enabledStorageTargets}
localNodeId={localNodeId}
nodes={nodes}
allTasks={tasks.map((t) => ({ id: t.id, name: t.name }))}
onCancel={() => {
setDrawerVisible(false)
setEditingTask(null)
}}
onSubmit={handleSubmit}
onCreateStorageTarget={handleCreateStorageTarget}
onTestStorageTarget={handleTestStorageTarget}
onGoogleDriveAuth={handleGoogleDriveAuth}
onStorageTargetCreated={reloadStorageTargets}
/>
<BackupTaskDetailDrawer
visible={detailVisible}
task={detailTask}
onCancel={() => {
setDetailVisible(false)
setDetailTask(null)
}}
/>
<Modal
visible={importResults !== null}
title="导入结果"
footer={null}
onCancel={() => setImportResults(null)}
style={{ width: 640 }}
>
{importResults && (
<Table
rowKey="name"
pagination={false}
data={importResults}
size="small"
columns={[
{ title: '任务名', dataIndex: 'name' },
{ title: '状态', render: (_: unknown, r: TaskImportResult) => (
r.skipped ? <Tag color="gray" bordered></Tag>
: r.success ? <Tag color="green" bordered></Tag>
: <Tag color="red" bordered></Tag>
)},
{ title: 'ID', dataIndex: 'taskId', render: (v?: number) => v ? `#${v}` : '-' },
{ title: '说明', dataIndex: 'error', render: (v?: string) => v || '-' },
]}
/>
)}
</Modal>
</Space>
)
}