功能: 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 726c5e134b
commit f7596bd319
130 changed files with 14184 additions and 382 deletions

View File

@@ -0,0 +1,177 @@
import { Alert, Button, Card, Empty, Form, Input, InputNumber, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { createApiKey, listApiKeys, revokeApiKey, toggleApiKey, type ApiKeyCreateInput, type ApiKeySummary } from '../../services/api-keys'
import type { UserRole } from '../../services/users'
import { useAuthStore } from '../../stores/auth'
import { resolveErrorMessage } from '../../utils/error'
import { isAdmin, roleLabel } from '../../utils/permissions'
import { formatDateTime } from '../../utils/format'
const roleOptions = [
{ label: '管理员 (admin)', value: 'admin' },
{ label: '运维 (operator)', value: 'operator' },
{ label: '只读 (viewer)', value: 'viewer' },
]
// ApiKeysPage API Key 管理admin 专属)。
// 新创建的 Key 明文只返回一次,需要用户立即保存。
export function ApiKeysPage() {
const user = useAuthStore((s) => s.user)
const [items, setItems] = useState<ApiKeySummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [modalVisible, setModalVisible] = useState(false)
const [draft, setDraft] = useState<ApiKeyCreateInput>({ name: '', role: 'viewer', ttlHours: 0 })
const [submitting, setSubmitting] = useState(false)
const [plainKey, setPlainKey] = useState<string>('')
const load = useCallback(async () => {
setLoading(true)
try {
setItems(await listApiKeys())
setError('')
} catch (e) {
setError(resolveErrorMessage(e, '加载 API Key 失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
function openCreate() {
setDraft({ name: '', role: 'viewer', ttlHours: 0 })
setPlainKey('')
setModalVisible(true)
}
async function handleSubmit() {
if (!draft.name.trim()) {
Message.error('名称不能为空')
return
}
setSubmitting(true)
try {
const result = await createApiKey(draft)
setPlainKey(result.plainKey)
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '创建失败'))
} finally {
setSubmitting(false)
}
}
async function handleToggle(item: ApiKeySummary) {
try {
await toggleApiKey(item.id, !item.disabled)
Message.success(item.disabled ? '已启用' : '已停用')
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '操作失败'))
}
}
async function handleRevoke(item: ApiKeySummary) {
if (!window.confirm(`确定撤销 API Key「${item.name}」?操作不可撤销。`)) return
try {
await revokeApiKey(item.id)
Message.success('已撤销')
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '撤销失败'))
}
}
async function copyPlainKey() {
if (!plainKey) return
try {
await navigator.clipboard.writeText(plainKey)
Message.success('已复制到剪贴板')
} catch {
Message.info('请手动选择文本复制')
}
}
if (!isAdmin(user)) {
return <Alert type="warning" content="当前账号无权访问 API Key 管理(仅 admin" />
}
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title heading={4}>API Key</Typography.Title>
<Typography.Paragraph type="secondary">
API Key CI/CD访 BackupX <Typography.Text code>Authorization: Bearer bax_xxx</Typography.Text> <Typography.Text code>X-Api-Key: bax_xxx</Typography.Text>
</Typography.Paragraph>
</div>
<Space>
<Button type="primary" onClick={openCreate}> API Key</Button>
</Space>
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
<Card>
<Table
rowKey="id"
loading={loading}
data={items}
pagination={false}
stripe
noDataElement={<Empty description="暂无 API Key" />}
columns={[
{ title: '名称', dataIndex: 'name' },
{ title: '角色', dataIndex: 'role', render: (v: string) => <Tag color="arcoblue" bordered>{roleLabel(v)}</Tag> },
{ title: 'Key 前缀', dataIndex: 'prefix', render: (v: string) => <Typography.Text code>{v}</Typography.Text> },
{ title: '创建者', dataIndex: 'createdBy', render: (v: string) => v || '-' },
{ title: '最近使用', dataIndex: 'lastUsedAt', render: (v?: string) => v ? formatDateTime(v) : '从未使用' },
{ title: '过期', dataIndex: 'expiresAt', render: (v?: string) => v ? formatDateTime(v) : '永不过期' },
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered></Tag> : <Tag color="green" bordered></Tag> },
{ title: '操作', width: 180, render: (_: unknown, row: ApiKeySummary) => (
<Space>
<Button size="small" type="text" onClick={() => void handleToggle(row)}>{row.disabled ? '启用' : '停用'}</Button>
<Button size="small" type="text" status="danger" onClick={() => void handleRevoke(row)}></Button>
</Space>
) },
]}
/>
</Card>
<Modal
visible={modalVisible}
title="生成 API Key"
onCancel={() => { setModalVisible(false); setPlainKey('') }}
onOk={plainKey ? () => { setModalVisible(false); setPlainKey('') } : handleSubmit}
okText={plainKey ? '完成' : '生成'}
confirmLoading={submitting}
unmountOnExit
>
{plainKey ? (
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Alert type="warning" content="明文 Key 只会显示一次,请立即妥善保存。" />
<Input.TextArea value={plainKey} autoSize readOnly />
<Button type="outline" onClick={() => void copyPlainKey()}></Button>
</Space>
) : (
<Form layout="vertical">
<Form.Item label="名称" required>
<Input value={draft.name} onChange={(v) => setDraft({ ...draft, name: v })} placeholder="例如ci-deploy-script" />
</Form.Item>
<Form.Item label="角色" required>
<Select value={draft.role} options={roleOptions} onChange={(v: UserRole) => setDraft({ ...draft, role: v })} />
</Form.Item>
<Form.Item label="有效期小时0=永不过期)">
<InputNumber style={{ width: '100%' }} min={0} value={draft.ttlHours ?? 0} onChange={(v) => setDraft({ ...draft, ttlHours: Number(v ?? 0) })} />
</Form.Item>
</Form>
)}
</Modal>
</Space>
)
}
// 避免未使用告警
void Switch

View File

@@ -0,0 +1,179 @@
import { Alert, Button, Card, Empty, Form, Input, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { createUser, deleteUser, listUsers, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users'
import { useAuthStore } from '../../stores/auth'
import { resolveErrorMessage } from '../../utils/error'
import { isAdmin, roleLabel } from '../../utils/permissions'
const roleOptions = [
{ label: '管理员 (admin)', value: 'admin' },
{ label: '运维 (operator)', value: 'operator' },
{ label: '只读 (viewer)', value: 'viewer' },
]
function createEmpty(): UserUpsertPayload {
return { username: '', password: '', displayName: '', email: '', role: 'operator', disabled: false }
}
// UsersPage admin 用户管理。非 admin 角色进入路由会被路由守卫拦截。
export function UsersPage() {
const user = useAuthStore((s) => s.user)
const [items, setItems] = useState<UserSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editing, setEditing] = useState<UserSummary | null>(null)
const [modalVisible, setModalVisible] = useState(false)
const [draft, setDraft] = useState<UserUpsertPayload>(createEmpty())
const [submitting, setSubmitting] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
setItems(await listUsers())
setError('')
} catch (e) {
setError(resolveErrorMessage(e, '加载用户失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
function openCreate() {
setEditing(null)
setDraft(createEmpty())
setModalVisible(true)
}
function openEdit(item: UserSummary) {
setEditing(item)
setDraft({
username: item.username,
password: '',
displayName: item.displayName,
email: item.email,
role: item.role,
disabled: item.disabled,
})
setModalVisible(true)
}
async function handleSubmit() {
if (!draft.username.trim() || !draft.displayName.trim()) {
Message.error('用户名与显示名称不能为空')
return
}
if (!editing && !draft.password?.trim()) {
Message.error('创建用户必须设置初始密码')
return
}
setSubmitting(true)
try {
if (editing) {
await updateUser(editing.id, draft)
Message.success('用户已更新')
} else {
await createUser(draft)
Message.success('用户已创建')
}
setModalVisible(false)
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '保存失败'))
} finally {
setSubmitting(false)
}
}
async function handleDelete(item: UserSummary) {
if (!window.confirm(`确定删除用户「${item.username}」吗?`)) return
try {
await deleteUser(item.id)
Message.success('已删除')
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '删除失败'))
}
}
if (!isAdmin(user)) {
return <Alert type="warning" content="当前账号无权访问用户管理(仅 admin" />
}
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title heading={4}></Typography.Title>
<Typography.Paragraph type="secondary"></Typography.Paragraph>
</div>
<Space>
<Button type="primary" onClick={openCreate}></Button>
</Space>
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
<Card>
<Table
rowKey="id"
loading={loading}
data={items}
pagination={false}
stripe
noDataElement={<Empty description="暂无用户" />}
columns={[
{ title: '用户名', dataIndex: 'username', render: (value: string, row: UserSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text bold>{value}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.displayName}</Typography.Text>
</Space>
) },
{ title: '角色', dataIndex: 'role', render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag> },
{ title: '邮箱', dataIndex: 'email', render: (v: string) => v || '-' },
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered></Tag> : <Tag color="green" bordered></Tag> },
{ title: '创建时间', dataIndex: 'createdAt' },
{ title: '操作', width: 180, render: (_: unknown, row: UserSummary) => (
<Space>
<Button size="small" type="text" onClick={() => openEdit(row)}></Button>
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(row)} disabled={row.id === user?.id}></Button>
</Space>
) },
]}
/>
</Card>
<Modal
visible={modalVisible}
title={editing ? '编辑用户' : '新建用户'}
onCancel={() => setModalVisible(false)}
onOk={handleSubmit}
confirmLoading={submitting}
unmountOnExit
>
<Form layout="vertical">
<Form.Item label="用户名" required>
<Input value={draft.username} onChange={(v) => setDraft({ ...draft, username: v })} disabled={!!editing} />
</Form.Item>
<Form.Item label="显示名称" required>
<Input value={draft.displayName} onChange={(v) => setDraft({ ...draft, displayName: v })} />
</Form.Item>
<Form.Item label="邮箱">
<Input value={draft.email} onChange={(v) => setDraft({ ...draft, email: v })} />
</Form.Item>
<Form.Item label={editing ? '新密码(留空不修改)' : '初始密码'} required={!editing}>
<Input.Password value={draft.password} onChange={(v) => setDraft({ ...draft, password: v })} />
</Form.Item>
<Form.Item label="角色" required>
<Select value={draft.role} options={roleOptions} onChange={(v: UserRole) => setDraft({ ...draft, role: v })} />
</Form.Item>
<Form.Item label="停用账号">
<Switch checked={draft.disabled} onChange={(v) => setDraft({ ...draft, disabled: v })} />
</Form.Item>
</Form>
</Modal>
</Space>
)
}

View File

@@ -1,7 +1,7 @@
import { PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
import { Button, DatePicker, Input, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
import type { ColumnProps } from '@arco-design/web-react/es/Table'
import { useCallback, useEffect, useState } from 'react'
import { listAuditLogs } from '../../services/audit'
import { exportAuditLogs, listAuditLogs } from '../../services/audit'
import type { AuditLog } from '../../types/audit'
import { formatDateTime } from '../../utils/format'
import { resolveErrorMessage } from '../../utils/error'
@@ -90,13 +90,21 @@ export function AuditLogsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [category, setCategory] = useState('')
const [username, setUsername] = useState('')
const [keyword, setKeyword] = useState('')
const [dateRange, setDateRange] = useState<string[] | null>(null)
const [page, setPage] = useState(1)
const [exporting, setExporting] = useState(false)
const fetchData = useCallback(async (cat: string, currentPage: number) => {
const fetchData = useCallback(async (currentPage: number) => {
setLoading(true)
try {
const result = await listAuditLogs({
category: cat || undefined,
category: category || undefined,
username: username.trim() || undefined,
keyword: keyword.trim() || undefined,
dateFrom: dateRange?.[0] ? new Date(dateRange[0]).toISOString() : undefined,
dateTo: dateRange?.[1] ? new Date(dateRange[1]).toISOString() : undefined,
limit: PAGE_SIZE,
offset: (currentPage - 1) * PAGE_SIZE,
})
@@ -108,14 +116,35 @@ export function AuditLogsPage() {
} finally {
setLoading(false)
}
}, [])
}, [category, username, keyword, dateRange])
useEffect(() => {
void fetchData(category, page)
}, [category, page, fetchData])
void fetchData(page)
}, [page, fetchData])
function handleCategoryChange(value: string) {
setCategory(value)
async function handleExport() {
setExporting(true)
try {
await exportAuditLogs({
category: category || undefined,
username: username.trim() || undefined,
keyword: keyword.trim() || undefined,
dateFrom: dateRange?.[0] ? new Date(dateRange[0]).toISOString() : undefined,
dateTo: dateRange?.[1] ? new Date(dateRange[1]).toISOString() : undefined,
})
Message.success('CSV 已开始下载')
} catch (e) {
Message.error(resolveErrorMessage(e, '导出失败'))
} finally {
setExporting(false)
}
}
function handleReset() {
setCategory('')
setUsername('')
setKeyword('')
setDateRange(null)
setPage(1)
}
@@ -124,17 +153,39 @@ export function AuditLogsPage() {
<PageHeader
style={{ paddingBottom: 0 }}
title="审计日志"
subTitle="记录系统中所有关键操作,保障数据操作链可溯源"
subTitle="记录系统中所有关键操作,保障数据操作链可溯源。支持高级筛选与 CSV 导出(最多 10000 行)。"
/>
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
<Space>
<Space wrap>
<Select
style={{ width: 160 }}
value={category}
options={categoryOptions}
onChange={handleCategoryChange}
placeholder="筛选分类"
onChange={(v) => { setCategory(v); setPage(1) }}
placeholder="分类"
/>
<Input
style={{ width: 160 }}
value={username}
placeholder="用户名"
onChange={setUsername}
onPressEnter={() => { setPage(1); void fetchData(1) }}
/>
<Input
style={{ width: 240 }}
value={keyword}
placeholder="关键词(详情/目标名)"
onChange={setKeyword}
onPressEnter={() => { setPage(1); void fetchData(1) }}
/>
<DatePicker.RangePicker
showTime
value={dateRange ?? undefined}
onChange={(v) => { setDateRange(v as string[] | null); setPage(1) }}
/>
<Button type="primary" onClick={() => { setPage(1); void fetchData(1) }}></Button>
<Button onClick={handleReset}></Button>
<Button type="outline" loading={exporting} onClick={() => void handleExport()}> CSV</Button>
</Space>
<Table
columns={columns}

View File

@@ -1,19 +1,25 @@
import { Button, Card, Empty, Message, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
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 { createBackupTask, deleteBackupTask, getBackupTask, listBackupTasks, runBackupTask, toggleBackupTask, updateBackupTask } from '../../services/backup-tasks'
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)
@@ -24,15 +30,42 @@ export function BackupTasksPage() {
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)
@@ -129,6 +162,77 @@ export function BackupTasksPage() {
}
}
// 导出选中或全部任务为 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('存储目标已创建')
@@ -192,6 +296,30 @@ export function BackupTasksPage() {
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']) => {
@@ -213,18 +341,26 @@ export function BackupTasksPage() {
<Button size="small" type="text" onClick={() => void openDetail(record.id)}>
</Button>
<Button size="small" type="text" onClick={() => void openEdit(record.id)} loading={submitting && editingTask?.id === record.id}>
</Button>
<Button size="small" type="text" status="success" onClick={() => void handleRun(record)}>
</Button>
<Button size="small" type="text" onClick={() => void handleToggle(record)}>
{record.enabled ? '停用' : '启用'}
</Button>
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(record)}>
</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>
),
},
@@ -237,16 +373,32 @@ export function BackupTasksPage() {
title="备份任务"
subTitle="管理文件目录、MySQL、SQLite 与 PostgreSQL 的备份计划,并支持立即执行"
extra={
<Button
type="primary"
disabled={enabledStorageTargets.length === 0}
onClick={() => {
setEditingTask(null)
setDrawerVisible(true)
}}
>
</Button>
<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>
}
/>
@@ -257,8 +409,61 @@ export function BackupTasksPage() {
</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={tasks} pagination={{ pageSize: 10 }} stripe noDataElement={<Empty description="暂无备份任务,请先点击右上角创建任务" />} />
<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
@@ -267,6 +472,8 @@ export function BackupTasksPage() {
initialValue={editingTask}
storageTargets={enabledStorageTargets}
localNodeId={localNodeId}
nodes={nodes}
allTasks={tasks.map((t) => ({ id: t.id, name: t.name }))}
onCancel={() => {
setDrawerVisible(false)
setEditingTask(null)
@@ -286,6 +493,33 @@ export function BackupTasksPage() {
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>
)
}

View File

@@ -1,18 +1,19 @@
import { Avatar, Card, Empty, Grid, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircle, IconHistory, IconSave, IconStorage } from '@arco-design/web-react/icon'
import { Alert, Avatar, Card, Empty, Grid, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircle, IconDesktop, IconHistory, IconSafe, IconSave, IconStorage } from '@arco-design/web-react/icon'
import ReactEChartsCore from 'echarts-for-react/lib/core'
import * as echarts from 'echarts/core'
import { LineChart, PieChart } from 'echarts/charts'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { useEffect, useMemo, useState } from 'react'
import { fetchDashboardStats, fetchDashboardTimeline } from '../../services/dashboard'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { fetchDashboardBreakdown, fetchDashboardCluster, fetchDashboardNodePerformance, fetchDashboardSLA, fetchDashboardStats, fetchDashboardTimeline } from '../../services/dashboard'
import { useEventStream } from '../../hooks/useEventStream'
import { useAuthStore } from '../../stores/auth'
import type { BackupTimelinePoint, DashboardStats } from '../../types/dashboard'
import type { BackupTimelinePoint, BreakdownStats, ClusterOverview, DashboardStats, NodePerformance, SLAComplianceReport } from '../../types/dashboard'
import { resolveErrorMessage } from '../../utils/error'
import { formatBytes, formatDateTime, formatPercent } from '../../utils/format'
echarts.use([LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
const { Row, Col } = Grid
@@ -20,36 +21,53 @@ export function DashboardPage() {
const user = useAuthStore((state) => state.user)
const [stats, setStats] = useState<DashboardStats | null>(null)
const [timeline, setTimeline] = useState<BackupTimelinePoint[]>([])
const [sla, setSla] = useState<SLAComplianceReport | null>(null)
const [cluster, setCluster] = useState<ClusterOverview | null>(null)
const [breakdown, setBreakdown] = useState<BreakdownStats | null>(null)
const [nodePerf, setNodePerf] = useState<NodePerformance[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
let active = true
void (async () => {
setLoading(true)
try {
const [statsResult, timelineResult] = await Promise.all([fetchDashboardStats(), fetchDashboardTimeline(30)])
if (!active) {
return
}
setStats(statsResult)
setTimeline(timelineResult || [])
setError('')
} catch (loadError) {
if (active) {
setError(resolveErrorMessage(loadError, '加载仪表盘失败'))
}
} finally {
if (active) {
setLoading(false)
}
}
})()
return () => {
active = false
// 统一的数据加载入口。SSE 事件到达时复用该方法刷新。
const reload = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const [statsResult, timelineResult, slaResult, clusterResult, breakdownResult, nodePerfResult] = await Promise.all([
fetchDashboardStats(),
fetchDashboardTimeline(30),
fetchDashboardSLA(),
fetchDashboardCluster(),
fetchDashboardBreakdown(30),
fetchDashboardNodePerformance(30),
])
setStats(statsResult)
setTimeline(timelineResult || [])
setSla(slaResult)
setCluster(clusterResult)
setBreakdown(breakdownResult)
setNodePerf(nodePerfResult || [])
setError('')
} catch (loadError) {
setError(resolveErrorMessage(loadError, '加载仪表盘失败'))
} finally {
if (showLoading) setLoading(false)
}
}, [])
useEffect(() => {
void reload(true)
}, [reload])
// 订阅实时事件:备份完成 / 恢复完成 / SLA 违约 / 存储健康变化时自动刷新 Dashboard。
// 只关心会影响 Dashboard 显示的事件,避免无关事件造成频繁重渲染。
useEventStream(
() => {
// debounce 500ms短时间多条事件合并一次刷新
void reload(false)
},
['backup_success', 'backup_failed', 'restore_success', 'restore_failed', 'verify_failed', 'sla_violation', 'storage_unhealthy', 'storage_capacity_warning'],
)
const cards = useMemo(
() => [
{ label: '备份任务', value: stats?.totalTasks ?? 0, helper: `${stats?.enabledTasks ?? 0} 个已启用`, icon: <IconStorage />, color: 'var(--color-primary-6)', bg: 'var(--color-primary-1)' },
@@ -105,6 +123,53 @@ export function DashboardPage() {
],
}), [timeline])
// 任务类型分布(饼图)
const typeChartOption = useMemo(() => {
const data = (breakdown?.byType ?? []).map((item) => ({ name: item.label, value: item.count ?? 0 }))
return {
tooltip: { trigger: 'item' as const },
legend: { bottom: 0, type: 'scroll' as const },
series: [{
type: 'pie' as const,
radius: ['45%', '68%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: 'var(--color-bg-2)', borderWidth: 2 },
label: { show: false },
emphasis: { label: { show: true, fontSize: 13, fontWeight: 'bold' } },
data,
color: ['#165DFF', '#14C9C9', '#FADC19', '#FF7D00', '#722ED1', '#F53F3F'],
}],
}
}, [breakdown])
// 节点分布(柱状图)
const nodeChartOption = useMemo(() => {
const items = breakdown?.byNode ?? []
return {
tooltip: { trigger: 'axis' as const },
grid: { left: 40, right: 20, top: 20, bottom: 40 },
xAxis: {
type: 'category' as const,
data: items.map((i) => i.label),
axisLabel: { rotate: 30, fontSize: 11, color: 'var(--color-text-3)' },
axisTick: { show: false },
axisLine: { lineStyle: { color: 'var(--color-border-2)' } },
},
yAxis: {
type: 'value' as const,
minInterval: 1,
axisLabel: { color: 'var(--color-text-3)' },
splitLine: { lineStyle: { type: 'dashed', color: 'var(--color-border-2)' } },
},
series: [{
type: 'bar' as const,
data: items.map((i) => i.count ?? 0),
itemStyle: { color: 'var(--color-primary-6)', borderRadius: [4, 4, 0, 0] },
barMaxWidth: 40,
}],
}
}, [breakdown])
const storageChartOption = useMemo(() => {
const data = (stats?.storageUsage ?? []).map((s) => ({
name: s.targetName || '未命名',
@@ -188,6 +253,190 @@ export function DashboardPage() {
</Col>
</Row>
{breakdown && ((breakdown.byType ?? []).length > 0 || (breakdown.byNode ?? []).length > 0) ? (
<Row gutter={16}>
<Col span={12}>
<Card loading={loading} title="任务类型分布">
{(breakdown.byType ?? []).length > 0 ? (
<ReactEChartsCore echarts={echarts} option={typeChartOption} style={{ height: 260 }} />
) : (
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography.Text type="secondary"></Typography.Text>
</div>
)}
</Card>
</Col>
<Col span={12}>
<Card loading={loading} title="任务按节点分布">
{(breakdown.byNode ?? []).length > 0 ? (
<ReactEChartsCore echarts={echarts} option={nodeChartOption} style={{ height: 260 }} />
) : (
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography.Text type="secondary"></Typography.Text>
</div>
)}
</Card>
</Col>
</Row>
) : null}
{cluster && cluster.totalNodes > 0 ? (
<Card loading={loading} title={
<Space>
<IconDesktop />
<span></span>
<Tag bordered>Master {cluster.masterVersion || '-'}</Tag>
</Space>
}>
<Row gutter={16}>
<Col span={6}>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<Typography.Title heading={4} style={{ margin: '4px 0 0' }}>{cluster.totalNodes}</Typography.Title>
</div>
</Col>
<Col span={6}>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>线</Typography.Text>
<Typography.Title heading={4} style={{ margin: '4px 0 0', color: 'var(--color-success-6)' }}>{cluster.onlineNodes}</Typography.Title>
</div>
</Col>
<Col span={6}>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>线</Typography.Text>
<Typography.Title heading={4} style={{ margin: '4px 0 0', color: cluster.offlineNodes > 0 ? 'var(--color-danger-6)' : undefined }}>{cluster.offlineNodes}</Typography.Title>
</div>
</Col>
<Col span={6}>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Agent </Typography.Text>
<Typography.Title heading={4} style={{ margin: '4px 0 0', color: cluster.outdatedAgents > 0 ? 'var(--color-warning-6)' : undefined }}>{cluster.outdatedAgents}</Typography.Title>
</div>
</Col>
</Row>
<Table
style={{ marginTop: 16 }}
rowKey="id"
stripe
pagination={false}
data={cluster.nodes}
columns={[
{ title: '节点', dataIndex: 'name', render: (v: string, row) => (
<Space direction="vertical" size={2}>
<Typography.Text bold>{v}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.hostname || '-'}</Typography.Text>
</Space>
)},
{ title: '状态', dataIndex: 'status', render: (s: string) => <Tag color={s === 'online' ? 'green' : 'red'} bordered>{s === 'online' ? '在线' : '离线'}</Tag> },
{ title: '版本', dataIndex: 'agentVersion', render: (v: string, row) => {
const color = row.versionStatus === 'outdated' ? 'orange' : row.versionStatus === 'unknown' ? 'gray' : 'arcoblue'
const label = row.versionStatus === 'outdated' ? '过期' : row.versionStatus === 'unknown' ? '未知' : '当前'
return <Space><Typography.Text>{v || '-'}</Typography.Text><Tag color={color} bordered size="small">{label}</Tag></Space>
}},
{ title: '任务', dataIndex: 'taskCount', render: (v: number) => `${v}` },
{ title: '最近心跳', dataIndex: 'lastSeen', render: (v: string) => formatDateTime(v) },
]}
/>
</Card>
) : null}
{nodePerf.length > 0 && nodePerf.some((n) => n.totalRuns > 0) ? (
<Card loading={loading} title="节点执行表现(近 30 天)">
<Table
rowKey={(r: NodePerformance) => `${r.nodeId}-${r.nodeName}`}
stripe
pagination={false}
data={nodePerf.filter((n) => n.totalRuns > 0)}
columns={[
{ title: '节点', render: (_: unknown, r: NodePerformance) => (
<Space>
<Typography.Text bold>{r.nodeName}</Typography.Text>
{r.isLocal && <Tag bordered size="small">Master</Tag>}
</Space>
)},
{ title: '执行次数', dataIndex: 'totalRuns', render: (v: number) => `${v}` },
{ title: '成功 / 失败', render: (_: unknown, r: NodePerformance) => (
<Space>
<Typography.Text style={{ color: 'var(--color-success-6)' }}>{r.successRuns}</Typography.Text>
<Typography.Text type="secondary">/</Typography.Text>
<Typography.Text style={{ color: r.failedRuns > 0 ? 'var(--color-danger-6)' : undefined }}>{r.failedRuns}</Typography.Text>
</Space>
)},
{ title: '成功率', dataIndex: 'successRate', render: (v: number) => {
const rate = v * 100
const color = rate >= 95 ? 'var(--color-success-6)' : rate >= 80 ? 'var(--color-warning-6)' : 'var(--color-danger-6)'
return <Typography.Text style={{ color }}>{rate.toFixed(1)}%</Typography.Text>
}},
{ title: '备份总量', dataIndex: 'totalBytes', render: (v: number) => formatBytes(v) },
{ title: '平均耗时', dataIndex: 'avgDurationSecs', render: (v: number) => {
if (v <= 0) return '-'
if (v < 60) return `${v.toFixed(0)}`
return `${(v / 60).toFixed(1)}`
}},
]}
/>
</Card>
) : null}
{sla && sla.totalTasksWithSla > 0 ? (
<Card loading={loading} title={
<Space>
<IconSafe />
<span>SLA </span>
<Tag color={sla.violated === 0 ? 'green' : 'red'} bordered>
{sla.violated === 0 ? '全部达标' : `${sla.violated} 个违约`}
</Tag>
</Space>
}>
<Row gutter={16}>
<Col span={8}>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> SLA </Typography.Text>
<Typography.Title heading={4} style={{ margin: '4px 0 0' }}>{sla.totalTasksWithSla}</Typography.Title>
</div>
</Col>
<Col span={8}>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<Typography.Title heading={4} style={{ margin: '4px 0 0', color: 'var(--color-success-6)' }}>{sla.compliant}</Typography.Title>
</div>
</Col>
<Col span={8}>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<Typography.Title heading={4} style={{ margin: '4px 0 0' }}>{formatPercent(sla.coverageRate)}</Typography.Title>
</div>
</Col>
</Row>
{sla.violations.length > 0 && (
<>
<Alert type="warning" style={{ marginTop: 16 }} content={`${sla.violations.length} 个任务的 RPO 超标,请尽快排查:`} />
<Table
style={{ marginTop: 12 }}
noDataElement={<Empty description="无违约任务" />}
rowKey="taskId"
columns={[
{ title: '任务', dataIndex: 'taskName', render: (value: string, record: SLAComplianceReport['violations'][number]) => (
<Space direction="vertical" size={2}>
<Typography.Text bold>{value}</Typography.Text>
{record.nodeName ? <Typography.Text type="secondary" style={{ fontSize: 12 }}>: {record.nodeName}</Typography.Text> : null}
</Space>
) },
{ title: 'RPO 目标', dataIndex: 'slaHoursRpo', render: (value: number) => `${value} 小时` },
{ title: '距上次成功', dataIndex: 'hoursSinceLastSuccess', render: (value: number, record: SLAComplianceReport['violations'][number]) =>
record.neverSucceeded ? <Tag color="red" bordered></Tag> : `${value.toFixed(1)} 小时`,
},
{ title: '最近成功', dataIndex: 'lastSuccessAt', render: (value?: string) => formatDateTime(value) },
]}
data={sla.violations}
pagination={false}
stripe
/>
</>
)}
</Card>
) : null}
<Card loading={loading} title="最近备份记录">
<Table
noDataElement={<Empty description="暂无近期运行记录" />}

View File

@@ -0,0 +1,116 @@
import { Card, Empty, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { listReplicationRecords, type ReplicationRecordSummary, type ReplicationStatus } from '../../services/replication-records'
import { resolveErrorMessage } from '../../utils/error'
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '执行中', value: 'running' },
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
]
function statusColor(s: ReplicationStatus) {
switch (s) {
case 'success': return 'green'
case 'failed': return 'red'
default: return 'arcoblue'
}
}
function statusLabel(s: ReplicationStatus) {
switch (s) {
case 'success': return '成功'
case 'failed': return '失败'
case 'running': return '执行中'
default: return s
}
}
// ReplicationRecordsPage 展示备份复制3-2-1 规则)执行历史。
export function ReplicationRecordsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [records, setRecords] = useState<ReplicationRecordSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const status = (searchParams.get('status') ?? '') as ReplicationStatus | ''
const load = useCallback(async () => {
setLoading(true)
try {
setRecords(await listReplicationRecords({ status }))
setError('')
} catch (e) {
setError(resolveErrorMessage(e, '加载复制记录失败'))
} finally {
setLoading(false)
}
}, [status])
useEffect(() => { void load() }, [load])
function setStatus(v?: string) {
const next = new URLSearchParams(searchParams)
if (!v) next.delete('status')
else next.set('status', v)
setSearchParams(next, { replace: true })
}
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title heading={4}></Typography.Title>
<Typography.Paragraph type="secondary">
3-2-1 2 1
</Typography.Paragraph>
</div>
<Card>
<Space wrap>
<div>
<Typography.Text></Typography.Text>
<Select style={{ width: 180 }} value={status} options={statusOptions} onChange={(v) => setStatus(v ? String(v) : undefined)} />
</div>
</Space>
</Card>
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
<Card>
{records.length === 0 && !loading ? (
<Empty description="暂无复制记录" />
) : (
<Table
rowKey="id"
loading={loading}
data={records}
stripe
pagination={{ pageSize: 10 }}
columns={[
{ title: '任务/状态', render: (_: unknown, r: ReplicationRecordSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text bold> #{r.taskId}</Typography.Text>
<Tag color={statusColor(r.status)} bordered>{statusLabel(r.status)}</Tag>
</Space>
)},
{ title: '源 → 目标', render: (_: unknown, r: ReplicationRecordSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text>{r.sourceTargetName || `#${r.sourceTargetId}`}</Typography.Text>
<Typography.Text type="secondary"> {r.destTargetName || `#${r.destTargetId}`}</Typography.Text>
</Space>
)},
{ title: '大小', dataIndex: 'fileSize', render: (v: number) => formatBytes(v) },
{ title: '耗时', dataIndex: 'durationSeconds', render: (v: number) => formatDuration(v) },
{ title: '触发', dataIndex: 'triggeredBy', render: (v: string) => v || '-' },
{ title: '开始时间', dataIndex: 'startedAt', render: (v: string) => formatDateTime(v) },
{ title: '错误', dataIndex: 'errorMessage', render: (v: string) => v || '-' },
]}
/>
)}
</Card>
</Space>
)
}

View File

@@ -0,0 +1,183 @@
import { Button, Card, Empty, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { RestoreRecordLogDrawer } from '../../components/restore-records/RestoreRecordLogDrawer'
import { listRestoreRecords } from '../../services/restore-records'
import type { RestoreRecordStatus, RestoreRecordSummary } from '../../types/restore-records'
import { resolveErrorMessage } from '../../utils/error'
import { formatDateTime, formatDuration } from '../../utils/format'
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '执行中', value: 'running' },
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
]
function statusColor(status: RestoreRecordStatus) {
switch (status) {
case 'success':
return 'green'
case 'failed':
return 'red'
default:
return 'arcoblue'
}
}
function statusLabel(status: RestoreRecordStatus) {
switch (status) {
case 'success':
return '成功'
case 'failed':
return '失败'
case 'running':
return '执行中'
default:
return status
}
}
export function RestoreRecordsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [records, setRecords] = useState<RestoreRecordSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const selectedRestoreId = Number(searchParams.get('restoreId') ?? 0) || undefined
const selectedStatus = (searchParams.get('status') ?? '') as RestoreRecordStatus | ''
const loadData = useCallback(async () => {
setLoading(true)
try {
const items = await listRestoreRecords({ status: selectedStatus })
setRecords(items)
setError('')
} catch (loadError) {
setError(resolveErrorMessage(loadError, '加载恢复记录失败'))
} finally {
setLoading(false)
}
}, [selectedStatus])
useEffect(() => {
void loadData()
}, [loadData])
function updateSearchParam(key: 'status' | 'restoreId', value?: string) {
const next = new URLSearchParams(searchParams)
if (!value) {
next.delete(key)
} else {
next.set(key, value)
}
setSearchParams(next, { replace: true })
}
const columns = [
{
title: '任务 / 状态',
dataIndex: 'taskName',
render: (_: unknown, record: RestoreRecordSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text bold>{record.taskName}</Typography.Text>
<Space>
<Tag color={statusColor(record.status)} bordered>{statusLabel(record.status)}</Tag>
{record.nodeName ? (
<Tag color="arcoblue" bordered>{record.nodeName}</Tag>
) : record.nodeId === 0 ? (
<Tag color="arcoblue" bordered> Master</Tag>
) : null}
</Space>
</Space>
),
},
{
title: '源备份',
render: (_: unknown, record: RestoreRecordSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text>{record.backupFileName || `#${record.backupRecordId}`}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> ID: {record.backupRecordId}</Typography.Text>
</Space>
),
},
{
title: '开始 / 完成',
dataIndex: 'startedAt',
render: (_: unknown, record: RestoreRecordSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text>{formatDateTime(record.startedAt)}</Typography.Text>
<Typography.Text type="secondary">{formatDateTime(record.completedAt)}</Typography.Text>
</Space>
),
},
{
title: '耗时',
dataIndex: 'durationSeconds',
render: (value: number) => formatDuration(value),
},
{
title: '触发人',
dataIndex: 'triggeredBy',
render: (value: string) => value || '-',
},
{
title: '错误信息',
dataIndex: 'errorMessage',
render: (value: string) => value || '-',
},
{
title: '操作',
dataIndex: 'actions',
width: 120,
render: (_: unknown, record: RestoreRecordSummary) => (
<Button size="small" type="text" onClick={() => updateSearchParam('restoreId', String(record.id))}>
</Button>
),
},
]
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title heading={4}></Typography.Title>
<Typography.Paragraph type="secondary">
Master Agent
</Typography.Paragraph>
</div>
<Card>
<Space wrap>
<div>
<Typography.Text></Typography.Text>
<Select style={{ width: 180 }} value={selectedStatus} options={statusOptions} onChange={(value) => updateSearchParam('status', value ? String(value) : undefined)} />
</div>
<Button type="outline" onClick={() => {
const next = new URLSearchParams(searchParams)
next.delete('status')
setSearchParams(next, { replace: true })
}}>
</Button>
</Space>
</Card>
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
<Card>
{records.length === 0 && !loading ? (
<Empty description="暂无恢复记录" />
) : (
<Table rowKey="id" loading={loading} columns={columns} data={records} pagination={{ pageSize: 10 }} stripe noDataElement={<Empty description="暂无符合条件的恢复记录" />} />
)}
</Card>
<RestoreRecordLogDrawer
visible={Boolean(selectedRestoreId)}
restoreId={selectedRestoreId}
onCancel={() => updateSearchParam('restoreId', undefined)}
/>
</Space>
)
}

View File

@@ -1,17 +1,20 @@
import { Alert, Button, Card, Empty, Grid, Message, PageHeader, Space, Spin, Tag, Typography } from '@arco-design/web-react'
import { Alert, Button, Card, Empty, Grid, Message, PageHeader, Progress, Space, Spin, Tag, Typography } from '@arco-design/web-react'
import axios from 'axios'
import { useCallback, useEffect, useState } from 'react'
import {
createStorageTarget,
deleteStorageTarget,
getStorageTarget,
getStorageTargetUsage,
listStorageTargets,
startGoogleDriveAuth,
testSavedStorageTarget,
testStorageTarget,
toggleStorageTargetStar,
type StorageTargetUsage,
updateStorageTarget,
} from '../../services/storage-targets'
import { formatBytes } from '../../utils/format'
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
import { getStorageTargetTypeLabel } from '../../components/storage-targets/field-config'
import { StorageTargetFormDrawer } from '../../components/storage-targets/StorageTargetFormDrawer'
@@ -42,6 +45,7 @@ export function StorageTargetsPage() {
const [drawerVisible, setDrawerVisible] = useState(false)
const [editingTarget, setEditingTarget] = useState<StorageTargetDetail | null>(null)
const [error, setError] = useState('')
const [usageMap, setUsageMap] = useState<Record<number, StorageTargetUsage>>({})
const loadTargets = useCallback(async () => {
setLoading(true)
@@ -49,6 +53,22 @@ export function StorageTargetsPage() {
const result = await listStorageTargets()
setTargets(result)
setError('')
// 异步加载每个启用目标的使用量(容量 About。失败不阻塞列表展示。
const usageEntries = await Promise.all(
result.filter((t) => t.enabled).map(async (t) => {
try {
const u = await getStorageTargetUsage(t.id)
return [t.id, u] as const
} catch {
return null
}
}),
)
const next: Record<number, StorageTargetUsage> = {}
for (const entry of usageEntries) {
if (entry) next[entry[0]] = entry[1]
}
setUsageMap(next)
} catch (loadError) {
setError(resolveErrorMessage(loadError))
} finally {
@@ -218,6 +238,37 @@ export function StorageTargetsPage() {
{target.lastTestMessage ? (
<Typography.Paragraph type="secondary">{target.lastTestMessage}</Typography.Paragraph>
) : null}
{(() => {
const usage = usageMap[target.id]
if (!usage) return null
const disk = usage.diskUsage
// 优先后端 About远端真实容量否则展示"已用量"(累计备份大小)
if (disk && disk.total && disk.used !== undefined) {
const rate = disk.total > 0 ? disk.used / disk.total : 0
const percent = Math.round(rate * 100)
const color = rate >= 0.85 ? '#F53F3F' : rate >= 0.7 ? '#FF7D00' : '#00B42A'
return (
<div>
<Space size="mini" style={{ marginBottom: 4 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>使 {percent}%</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatBytes(disk.used)} / {formatBytes(disk.total)}
</Typography.Text>
{rate >= 0.85 && <Tag color="red" bordered size="small"></Tag>}
</Space>
<Progress percent={percent} color={color} size="small" showText={false} />
</div>
)
}
if (usage.totalSize > 0) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatBytes(usage.totalSize)}{usage.recordCount}
</Typography.Text>
)
}
return null
})()}
<Typography.Text type="secondary">{target.updatedAt}</Typography.Text>
<Space wrap size="mini">

View File

@@ -0,0 +1,207 @@
import { Alert, Button, Card, Empty, Form, Input, InputNumber, Message, Modal, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { applyTaskTemplate, deleteTaskTemplate, getTaskTemplate, listTaskTemplates, type TaskTemplateApplyResult, type TaskTemplateSummary, type TaskTemplateVariables } from '../../services/task-templates'
import { useAuthStore } from '../../stores/auth'
import { resolveErrorMessage } from '../../utils/error'
import { canWrite } from '../../utils/permissions'
import { formatDateTime } from '../../utils/format'
interface VariableRow extends TaskTemplateVariables {
key: string
}
function newRow(defaults?: Partial<TaskTemplateVariables>): VariableRow {
return {
key: Math.random().toString(36).slice(2),
name: '',
sourcePath: defaults?.sourcePath ?? '',
dbHost: defaults?.dbHost ?? '',
dbName: defaults?.dbName ?? '',
tags: defaults?.tags ?? '',
}
}
// TaskTemplatesPage 任务模板管理 + 批量创建。
// 仅 operator/admin 角色看到全部操作viewer 仅查看列表。
export function TaskTemplatesPage() {
const user = useAuthStore((s) => s.user)
const writable = canWrite(user)
const [items, setItems] = useState<TaskTemplateSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [applyVisible, setApplyVisible] = useState(false)
const [applyTemplateId, setApplyTemplateId] = useState<number | null>(null)
const [applyTemplateName, setApplyTemplateName] = useState('')
const [rows, setRows] = useState<VariableRow[]>([newRow()])
const [applyResult, setApplyResult] = useState<TaskTemplateApplyResult[] | null>(null)
const [applying, setApplying] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
setItems(await listTaskTemplates())
setError('')
} catch (e) {
setError(resolveErrorMessage(e, '加载任务模板失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => { void load() }, [load])
async function openApply(item: TaskTemplateSummary) {
try {
const detail = await getTaskTemplate(item.id)
setApplyTemplateId(item.id)
setApplyTemplateName(item.name)
setRows([newRow({ sourcePath: detail.payload.sourcePath, dbHost: detail.payload.dbHost, dbName: detail.payload.dbName, tags: detail.payload.tags })])
setApplyResult(null)
setApplyVisible(true)
} catch (e) {
Message.error(resolveErrorMessage(e, '加载模板失败'))
}
}
async function handleApply() {
if (!applyTemplateId) return
const variables: TaskTemplateVariables[] = rows
.filter((r) => r.name.trim())
.map((r) => ({
name: r.name.trim(),
sourcePath: r.sourcePath?.trim() || undefined,
dbHost: r.dbHost?.trim() || undefined,
dbName: r.dbName?.trim() || undefined,
tags: r.tags?.trim() || undefined,
nodeId: r.nodeId,
}))
if (variables.length === 0) {
Message.error('至少填写一条任务名称')
return
}
setApplying(true)
try {
const result = await applyTaskTemplate(applyTemplateId, variables)
setApplyResult(result)
const succ = result.filter((r) => r.success).length
Message.success(`已创建 ${succ}/${result.length} 个任务`)
} catch (e) {
Message.error(resolveErrorMessage(e, '应用模板失败'))
} finally {
setApplying(false)
}
}
async function handleDelete(item: TaskTemplateSummary) {
if (!window.confirm(`确定删除模板「${item.name}」?`)) return
try {
await deleteTaskTemplate(item.id)
Message.success('已删除')
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '删除失败'))
}
}
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title heading={4}></Typography.Title>
<Typography.Paragraph type="secondary">
100+ "保存为模板"
</Typography.Paragraph>
</div>
{error ? <Alert type="error" content={error} /> : null}
<Card>
{items.length === 0 && !loading ? (
<Empty description="暂无模板。在创建任务时勾选'保存为模板'或通过 API 创建。" />
) : (
<Table
rowKey="id"
loading={loading}
data={items}
stripe
pagination={false}
columns={[
{ title: '名称', render: (_: unknown, r: TaskTemplateSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text bold>{r.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{r.description || '-'}</Typography.Text>
</Space>
)},
{ title: '类型', dataIndex: 'taskType', render: (v: string) => <Tag color="arcoblue" bordered>{v.toUpperCase()}</Tag> },
{ title: '创建者', dataIndex: 'createdBy', render: (v: string) => v || '-' },
{ title: '创建时间', dataIndex: 'createdAt', render: (v: string) => formatDateTime(v) },
{ title: '操作', width: 240, render: (_: unknown, r: TaskTemplateSummary) => (
<Space>
{writable && <Button size="small" type="primary" onClick={() => void openApply(r)}></Button>}
{writable && <Button size="small" type="text" status="danger" onClick={() => void handleDelete(r)}></Button>}
</Space>
)},
]}
/>
)}
</Card>
<Modal
visible={applyVisible}
title={`应用模板:${applyTemplateName}`}
onCancel={() => setApplyVisible(false)}
onOk={applyResult ? () => setApplyVisible(false) : handleApply}
okText={applyResult ? '完成' : '批量创建'}
confirmLoading={applying}
style={{ width: 780 }}
unmountOnExit
>
{applyResult ? (
<Table
rowKey="name"
pagination={false}
data={applyResult}
columns={[
{ title: '任务名', dataIndex: 'name' },
{ title: '结果', dataIndex: 'success', render: (v: boolean) => v ? <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 || '-' },
]}
/>
) : (
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Alert type="info" content="每行一个任务。仅 name 必填;其他字段非空时覆盖模板。" />
<Table
rowKey="key"
pagination={false}
data={rows}
size="small"
columns={[
{ title: '任务名 *', width: 160, render: (_: unknown, r: VariableRow, idx: number) => (
<Input value={r.name} onChange={(v) => setRows((list) => list.map((x, i) => i === idx ? { ...x, name: v } : x))} placeholder="如prod-web-1" />
)},
{ title: '源路径', width: 200, render: (_: unknown, r: VariableRow, idx: number) => (
<Input value={r.sourcePath} onChange={(v) => setRows((list) => list.map((x, i) => i === idx ? { ...x, sourcePath: v } : x))} placeholder="/var/www" />
)},
{ title: '数据库主机', width: 140, render: (_: unknown, r: VariableRow, idx: number) => (
<Input value={r.dbHost} onChange={(v) => setRows((list) => list.map((x, i) => i === idx ? { ...x, dbHost: v } : x))} placeholder="host-1" />
)},
{ title: '数据库名', width: 140, render: (_: unknown, r: VariableRow, idx: number) => (
<Input value={r.dbName} onChange={(v) => setRows((list) => list.map((x, i) => i === idx ? { ...x, dbName: v } : x))} />
)},
{ title: '', width: 60, render: (_: unknown, _r: VariableRow, idx: number) => (
<Button size="mini" type="text" status="danger" onClick={() => setRows((list) => list.filter((_, i) => i !== idx))}></Button>
)},
]}
/>
<Button type="outline" long onClick={() => setRows((list) => [...list, newRow()])}>+ </Button>
</Space>
)}
</Modal>
</Space>
)
}
// 避免未使用变量警告
void Form
void InputNumber
void Select

View File

@@ -0,0 +1,176 @@
import { Button, Card, Empty, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { VerificationRecordLogDrawer } from '../../components/verification-records/VerificationRecordLogDrawer'
import { listVerificationRecords } from '../../services/verification-records'
import type { VerificationRecordStatus, VerificationRecordSummary } from '../../types/verification-records'
import { resolveErrorMessage } from '../../utils/error'
import { formatDateTime, formatDuration } from '../../utils/format'
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '验证中', value: 'running' },
{ label: '通过', value: 'success' },
{ label: '未通过', value: 'failed' },
]
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
}
}
export function VerificationRecordsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [records, setRecords] = useState<VerificationRecordSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const selectedVerifyId = Number(searchParams.get('verifyId') ?? 0) || undefined
const selectedStatus = (searchParams.get('status') ?? '') as VerificationRecordStatus | ''
const loadData = useCallback(async () => {
setLoading(true)
try {
const items = await listVerificationRecords({ status: selectedStatus })
setRecords(items)
setError('')
} catch (e) {
setError(resolveErrorMessage(e, '加载验证记录失败'))
} finally {
setLoading(false)
}
}, [selectedStatus])
useEffect(() => {
void loadData()
}, [loadData])
function updateSearchParam(key: 'status' | 'verifyId', value?: string) {
const next = new URLSearchParams(searchParams)
if (!value) next.delete(key)
else next.set(key, value)
setSearchParams(next, { replace: true })
}
const columns = [
{
title: '任务 / 结果',
render: (_: unknown, record: VerificationRecordSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text bold>{record.taskName}</Typography.Text>
<Space>
<Tag color={statusColor(record.status)} bordered>{statusLabel(record.status)}</Tag>
<Tag bordered>{record.mode === 'deep' ? '深度' : '快速'}</Tag>
</Space>
</Space>
),
},
{
title: '摘要 / 源备份',
render: (_: unknown, record: VerificationRecordSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text>{record.summary || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
#{record.backupRecordId}{record.backupFileName ? ` (${record.backupFileName})` : ''}
</Typography.Text>
</Space>
),
},
{
title: '开始 / 完成',
render: (_: unknown, record: VerificationRecordSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text>{formatDateTime(record.startedAt)}</Typography.Text>
<Typography.Text type="secondary">{formatDateTime(record.completedAt)}</Typography.Text>
</Space>
),
},
{
title: '耗时',
dataIndex: 'durationSeconds',
render: (value: number) => formatDuration(value),
},
{
title: '触发',
dataIndex: 'triggeredBy',
render: (value: string) => value || '-',
},
{
title: '错误信息',
dataIndex: 'errorMessage',
render: (value: string) => value || '-',
},
{
title: '操作',
dataIndex: 'actions',
width: 120,
render: (_: unknown, record: VerificationRecordSummary) => (
<Button size="small" type="text" onClick={() => updateSearchParam('verifyId', String(record.id))}>
</Button>
),
},
]
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title heading={4}></Typography.Title>
<Typography.Paragraph type="secondary">
/
</Typography.Paragraph>
</div>
<Card>
<Space wrap>
<div>
<Typography.Text></Typography.Text>
<Select style={{ width: 180 }} value={selectedStatus} options={statusOptions} onChange={(value) => updateSearchParam('status', value ? String(value) : undefined)} />
</div>
<Button type="outline" onClick={() => {
const next = new URLSearchParams(searchParams)
next.delete('status')
setSearchParams(next, { replace: true })
}}>
</Button>
</Space>
</Card>
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
<Card>
{records.length === 0 && !loading ? (
<Empty description="暂无验证记录" />
) : (
<Table rowKey="id" loading={loading} columns={columns} data={records} pagination={{ pageSize: 10 }} stripe noDataElement={<Empty description="暂无验证记录" />} />
)}
</Card>
<VerificationRecordLogDrawer
visible={Boolean(selectedVerifyId)}
verifyId={selectedVerifyId}
onCancel={() => updateSearchParam('verifyId', undefined)}
/>
</Space>
)
}