功能: 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
import { useEffect, useRef } from 'react'
import { getAccessToken } from '../services/http'
export interface SystemEvent {
type: string
title: string
body: string
fields?: Record<string, unknown>
timestamp: string
}
export type EventHandler = (event: SystemEvent) => void
/**
* useEventStream 订阅后端 SSE 实时事件流。
*
* 因 EventSource 原生不支持自定义 header这里使用 fetch + ReadableStream 解析 SSE 帧。
* 优势:带 Authorization Bearer Token无需把 token 放在 URL 里被日志记录。
*
* 连接中断时自动指数退避重连1s / 2s / 4s / ... / 最大 30s
*/
export function useEventStream(handler: EventHandler, types?: string[]) {
const handlerRef = useRef(handler)
handlerRef.current = handler
const typesKey = types ? types.sort().join(',') : ''
useEffect(() => {
let active = true
let controller: AbortController | null = null
let reconnectTimer: number | null = null
let backoff = 1000
async function connect() {
if (!active) return
controller = new AbortController()
const token = getAccessToken()
try {
const response = await fetch('/api/events/stream', {
method: 'GET',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
signal: controller.signal,
})
if (!response.ok || !response.body) {
throw new Error(`SSE 连接失败HTTP ${response.status}`)
}
backoff = 1000 // 连上后重置退避
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (active) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// 按 "\n\n" 分帧
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n')
const frame = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const event = parseFrame(frame)
if (!event) continue
if (types && !types.includes(event.type)) continue
handlerRef.current(event)
}
}
} catch (e) {
if (!active) return
// 连接断开,指数退避后重连
if (e instanceof DOMException && e.name === 'AbortError') return
reconnectTimer = window.setTimeout(connect, backoff)
backoff = Math.min(backoff * 2, 30000)
}
}
void connect()
return () => {
active = false
if (controller) controller.abort()
if (reconnectTimer) window.clearTimeout(reconnectTimer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typesKey])
}
// parseFrame 解析 SSE 帧event: type\ndata: json
function parseFrame(frame: string): SystemEvent | null {
const lines = frame.split('\n')
let eventType = ''
let dataLine = ''
for (const line of lines) {
if (line.startsWith(':')) continue // comment心跳
if (line.startsWith('event:')) {
eventType = line.slice(6).trim()
} else if (line.startsWith('data:')) {
dataLine = line.slice(5).trim()
}
}
if (!dataLine) return null
try {
const parsed = JSON.parse(dataLine) as SystemEvent
if (eventType && !parsed.type) parsed.type = eventType
return parsed
} catch {
return null
}
}

View File

@@ -4,6 +4,12 @@ import {
IconStorage,
IconFile,
IconHistory,
IconRefresh,
IconSafe,
IconCopy,
IconBook,
IconUser,
IconCommand,
IconNotification,
IconSettings,
IconMenuFold,
@@ -20,6 +26,9 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { changePassword, type ChangePasswordPayload } from '../services/auth'
import { useAuthStore } from '../stores/auth'
import { resolveErrorMessage } from '../utils/error'
import { isAdmin, roleLabel } from '../utils/permissions'
import { GlobalSearch } from '../components/common/GlobalSearch'
import { EventCenter } from '../components/common/EventCenter'
const Header = Layout.Header
const Sider = Layout.Sider
@@ -32,6 +41,15 @@ function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/backup/records')) {
return '/backup/records'
}
if (pathname.startsWith('/restore/records')) {
return '/restore/records'
}
if (pathname.startsWith('/verify/records')) {
return '/verify/records'
}
if (pathname.startsWith('/replication/records')) {
return '/replication/records'
}
if (pathname.startsWith('/storage-targets')) {
return '/storage-targets'
}
@@ -44,19 +62,41 @@ function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/nodes')) {
return '/nodes'
}
if (pathname.startsWith('/task-templates')) {
return '/task-templates'
}
if (pathname.startsWith('/admin/users')) {
return '/admin/users'
}
if (pathname.startsWith('/admin/api-keys')) {
return '/admin/api-keys'
}
if (pathname.startsWith('/settings') || pathname.startsWith('/system-info')) {
return '/settings'
}
return pathname
}
const menuItems = [
interface MenuItemConfig {
key: string
label: string
icon: React.ReactNode
adminOnly?: boolean
}
const menuItems: MenuItemConfig[] = [
{ key: '/dashboard', label: '仪表盘', icon: <IconDashboard /> },
{ key: '/backup/tasks', label: '备份任务', icon: <IconFile /> },
{ key: '/backup/records', label: '备份记录', icon: <IconHistory /> },
{ key: '/restore/records', label: '恢复记录', icon: <IconRefresh /> },
{ key: '/verify/records', label: '验证演练', icon: <IconSafe /> },
{ key: '/replication/records', label: '备份复制', icon: <IconCopy /> },
{ key: '/task-templates', label: '任务模板', icon: <IconBook /> },
{ key: '/storage-targets', label: '存储目标', icon: <IconStorage /> },
{ key: '/nodes', label: '节点管理', icon: <IconDesktop /> },
{ key: '/settings/notifications', label: '通知配置', icon: <IconNotification /> },
{ key: '/admin/users', label: '用户管理', icon: <IconUser />, adminOnly: true },
{ key: '/admin/api-keys', label: 'API Key', icon: <IconCommand />, adminOnly: true },
{ key: '/audit', label: '审计日志', icon: <IconList /> },
{ key: '/settings', label: '系统设置', icon: <IconSettings /> },
]
@@ -113,12 +153,14 @@ export function AppLayout() {
{!collapsed && <Typography.Title heading={5} style={{ margin: 0, fontWeight: 700 }}>BackupX</Typography.Title>}
</div>
<Menu selectedKeys={[resolveSelectedKey(location.pathname)]} onClickMenuItem={(key) => navigate(key)}>
{menuItems.map((item) => (
<Menu.Item key={item.key}>
{item.icon}
{item.label}
</Menu.Item>
))}
{menuItems
.filter((item) => !item.adminOnly || isAdmin(user))
.map((item) => (
<Menu.Item key={item.key}>
{item.icon}
{item.label}
</Menu.Item>
))}
</Menu>
{!collapsed && (
<div style={{ position: 'absolute', bottom: 16, left: 0, right: 0, textAlign: 'center' }}>
@@ -128,18 +170,23 @@ export function AppLayout() {
</Sider>
<Layout>
<Header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 20px', background: 'var(--color-bg-2)', borderBottom: '1px solid var(--color-border)' }}>
<Button
type="text"
icon={collapsed ? <IconMenuUnfold /> : <IconMenuFold />}
onClick={() => setCollapsed((value) => !value)}
/>
<Space>
<Button
type="text"
icon={collapsed ? <IconMenuUnfold /> : <IconMenuFold />}
onClick={() => setCollapsed((value) => !value)}
/>
<GlobalSearch />
</Space>
<Space>
<EventCenter />
<Dropdown droplist={userDroplist} position="br">
<Button type="text" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Avatar size={28} style={{ backgroundColor: 'var(--color-primary-6)' }}>
{(user?.displayName ?? user?.username ?? '管')[0]}
</Avatar>
<span>{user?.displayName ?? user?.username ?? '管理员'}</span>
<span style={{ color: 'var(--color-text-3)', fontSize: 12 }}>[{roleLabel(user?.role)}]</span>
<IconDown />
</Button>
</Dropdown>

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>
)
}

View File

@@ -5,6 +5,12 @@ import { LoginPage } from '../pages/login/LoginPage'
import { NotificationsPage } from '../pages/notifications/NotificationsPage'
import { BackupRecordsPage } from '../pages/backup-records/BackupRecordsPage'
import { BackupTasksPage } from '../pages/backup-tasks/BackupTasksPage'
import { RestoreRecordsPage } from '../pages/restore-records/RestoreRecordsPage'
import { VerificationRecordsPage } from '../pages/verification-records/VerificationRecordsPage'
import { ReplicationRecordsPage } from '../pages/replication-records/ReplicationRecordsPage'
import { TaskTemplatesPage } from '../pages/task-templates/TaskTemplatesPage'
import { UsersPage } from '../pages/admin/UsersPage'
import { ApiKeysPage } from '../pages/admin/ApiKeysPage'
import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCallbackPage'
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
import { SettingsPage } from '../pages/settings/SettingsPage'
@@ -28,6 +34,12 @@ export function RouterView() {
<Route path="dashboard" element={<DashboardPage />} />
<Route path="backup/tasks" element={<BackupTasksPage />} />
<Route path="backup/records" element={<BackupRecordsPage />} />
<Route path="restore/records" element={<RestoreRecordsPage />} />
<Route path="verify/records" element={<VerificationRecordsPage />} />
<Route path="replication/records" element={<ReplicationRecordsPage />} />
<Route path="task-templates" element={<TaskTemplatesPage />} />
<Route path="admin/users" element={<UsersPage />} />
<Route path="admin/api-keys" element={<ApiKeysPage />} />
<Route path="storage-targets" element={<StorageTargetsPage />} />
<Route path="storage-targets/google-drive/callback" element={<GoogleDriveCallbackPage />} />
<Route path="settings" element={<SettingsPage />} />

View File

@@ -0,0 +1,45 @@
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
import type { UserRole } from './users'
export interface ApiKeySummary {
id: number
name: string
role: UserRole
prefix: string
createdBy: string
lastUsedAt?: string
expiresAt?: string
disabled: boolean
createdAt: string
}
export interface ApiKeyCreateInput {
name: string
role: UserRole
ttlHours?: number
}
export interface ApiKeyCreateResult {
apiKey: ApiKeySummary
plainKey: string
}
export async function listApiKeys() {
const response = await http.get<ApiEnvelope<ApiKeySummary[]>>('/api-keys')
return unwrapApiEnvelope(response.data)
}
export async function createApiKey(payload: ApiKeyCreateInput) {
const response = await http.post<ApiEnvelope<ApiKeyCreateResult>>('/api-keys', payload)
return unwrapApiEnvelope(response.data)
}
export async function toggleApiKey(id: number, disabled: boolean) {
const response = await http.put<ApiEnvelope<{ disabled: boolean }>>(`/api-keys/${id}/toggle`, { disabled })
return unwrapApiEnvelope(response.data)
}
export async function revokeApiKey(id: number) {
const response = await http.delete<ApiEnvelope<{ revoked: boolean }>>(`/api-keys/${id}`)
return unwrapApiEnvelope(response.data)
}

View File

@@ -1,7 +1,47 @@
import { http } from './http'
import { http, getAccessToken } from './http'
import type { AuditLogListResult } from '../types/audit'
export async function listAuditLogs(params: { category?: string; limit?: number; offset?: number }) {
export interface AuditListParams {
category?: string
action?: string
username?: string
targetId?: string
keyword?: string
dateFrom?: string
dateTo?: string
limit?: number
offset?: number
}
export async function listAuditLogs(params: AuditListParams) {
const response = await http.get<{ code: string; message: string; data: AuditLogListResult }>('/audit-logs', { params })
return response.data.data
}
// exportAuditLogs 触发浏览器下载 CSV。
// fetch 走 token 认证,返回 blob默认 10000 行上限。
export async function exportAuditLogs(params: AuditListParams) {
const token = getAccessToken()
const query = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== '' && v !== null) {
query.set(k, String(v))
}
}
const response = await fetch(`/api/audit-logs/export?${query.toString()}`, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
})
if (!response.ok) {
throw new Error(`导出失败 (HTTP ${response.status})`)
}
const blob = await response.blob()
const cd = response.headers.get('content-disposition') ?? ''
const match = cd.match(/filename="?([^";]+)"?/i)
const filename = match?.[1] ?? `backupx-audit-${new Date().toISOString().slice(0, 10)}.csv`
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}

View File

@@ -77,8 +77,10 @@ export async function downloadBackupRecord(id: number) {
}
}
// @deprecated 请使用 services/restore-records.ts 的 startRestoreFromBackup。
// 保留此导出避免破坏外部集成;返回类型已更新为异步恢复记录详情。
export async function restoreBackupRecord(id: number) {
const response = await http.post<ApiEnvelope<{ restored: boolean }>>(`/backup/records/${id}/restore`)
const response = await http.post<ApiEnvelope<unknown>>(`/backup/records/${id}/restore`)
return unwrapApiEnvelope(response.data)
}

View File

@@ -1,4 +1,4 @@
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
import { http, getAccessToken, type ApiEnvelope, unwrapApiEnvelope } from './http'
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskSummary, BackupTaskTogglePayload } from '../types/backup-tasks'
import type { BackupRecordDetail } from '../types/backup-records'
@@ -36,3 +36,65 @@ export async function runBackupTask(id: number) {
const response = await http.post<ApiEnvelope<BackupRecordDetail>>(`/backup/tasks/${id}/run`)
return unwrapApiEnvelope(response.data)
}
export async function listBackupTaskTags() {
const response = await http.get<ApiEnvelope<string[] | null>>('/backup/tasks/tags')
return unwrapApiEnvelope(response.data) ?? []
}
// 批量操作结果
export interface BatchResult {
id: number
name?: string
success: boolean
error?: string
}
export async function batchToggleTasks(ids: number[], enabled: boolean) {
const response = await http.post<ApiEnvelope<BatchResult[]>>('/backup/tasks/batch/toggle', { ids, enabled })
return unwrapApiEnvelope(response.data) ?? []
}
export async function batchDeleteTasks(ids: number[]) {
const response = await http.post<ApiEnvelope<BatchResult[]>>('/backup/tasks/batch/delete', { ids })
return unwrapApiEnvelope(response.data) ?? []
}
export async function batchRunTasks(ids: number[]) {
const response = await http.post<ApiEnvelope<BatchResult[]>>('/backup/tasks/batch/run', { ids })
return unwrapApiEnvelope(response.data) ?? []
}
// 导入/导出 JSON
export interface TaskImportResult {
name: string
taskId?: number
success: boolean
error?: string
skipped?: boolean
}
/** 导出任务配置为 JSON 文件。ids 为空则导出全部。 */
export async function exportBackupTasks(ids?: number[]): Promise<void> {
const token = getAccessToken()
const qs = ids && ids.length > 0 ? `?ids=${ids.join(',')}` : ''
const response = await fetch(`/api/backup/tasks/export${qs}`, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
})
if (!response.ok) throw new Error(`导出失败 (HTTP ${response.status})`)
const blob = await response.blob()
const cd = response.headers.get('content-disposition') ?? ''
const match = cd.match(/filename="?([^";]+)"?/i)
const filename = match?.[1] ?? `backupx-tasks-${new Date().toISOString().slice(0, 10)}.json`
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
export async function importBackupTasks(payload: unknown) {
const response = await http.post<ApiEnvelope<TaskImportResult[]>>('/backup/tasks/import', payload)
return unwrapApiEnvelope(response.data) ?? []
}

View File

@@ -1,5 +1,5 @@
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
import type { BackupTimelinePoint, DashboardStats } from '../types/dashboard'
import type { BackupTimelinePoint, BreakdownStats, ClusterOverview, DashboardStats, NodePerformance, SLAComplianceReport } from '../types/dashboard'
export async function fetchDashboardStats() {
const response = await http.get<ApiEnvelope<DashboardStats>>('/dashboard/stats')
@@ -10,3 +10,23 @@ export async function fetchDashboardTimeline(days = 30) {
const response = await http.get<ApiEnvelope<BackupTimelinePoint[]>>('/dashboard/timeline', { params: { days } })
return unwrapApiEnvelope(response.data)
}
export async function fetchDashboardSLA() {
const response = await http.get<ApiEnvelope<SLAComplianceReport>>('/dashboard/sla')
return unwrapApiEnvelope(response.data)
}
export async function fetchDashboardCluster() {
const response = await http.get<ApiEnvelope<ClusterOverview>>('/dashboard/cluster')
return unwrapApiEnvelope(response.data)
}
export async function fetchDashboardBreakdown(days = 30) {
const response = await http.get<ApiEnvelope<BreakdownStats>>('/dashboard/breakdown', { params: { days } })
return unwrapApiEnvelope(response.data)
}
export async function fetchDashboardNodePerformance(days = 30) {
const response = await http.get<ApiEnvelope<NodePerformance[]>>('/dashboard/node-performance', { params: { days } })
return unwrapApiEnvelope(response.data) ?? []
}

View File

@@ -6,6 +6,8 @@ export interface DatabaseDiscoverPayload {
port: number
user: string
password: string
/** 指定执行发现的节点。0 或省略表示 Master 本地执行;远程节点 ID 将通过 Agent 路由。 */
nodeId?: number
}
interface DatabaseDiscoverResult {
@@ -13,6 +15,6 @@ interface DatabaseDiscoverResult {
}
export async function discoverDatabases(payload: DatabaseDiscoverPayload): Promise<string[]> {
const response = await http.post<ApiEnvelope<DatabaseDiscoverResult>>('/database/discover', payload, { timeout: 10000 })
const response = await http.post<ApiEnvelope<DatabaseDiscoverResult>>('/database/discover', payload, { timeout: 20000 })
return unwrapApiEnvelope(response.data).databases ?? []
}

View File

@@ -0,0 +1,53 @@
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
export type ReplicationStatus = 'running' | 'success' | 'failed'
export interface ReplicationRecordSummary {
id: number
backupRecordId: number
taskId: number
sourceTargetId: number
sourceTargetName: string
destTargetId: number
destTargetName: string
status: ReplicationStatus
storagePath: string
fileSize: number
checksum: string
errorMessage: string
durationSeconds: number
triggeredBy: string
startedAt: string
completedAt?: string
}
export interface ReplicationListFilter {
taskId?: number
backupRecordId?: number
destTargetId?: number
status?: ReplicationStatus | ''
}
function buildQuery(filter: ReplicationListFilter) {
const q: Record<string, string | number> = {}
if (filter.taskId) q.taskId = filter.taskId
if (filter.backupRecordId) q.backupRecordId = filter.backupRecordId
if (filter.destTargetId) q.destTargetId = filter.destTargetId
if (filter.status) q.status = filter.status
return q
}
export async function listReplicationRecords(filter: ReplicationListFilter = {}) {
const response = await http.get<ApiEnvelope<ReplicationRecordSummary[]>>('/replication/records', { params: buildQuery(filter) })
return unwrapApiEnvelope(response.data)
}
export async function getReplicationRecord(id: number) {
const response = await http.get<ApiEnvelope<ReplicationRecordSummary>>(`/replication/records/${id}`)
return unwrapApiEnvelope(response.data)
}
export async function startReplication(backupRecordId: number, destTargetId: number) {
const response = await http.post<ApiEnvelope<ReplicationRecordSummary>>(`/backup/records/${backupRecordId}/replicate`, { destTargetId })
return unwrapApiEnvelope(response.data)
}

View File

@@ -0,0 +1,134 @@
import { http, getAccessToken, type ApiEnvelope, unwrapApiEnvelope } from './http'
import type { BackupLogEvent } from '../types/backup-records'
import type { RestoreRecordDetail, RestoreRecordListFilter, RestoreRecordSummary } from '../types/restore-records'
import { resolveErrorMessage } from '../utils/error'
interface RestoreLogStreamHandlers {
onEvent: (event: BackupLogEvent) => void
onDone?: () => void
onError?: (message: string) => void
}
function buildQuery(filter: RestoreRecordListFilter) {
const query: Record<string, string | number> = {}
if (filter.taskId) {
query.taskId = filter.taskId
}
if (filter.backupRecordId) {
query.backupRecordId = filter.backupRecordId
}
if (filter.status) {
query.status = filter.status
}
if (filter.dateFrom) {
query.dateFrom = filter.dateFrom
}
if (filter.dateTo) {
query.dateTo = filter.dateTo
}
return query
}
export async function listRestoreRecords(filter: RestoreRecordListFilter = {}) {
const response = await http.get<ApiEnvelope<RestoreRecordSummary[]>>('/restore/records', { params: buildQuery(filter) })
return unwrapApiEnvelope(response.data)
}
export async function getRestoreRecord(id: number) {
const response = await http.get<ApiEnvelope<RestoreRecordDetail>>(`/restore/records/${id}`)
return unwrapApiEnvelope(response.data)
}
// startRestoreFromBackup 通过源备份记录启动恢复。返回新建的恢复记录详情。
export async function startRestoreFromBackup(backupRecordId: number) {
const response = await http.post<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`)
return unwrapApiEnvelope(response.data)
}
function parseLogEvent(chunk: string) {
const payloadLine = chunk.split('\n').find((line) => line.startsWith('data:'))
if (!payloadLine) {
return null
}
const payload = payloadLine.slice(5).trim()
if (!payload) {
return null
}
return JSON.parse(payload) as BackupLogEvent
}
async function resolveStreamError(response: Response) {
try {
const payload = (await response.json()) as { message?: string }
return payload.message ?? '连接日志流失败'
} catch {
return `连接日志流失败HTTP ${response.status}`
}
}
export function streamRestoreRecordLogs(restoreId: number, handlers: RestoreLogStreamHandlers) {
const controller = new AbortController()
void (async () => {
try {
const token = getAccessToken()
const response = await fetch(`/api/restore/records/${restoreId}/logs/stream`, {
method: 'GET',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
signal: controller.signal,
})
if (!response.ok) {
throw new Error(await resolveStreamError(response))
}
if (!response.body) {
throw new Error('日志流不可用')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n')
const chunk = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const event = parseLogEvent(chunk)
if (!event) {
continue
}
handlers.onEvent(event)
if (event.completed) {
handlers.onDone?.()
controller.abort()
return
}
}
}
if (buffer.trim()) {
const event = parseLogEvent(buffer)
if (event) {
handlers.onEvent(event)
}
}
handlers.onDone?.()
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
handlers.onError?.(resolveErrorMessage(error, '日志流连接失败'))
}
})()
return () => controller.abort()
}

View File

@@ -0,0 +1,26 @@
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
export type SearchKind = 'task' | 'record' | 'storage' | 'node'
export interface SearchResultItem {
kind: SearchKind
id: number
title: string
subtitle?: string
highlight?: string
url: string
}
export interface SearchResult {
query: string
tasks: SearchResultItem[]
records: SearchResultItem[]
storage: SearchResultItem[]
nodes: SearchResultItem[]
totalCount: number
}
export async function globalSearch(query: string): Promise<SearchResult> {
const response = await http.get<ApiEnvelope<SearchResult>>('/search', { params: { q: query } })
return unwrapApiEnvelope(response.data)
}

View File

@@ -67,11 +67,19 @@ export async function completeGoogleDriveAuth(queryString: string) {
return unwrap(response.data)
}
export interface StorageDiskUsage {
total?: number
used?: number
free?: number
objects?: number
}
export interface StorageTargetUsage {
targetId: number
targetName: string
recordCount: number
totalSize: number
diskUsage?: StorageDiskUsage
}
export async function toggleStorageTargetStar(id: number) {

View File

@@ -0,0 +1,69 @@
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
import type { BackupTaskPayload } from '../types/backup-tasks'
export interface TaskTemplateSummary {
id: number
name: string
description: string
taskType: string
createdBy: string
createdAt: string
updatedAt: string
}
export interface TaskTemplateDetail extends TaskTemplateSummary {
payload: BackupTaskPayload
}
export interface TaskTemplateUpsertPayload {
name: string
description: string
payload: BackupTaskPayload
}
export interface TaskTemplateVariables {
name: string
sourcePath?: string
sourcePaths?: string[]
dbHost?: string
dbName?: string
tags?: string
nodeId?: number
}
export interface TaskTemplateApplyResult {
name: string
taskId?: number
success: boolean
error?: string
}
export async function listTaskTemplates() {
const response = await http.get<ApiEnvelope<TaskTemplateSummary[] | null>>('/task-templates')
return unwrapApiEnvelope(response.data) ?? []
}
export async function getTaskTemplate(id: number) {
const response = await http.get<ApiEnvelope<TaskTemplateDetail>>(`/task-templates/${id}`)
return unwrapApiEnvelope(response.data)
}
export async function createTaskTemplate(payload: TaskTemplateUpsertPayload) {
const response = await http.post<ApiEnvelope<TaskTemplateDetail>>('/task-templates', payload)
return unwrapApiEnvelope(response.data)
}
export async function updateTaskTemplate(id: number, payload: TaskTemplateUpsertPayload) {
const response = await http.put<ApiEnvelope<TaskTemplateDetail>>(`/task-templates/${id}`, payload)
return unwrapApiEnvelope(response.data)
}
export async function deleteTaskTemplate(id: number) {
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/task-templates/${id}`)
return unwrapApiEnvelope(response.data)
}
export async function applyTaskTemplate(id: number, variables: TaskTemplateVariables[]) {
const response = await http.post<ApiEnvelope<TaskTemplateApplyResult[]>>(`/task-templates/${id}/apply`, { variables })
return unwrapApiEnvelope(response.data)
}

42
web/src/services/users.ts Normal file
View File

@@ -0,0 +1,42 @@
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
export type UserRole = 'admin' | 'operator' | 'viewer'
export interface UserSummary {
id: number
username: string
displayName: string
email: string
role: UserRole
disabled: boolean
createdAt: string
}
export interface UserUpsertPayload {
username: string
password?: string
displayName: string
email?: string
role: UserRole
disabled: boolean
}
export async function listUsers() {
const response = await http.get<ApiEnvelope<UserSummary[]>>('/users')
return unwrapApiEnvelope(response.data)
}
export async function createUser(payload: UserUpsertPayload) {
const response = await http.post<ApiEnvelope<UserSummary>>('/users', payload)
return unwrapApiEnvelope(response.data)
}
export async function updateUser(id: number, payload: UserUpsertPayload) {
const response = await http.put<ApiEnvelope<UserSummary>>(`/users/${id}`, payload)
return unwrapApiEnvelope(response.data)
}
export async function deleteUser(id: number) {
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/users/${id}`)
return unwrapApiEnvelope(response.data)
}

View File

@@ -0,0 +1,105 @@
import { http, getAccessToken, type ApiEnvelope, unwrapApiEnvelope } from './http'
import type { BackupLogEvent } from '../types/backup-records'
import type { VerificationMode, VerificationRecordDetail, VerificationRecordListFilter, VerificationRecordSummary } from '../types/verification-records'
import { resolveErrorMessage } from '../utils/error'
interface VerifyLogStreamHandlers {
onEvent: (event: BackupLogEvent) => void
onDone?: () => void
onError?: (message: string) => void
}
function buildQuery(filter: VerificationRecordListFilter) {
const query: Record<string, string | number> = {}
if (filter.taskId) query.taskId = filter.taskId
if (filter.backupRecordId) query.backupRecordId = filter.backupRecordId
if (filter.status) query.status = filter.status
if (filter.dateFrom) query.dateFrom = filter.dateFrom
if (filter.dateTo) query.dateTo = filter.dateTo
return query
}
export async function listVerificationRecords(filter: VerificationRecordListFilter = {}) {
const response = await http.get<ApiEnvelope<VerificationRecordSummary[]>>('/verify/records', { params: buildQuery(filter) })
return unwrapApiEnvelope(response.data)
}
export async function getVerificationRecord(id: number) {
const response = await http.get<ApiEnvelope<VerificationRecordDetail>>(`/verify/records/${id}`)
return unwrapApiEnvelope(response.data)
}
// startVerifyByTask 使用任务的最新成功备份触发验证。
export async function startVerifyByTask(taskId: number, mode: VerificationMode = 'quick') {
const response = await http.post<ApiEnvelope<VerificationRecordDetail>>(`/backup/tasks/${taskId}/verify`, { mode })
return unwrapApiEnvelope(response.data)
}
// startVerifyByRecord 指定备份记录触发验证。
export async function startVerifyByRecord(backupRecordId: number, mode: VerificationMode = 'quick') {
const response = await http.post<ApiEnvelope<VerificationRecordDetail>>(`/backup/records/${backupRecordId}/verify`, { mode })
return unwrapApiEnvelope(response.data)
}
function parseLogEvent(chunk: string) {
const payloadLine = chunk.split('\n').find((line) => line.startsWith('data:'))
if (!payloadLine) return null
const payload = payloadLine.slice(5).trim()
if (!payload) return null
return JSON.parse(payload) as BackupLogEvent
}
async function resolveStreamError(response: Response) {
try {
const payload = (await response.json()) as { message?: string }
return payload.message ?? '连接日志流失败'
} catch {
return `连接日志流失败HTTP ${response.status}`
}
}
export function streamVerificationRecordLogs(verifyId: number, handlers: VerifyLogStreamHandlers) {
const controller = new AbortController()
void (async () => {
try {
const token = getAccessToken()
const response = await fetch(`/api/verify/records/${verifyId}/logs/stream`, {
method: 'GET',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
signal: controller.signal,
})
if (!response.ok) throw new Error(await resolveStreamError(response))
if (!response.body) throw new Error('日志流不可用')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n')
const chunk = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const event = parseLogEvent(chunk)
if (!event) continue
handlers.onEvent(event)
if (event.completed) {
handlers.onDone?.()
controller.abort()
return
}
}
}
if (buffer.trim()) {
const event = parseLogEvent(buffer)
if (event) handlers.onEvent(event)
}
handlers.onDone?.()
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
handlers.onError?.(resolveErrorMessage(error, '日志流连接失败'))
}
})()
return () => controller.abort()
}

46
web/src/stores/events.ts Normal file
View File

@@ -0,0 +1,46 @@
import { create } from 'zustand'
import type { SystemEvent } from '../hooks/useEventStream'
// 最多保留最近 50 条事件,防止内存增长
const MAX_EVENTS = 50
interface StoredEvent extends SystemEvent {
id: string
read: boolean
}
interface EventState {
events: StoredEvent[]
unreadCount: number
addEvent: (event: SystemEvent) => void
markAllRead: () => void
clear: () => void
}
/**
* useEventStore 全局事件历史存储。
* 设计为非持久化session 内存):
* - 事件重要性由后端 Notification 持久化保证
* - 前端 store 只负责当前会话的未读提示与历史查看
* - 浏览器刷新即清空,避免 localStorage 膨胀
*/
export const useEventStore = create<EventState>((set) => ({
events: [],
unreadCount: 0,
addEvent: (event) =>
set((state) => {
const stored: StoredEvent = {
...event,
id: `${event.timestamp}-${event.type}-${Math.random().toString(36).slice(2, 8)}`,
read: false,
}
const events = [stored, ...state.events].slice(0, MAX_EVENTS)
return { events, unreadCount: state.unreadCount + 1 }
}),
markAllRead: () =>
set((state) => ({
events: state.events.map((e) => ({ ...e, read: true })),
unreadCount: 0,
})),
clear: () => set({ events: [], unreadCount: 0 }),
}))

View File

@@ -21,6 +21,14 @@ export interface BackupTaskSummary {
maxBackups: number
lastRunAt?: string
lastStatus: BackupTaskStatus
verifyEnabled: boolean
verifyCronExpr: string
verifyMode: 'quick' | 'deep'
slaHoursRpo: number
alertOnConsecutiveFails: number
replicationTargetIds: number[]
maintenanceWindows: string
dependsOnTaskIds: number[]
updatedAt: string
}
@@ -63,6 +71,14 @@ export interface BackupTaskPayload {
maxBackups: number
/** 类型特有的扩展配置(如 SAP HANA 的 backupLevel/backupChannels 等) */
extraConfig?: Record<string, unknown>
verifyEnabled: boolean
verifyCronExpr: string
verifyMode: 'quick' | 'deep'
slaHoursRpo: number
alertOnConsecutiveFails: number
replicationTargetIds: number[]
maintenanceWindows: string
dependsOnTaskIds: number[]
}
export interface BackupTaskTogglePayload {

View File

@@ -23,3 +23,69 @@ export interface DashboardStats {
recentRecords: BackupRecordSummary[]
storageUsage: DashboardStorageUsageItem[]
}
export interface SLAViolation {
taskId: number
taskName: string
nodeId: number
nodeName?: string
slaHoursRpo: number
lastSuccessAt?: string
hoursSinceLastSuccess: number
neverSucceeded: boolean
}
export interface SLAComplianceReport {
totalTasksWithSla: number
compliant: number
violated: number
coverageRate: number
violations: SLAViolation[]
}
export interface ClusterNodeSummary {
id: number
name: string
hostname: string
status: 'online' | 'offline'
isLocal: boolean
agentVersion: string
versionStatus: 'current' | 'outdated' | 'unknown'
lastSeen: string
taskCount: number
}
export interface ClusterOverview {
masterVersion: string
totalNodes: number
onlineNodes: number
offlineNodes: number
outdatedAgents: number
nodes: ClusterNodeSummary[]
}
export interface BreakdownItem {
key: string
label: string
count?: number
totalSize?: number
}
export interface BreakdownStats {
byType: BreakdownItem[]
byStatus: BreakdownItem[]
byNode: BreakdownItem[]
byStorage: BreakdownItem[]
}
export interface NodePerformance {
nodeId: number
nodeName: string
isLocal: boolean
totalRuns: number
successRuns: number
failedRuns: number
successRate: number
totalBytes: number
avgDurationSecs: number
}

View File

@@ -0,0 +1,32 @@
import type { BackupLogEvent } from './backup-records'
export type RestoreRecordStatus = 'running' | 'success' | 'failed'
export interface RestoreRecordSummary {
id: number
backupRecordId: number
taskId: number
taskName: string
nodeId: number
nodeName?: string
status: RestoreRecordStatus
errorMessage: string
durationSeconds: number
startedAt: string
completedAt?: string
triggeredBy: string
backupFileName?: string
}
export interface RestoreRecordDetail extends RestoreRecordSummary {
logContent: string
logEvents?: BackupLogEvent[]
}
export interface RestoreRecordListFilter {
taskId?: number
backupRecordId?: number
status?: RestoreRecordStatus | ''
dateFrom?: string
dateTo?: string
}

View File

@@ -14,6 +14,8 @@ export interface StorageTargetSummary {
lastTestedAt?: string
lastTestStatus: StorageTestStatus
lastTestMessage?: string
/** 软配额字节0 = 不限制 */
quotaBytes?: number
}
export interface StorageTargetDetail extends StorageTargetSummary {
@@ -28,6 +30,8 @@ export interface StorageTargetPayload {
description: string
enabled: boolean
config: Record<string, string | boolean>
/** 软配额字节0 = 不限制 */
quotaBytes?: number
}
export interface StorageConnectionTestResult {

View File

@@ -0,0 +1,34 @@
import type { BackupLogEvent } from './backup-records'
export type VerificationRecordStatus = 'running' | 'success' | 'failed'
export type VerificationMode = 'quick' | 'deep'
export interface VerificationRecordSummary {
id: number
backupRecordId: number
taskId: number
taskName: string
nodeId: number
mode: VerificationMode
status: VerificationRecordStatus
summary: string
errorMessage: string
durationSeconds: number
startedAt: string
completedAt?: string
triggeredBy: string
backupFileName?: string
}
export interface VerificationRecordDetail extends VerificationRecordSummary {
logContent: string
logEvents?: BackupLogEvent[]
}
export interface VerificationRecordListFilter {
taskId?: number
backupRecordId?: number
status?: VerificationRecordStatus | ''
dateFrom?: string
dateTo?: string
}

View File

@@ -0,0 +1,40 @@
import type { UserInfo } from '../services/auth'
// 用户角色常量,与后端 model.UserRole* 保持一致。
export const UserRole = {
Admin: 'admin',
Operator: 'operator',
Viewer: 'viewer',
} as const
export type UserRoleType = typeof UserRole[keyof typeof UserRole]
/** 是否管理员角色。 */
export function isAdmin(user?: UserInfo | null): boolean {
return (user?.role ?? '').toLowerCase() === UserRole.Admin
}
/** 是否只读viewer。 */
export function isViewer(user?: UserInfo | null): boolean {
return (user?.role ?? '').toLowerCase() === UserRole.Viewer
}
/** 是否允许写入/变更类操作admin 或 operator。 */
export function canWrite(user?: UserInfo | null): boolean {
const role = (user?.role ?? '').toLowerCase()
return role === UserRole.Admin || role === UserRole.Operator
}
/** 角色展示名(用于 UI。 */
export function roleLabel(role?: string): string {
switch ((role ?? '').toLowerCase()) {
case UserRole.Admin:
return '管理员'
case UserRole.Operator:
return '运维'
case UserRole.Viewer:
return '只读'
default:
return role ?? '-'
}
}