mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-28 11:43:06 +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:
177
web/src/pages/admin/ApiKeysPage.tsx
Normal file
177
web/src/pages/admin/ApiKeysPage.tsx
Normal 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
|
||||
179
web/src/pages/admin/UsersPage.tsx
Normal file
179
web/src/pages/admin/UsersPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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="暂无近期运行记录" />}
|
||||
|
||||
116
web/src/pages/replication-records/ReplicationRecordsPage.tsx
Normal file
116
web/src/pages/replication-records/ReplicationRecordsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
183
web/src/pages/restore-records/RestoreRecordsPage.tsx
Normal file
183
web/src/pages/restore-records/RestoreRecordsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
207
web/src/pages/task-templates/TaskTemplatesPage.tsx
Normal file
207
web/src/pages/task-templates/TaskTemplatesPage.tsx
Normal 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
|
||||
176
web/src/pages/verification-records/VerificationRecordsPage.tsx
Normal file
176
web/src/pages/verification-records/VerificationRecordsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user