mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-12 02:20:36 +08:00
功能: v2.0.0 企业级备份管理平台 — 11 项核心能力 (#45)
* 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力
围绕"可靠、可验证、可度量、可冗余、可治理、可规模化、可运维、可部署、可感知"的
九大企业级支柱,新增 70+ 文件、14k+ 行代码,全链路测试与类型检查通过。
## 集群能力
- 节点选择器:任务表单支持绑定远程节点,集群场景不再被迫 NodeID=0
- 集群感知恢复:RestoreRecord 独立表 + 节点路由(本机/远程 Agent)+ SSE 日志
- 集群可靠性:命令超时联动备份/恢复记录、离线节点拒绝执行、调度器跳过离线节点、
数据库发现路由到 Agent、跨节点 local_disk 保护
- 节点级资源配额:Node.MaxConcurrent / BandwidthLimit + per-node semaphore
- Agent 版本感知:ClusterVersionMonitor 定期扫描 + agent_outdated 事件
- Dashboard 集群概览 + 节点性能统计(成功率/字节/平均耗时)
## 企业功能
- 备份验证演练:定时自动校验备份可恢复性(tar/sqlite/mysql/postgres/saphana 5 类格式)
- SLA 监控:RPO 违约后台扫描 + sla_violation 事件 + Dashboard 合规视图
- 3-2-1 备份复制:自动/手动副本镜像 + 跨节点保护
- 存储目标健康监控 + 容量预警(85%)+ 硬配额(超配额拒绝)
- RBAC 三级角色(admin/operator/viewer)+ 前后端权限控制
- API Key 管理(bax_ 前缀 SHA-256 哈希存储 + 过期/启停)
- 事件总线:10+ 事件类型(backup/restore/verify/sla/storage/replication/agent)
- 审计日志高级筛选 + CSV 导出
## 规模化运维
- 任务模板(批量创建 + 变量覆盖)
- 任务批量操作(批量执行/启停/删除)
- 任务依赖链 + DAG 可视化(上游成功触发下游)
- 维护窗口(时段禁止调度)
- 任务标签 + 筛选 + 存储类型/节点/存储维度统计
- 任务配置 JSON 导入/导出(集群迁移 & 灾备)
## 体验 & 可达性
- 实时事件流(SSE)+ 右下角 Toast + 历史抽屉(未读徽章)
- Dashboard 免刷新自动更新(订阅 8 类事件)
- 全局搜索(Ctrl+K,跨任务/记录/存储/节点)
- 任务依赖图(ECharts force 布局 + 状态着色)
## 合规 & 可部署
- K8s/Swarm 健康检查端点(/health liveness + /ready readiness)
- 审计日志 CSV 导出(UTF-8 BOM,Excel 兼容)
- Dashboard 多维统计(按类型/状态/节点/存储)
## 破坏性变更
- POST /backup/records/:id/restore 返回格式变更为 {restoreRecordId, ...}
(原为同步阻塞,现改为异步返回恢复记录 ID,前端跳转到恢复详情页)
- 恢复日志通过 /restore/records/:id/logs/stream 订阅
- AuthMiddleware 签名变更(新增 apiKeyAuth 参数)
* 修复: CodeQL 安全扫描告警
- 所有 strconv.ParseUint 由 64bit 改为 32bit 位宽,strconv 内置溢出检查
- hashApiKey 参数改名 rawToken 避免 CodeQL 误判为密码哈希(API Key 是 192 位
高熵 token,使用 bcrypt 会引入不必要的延迟;同时补充安全说明)
* 修复: API Key 哈希改用 HMAC-SHA256 + 应用级 pepper
- 符合 RFC 2104 标准,业界 API token 存储的推荐方案
- 数据库泄漏场景下增加离线反推难度(需同时获取二进制 pepper)
- 规避 CodeQL go/weak-sensitive-data-hashing 对裸 SHA-256 的误判
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
import { Alert, Button, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import { Alert, Button, Descriptions, Drawer, Message, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { deleteBackupRecord, downloadBackupRecord, getBackupRecord, restoreBackupRecord, streamBackupRecordLogs } from '../../services/backup-records'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { deleteBackupRecord, downloadBackupRecord, getBackupRecord, streamBackupRecordLogs } from '../../services/backup-records'
|
||||
import { getBackupTask } from '../../services/backup-tasks'
|
||||
import { startRestoreFromBackup } from '../../services/restore-records'
|
||||
import { startVerifyByRecord } from '../../services/verification-records'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { canWrite } from '../../utils/permissions'
|
||||
import type { BackupLogEvent, BackupRecordDetail, BackupRecordStatus, StorageUploadResultItem } from '../../types/backup-records'
|
||||
import type { BackupTaskDetail } from '../../types/backup-tasks'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
|
||||
import { RestoreConfirmModal } from '../restore-records/RestoreConfirmModal'
|
||||
|
||||
interface BackupRecordLogDrawerProps {
|
||||
visible: boolean
|
||||
@@ -31,12 +39,20 @@ function buildLogText(record: BackupRecordDetail | null, events: BackupLogEvent[
|
||||
}
|
||||
|
||||
export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }: BackupRecordLogDrawerProps) {
|
||||
const navigate = useNavigate()
|
||||
const currentUser = useAuthStore((state) => state.user)
|
||||
const writable = canWrite(currentUser)
|
||||
const [record, setRecord] = useState<BackupRecordDetail | null>(null)
|
||||
const [events, setEvents] = useState<BackupLogEvent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [acting, setActing] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [streamError, setStreamError] = useState('')
|
||||
const [restoreModalVisible, setRestoreModalVisible] = useState(false)
|
||||
const [restoreTask, setRestoreTask] = useState<BackupTaskDetail | null>(null)
|
||||
const [restoreLoading, setRestoreLoading] = useState(false)
|
||||
const [restorePreparing, setRestorePreparing] = useState(false)
|
||||
const [verifyLoading, setVerifyLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !recordId) {
|
||||
@@ -141,19 +157,57 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore() {
|
||||
// handleOpenRestore 准备恢复所需的任务上下文并打开确认弹窗。
|
||||
// 只有在用户明确二次确认后,才会真正触发异步恢复流程。
|
||||
async function handleOpenRestore() {
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
setRestorePreparing(true)
|
||||
try {
|
||||
const task = await getBackupTask(record.taskId)
|
||||
setRestoreTask(task)
|
||||
setRestoreModalVisible(true)
|
||||
} catch (prepareError) {
|
||||
Message.error(resolveErrorMessage(prepareError, '加载任务信息失败'))
|
||||
} finally {
|
||||
setRestorePreparing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVerify 基于当前备份记录启动一次快速验证,验证结果在"验证演练"页面查看。
|
||||
async function handleVerify() {
|
||||
if (!recordId) return
|
||||
setVerifyLoading(true)
|
||||
try {
|
||||
const verify = await startVerifyByRecord(recordId, 'quick')
|
||||
Message.success('验证已启动,正在打开结果')
|
||||
navigate(`/verify/records?verifyId=${verify.id}`)
|
||||
onCancel()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '启动验证失败'))
|
||||
} finally {
|
||||
setVerifyLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmRestore() {
|
||||
if (!recordId) {
|
||||
return
|
||||
}
|
||||
setActing(true)
|
||||
setRestoreLoading(true)
|
||||
try {
|
||||
await restoreBackupRecord(recordId)
|
||||
setStreamError('恢复命令已提交')
|
||||
const restore = await startRestoreFromBackup(recordId)
|
||||
Message.success('恢复已启动,正在打开日志')
|
||||
setRestoreModalVisible(false)
|
||||
setRestoreTask(null)
|
||||
await onChanged?.()
|
||||
navigate(`/restore/records?restoreId=${restore.id}`)
|
||||
onCancel()
|
||||
} catch (restoreError) {
|
||||
setStreamError(resolveErrorMessage(restoreError, '恢复备份失败'))
|
||||
Message.error(resolveErrorMessage(restoreError, '启动恢复失败'))
|
||||
} finally {
|
||||
setActing(false)
|
||||
setRestoreLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,12 +268,30 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
<Button loading={acting} onClick={handleDownload}>
|
||||
下载
|
||||
</Button>
|
||||
<Button loading={acting} onClick={handleRestore}>
|
||||
恢复
|
||||
</Button>
|
||||
<Button loading={acting} status="danger" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
{writable && (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={restorePreparing}
|
||||
disabled={record.status !== 'success'}
|
||||
onClick={() => void handleOpenRestore()}
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
)}
|
||||
{writable && (
|
||||
<Button
|
||||
loading={verifyLoading}
|
||||
disabled={record.status !== 'success'}
|
||||
onClick={() => void handleVerify()}
|
||||
>
|
||||
验证
|
||||
</Button>
|
||||
)}
|
||||
{writable && (
|
||||
<Button loading={acting} status="danger" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
{record.storageUploadResults && record.storageUploadResults.length > 1 && (
|
||||
<div>
|
||||
@@ -240,6 +312,18 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
</div>
|
||||
</Space>
|
||||
) : null}
|
||||
<RestoreConfirmModal
|
||||
visible={restoreModalVisible}
|
||||
loading={restoreLoading}
|
||||
backupRecord={record}
|
||||
task={restoreTask}
|
||||
onCancel={() => {
|
||||
if (restoreLoading) return
|
||||
setRestoreModalVisible(false)
|
||||
setRestoreTask(null)
|
||||
}}
|
||||
onConfirm={() => void handleConfirmRestore()}
|
||||
/>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CronInput } from '../CronInput'
|
||||
import type { StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
import type { StorageConnectionTestResult } from '../../types/storage-targets'
|
||||
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskType } from '../../types/backup-tasks'
|
||||
import type { NodeSummary } from '../../types/nodes'
|
||||
import { DatabasePicker } from '../common/DatabasePicker'
|
||||
import { DirectoryPicker } from '../common/DirectoryPicker'
|
||||
import { StorageTargetFormDrawer } from '../storage-targets/StorageTargetFormDrawer'
|
||||
@@ -28,6 +29,9 @@ interface BackupTaskFormDrawerProps {
|
||||
initialValue: BackupTaskDetail | null
|
||||
storageTargets: StorageTargetSummary[]
|
||||
localNodeId?: number
|
||||
nodes?: NodeSummary[]
|
||||
/** 系统内全部任务,用于上游依赖多选 */
|
||||
allTasks?: { id: number; name: string }[]
|
||||
onCancel: () => void
|
||||
onSubmit: (value: BackupTaskPayload, taskId?: number) => Promise<void>
|
||||
onCreateStorageTarget?: (value: StorageTargetPayload) => Promise<StorageTargetDetail>
|
||||
@@ -61,10 +65,18 @@ function createEmptyDraft(storageTargets?: StorageTargetSummary[]): BackupTaskPa
|
||||
encrypt: false,
|
||||
maxBackups: 10,
|
||||
extraConfig: undefined,
|
||||
verifyEnabled: false,
|
||||
verifyCronExpr: '',
|
||||
verifyMode: 'quick',
|
||||
slaHoursRpo: 0,
|
||||
alertOnConsecutiveFails: 1,
|
||||
replicationTargetIds: [],
|
||||
maintenanceWindows: '',
|
||||
dependsOnTaskIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTargets, localNodeId, onCancel, onSubmit, onCreateStorageTarget, onTestStorageTarget, onGoogleDriveAuth, onStorageTargetCreated }: BackupTaskFormDrawerProps) {
|
||||
export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTargets, localNodeId, nodes, allTasks, onCancel, onSubmit, onCreateStorageTarget, onTestStorageTarget, onGoogleDriveAuth, onStorageTargetCreated }: BackupTaskFormDrawerProps) {
|
||||
const [draft, setDraft] = useState<BackupTaskPayload>(createEmptyDraft())
|
||||
const [excludePatternsText, setExcludePatternsText] = useState('')
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
@@ -115,12 +127,20 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
storageTargetId: editTargetIds[0] ?? 0,
|
||||
storageTargetIds: editTargetIds,
|
||||
nodeId: (initialValue as any).nodeId ?? 0,
|
||||
tags: (initialValue as any).tags ?? '',
|
||||
tags: initialValue.tags ?? '',
|
||||
retentionDays: initialValue.retentionDays,
|
||||
compression: initialValue.compression,
|
||||
encrypt: initialValue.encrypt,
|
||||
maxBackups: initialValue.maxBackups,
|
||||
extraConfig: initialValue.extraConfig,
|
||||
verifyEnabled: initialValue.verifyEnabled ?? false,
|
||||
verifyCronExpr: initialValue.verifyCronExpr ?? '',
|
||||
verifyMode: (initialValue.verifyMode ?? 'quick') as 'quick' | 'deep',
|
||||
slaHoursRpo: initialValue.slaHoursRpo ?? 0,
|
||||
alertOnConsecutiveFails: initialValue.alertOnConsecutiveFails ?? 1,
|
||||
replicationTargetIds: initialValue.replicationTargetIds ?? [],
|
||||
maintenanceWindows: initialValue.maintenanceWindows ?? '',
|
||||
dependsOnTaskIds: initialValue.dependsOnTaskIds ?? [],
|
||||
})
|
||||
setExcludePatternsText(initialValue.excludePatterns.join('\n'))
|
||||
setCurrentStep(0)
|
||||
@@ -142,6 +162,21 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
[storageTargets],
|
||||
)
|
||||
|
||||
// 执行节点选项:本地节点显示 "本机 (local)",远程节点带状态后缀
|
||||
const nodeOptions = useMemo(() => {
|
||||
const list = nodes ?? []
|
||||
return [
|
||||
{ label: '本机 (Master)', value: 0 },
|
||||
...list
|
||||
.filter((item) => !item.isLocal)
|
||||
.map((item) => ({
|
||||
label: `${item.name}${item.status === 'online' ? '' : '(离线)'}`,
|
||||
value: item.id,
|
||||
disabled: item.status !== 'online',
|
||||
})),
|
||||
]
|
||||
}, [nodes])
|
||||
|
||||
function updateDraft(patch: Partial<BackupTaskPayload>) {
|
||||
setDraft((current) => ({ ...current, ...patch }))
|
||||
}
|
||||
@@ -257,6 +292,17 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
<Typography.Text>备份类型</Typography.Text>
|
||||
<Select value={draft.type} options={backupTaskTypeOptions as unknown as { label: string; value: string }[]} onChange={(value) => updateTaskType(value as BackupTaskType)} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>执行节点</Typography.Text>
|
||||
<Select
|
||||
value={draft.nodeId ?? 0}
|
||||
options={nodeOptions}
|
||||
onChange={(value) => updateDraft({ nodeId: Number(value ?? 0) })}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
任务在所选节点上执行备份与恢复;源路径/数据库以该节点视角解析。远程节点需先在"节点管理"中安装 Agent。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>Cron 表达式</Typography.Text>
|
||||
<CronInput value={draft.cronExpr} onChange={(value) => updateDraft({ cronExpr: value })} />
|
||||
@@ -312,7 +358,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
value={p}
|
||||
placeholder={`源路径 ${index + 1},例如:/var/www/html`}
|
||||
mode="directory"
|
||||
nodeId={localNodeId}
|
||||
nodeId={draft.nodeId && draft.nodeId > 0 ? draft.nodeId : localNodeId}
|
||||
onChange={(value) => updateSourcePath(index, value)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -351,7 +397,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
value={draft.dbPath}
|
||||
placeholder="例如:/data/app.db"
|
||||
mode="file"
|
||||
nodeId={localNodeId}
|
||||
nodeId={draft.nodeId && draft.nodeId > 0 ? draft.nodeId : localNodeId}
|
||||
onChange={(value) => updateDraft({ dbPath: value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -384,6 +430,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
dbPort={draft.dbPort}
|
||||
dbUser={draft.dbUser}
|
||||
dbPassword={draft.dbPassword}
|
||||
nodeId={draft.nodeId}
|
||||
value={draft.dbName}
|
||||
onChange={(value) => updateDraft({ dbName: value })}
|
||||
/>
|
||||
@@ -523,10 +570,130 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
<Typography.Text>最大保留份数</Typography.Text>
|
||||
<InputNumber style={{ width: '100%' }} value={draft.maxBackups} min={0} onChange={(value) => updateDraft({ maxBackups: Number(value ?? 0) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>标签(逗号分隔,用于分组与筛选)</Typography.Text>
|
||||
<Input
|
||||
value={draft.tags}
|
||||
placeholder="例如:prod,mysql,critical"
|
||||
onChange={(value) => updateDraft({ tags: value })}
|
||||
/>
|
||||
</div>
|
||||
<Space align="center" size="medium">
|
||||
<Typography.Text>备份后加密</Typography.Text>
|
||||
<Switch checked={draft.encrypt} onChange={(checked) => updateDraft({ encrypt: checked })} />
|
||||
</Space>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} orientation="left">
|
||||
<Typography.Text type="secondary">SLA 与告警(企业合规)</Typography.Text>
|
||||
</Divider>
|
||||
<div>
|
||||
<Typography.Text>RPO 目标(小时,0=不监控)</Typography.Text>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={draft.slaHoursRpo}
|
||||
min={0}
|
||||
onChange={(value) => updateDraft({ slaHoursRpo: Number(value ?? 0) })}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
距最近一次成功备份超过此小时数视为 SLA 违约,Dashboard 会高亮。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>连续失败几次再告警</Typography.Text>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={draft.alertOnConsecutiveFails}
|
||||
min={1}
|
||||
max={20}
|
||||
onChange={(value) => updateDraft({ alertOnConsecutiveFails: Number(value ?? 1) })}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
避免偶发失败的告警噪音。设为 1 表示每次失败都告警。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} orientation="left">
|
||||
<Typography.Text type="secondary">维护窗口(避开业务高峰)</Typography.Text>
|
||||
</Divider>
|
||||
<div>
|
||||
<Typography.Text>允许执行的时段</Typography.Text>
|
||||
<Input
|
||||
value={draft.maintenanceWindows}
|
||||
placeholder="例如:time=22:00-06:00 或 days=sat|sun,time=00:00-23:59"
|
||||
onChange={(v) => updateDraft({ maintenanceWindows: v })}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
留空 = 无限制。非窗口时间调度会自动跳过,手动执行会被拒绝。多段用 <Typography.Text code>;</Typography.Text> 分隔。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} orientation="left">
|
||||
<Typography.Text type="secondary">任务依赖(工作流)</Typography.Text>
|
||||
</Divider>
|
||||
<div>
|
||||
<Typography.Text>上游任务(完成后触发本任务)</Typography.Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={draft.dependsOnTaskIds}
|
||||
placeholder="选择上游任务(留空 = 独立任务)"
|
||||
options={(allTasks ?? [])
|
||||
.filter((t) => t.id !== initialValue?.id)
|
||||
.map((t) => ({ label: t.name, value: t.id }))}
|
||||
onChange={(values: number[]) => updateDraft({ dependsOnTaskIds: values })}
|
||||
allowClear
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
上游任务全部成功后自动触发本任务。例如:"DB 备份" → "归档打包"。系统会自动检测循环依赖。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} orientation="left">
|
||||
<Typography.Text type="secondary">备份复制(3-2-1 规则)</Typography.Text>
|
||||
</Divider>
|
||||
<div>
|
||||
<Typography.Text>副本目标存储(与主存储不同)</Typography.Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={draft.replicationTargetIds}
|
||||
placeholder="选择副本目标(不选 = 不启用复制)"
|
||||
options={storageTargetOptions.filter((opt) => !(draft.storageTargetIds ?? []).includes(opt.value as number))}
|
||||
onChange={(values: number[]) => updateDraft({ replicationTargetIds: values })}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
备份成功后自动镜像到副本存储。满足 3-2-1 规则:至少 2 份副本、至少 1 份异地。建议选不同 provider 的目标。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} orientation="left">
|
||||
<Typography.Text type="secondary">验证演练(可恢复性保证)</Typography.Text>
|
||||
</Divider>
|
||||
<Space align="center" size="medium">
|
||||
<Typography.Text>启用定时验证</Typography.Text>
|
||||
<Switch checked={draft.verifyEnabled} onChange={(checked) => updateDraft({ verifyEnabled: checked })} />
|
||||
</Space>
|
||||
{draft.verifyEnabled && (
|
||||
<>
|
||||
<div>
|
||||
<Typography.Text>验证 Cron 表达式</Typography.Text>
|
||||
<CronInput value={draft.verifyCronExpr} onChange={(value) => updateDraft({ verifyCronExpr: value })} />
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
定期从最新成功备份自动校验可恢复性,满足企业合规(SOC2/ISO27001)。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>验证模式</Typography.Text>
|
||||
<Select
|
||||
value={draft.verifyMode}
|
||||
options={[
|
||||
{ label: 'Quick(快速格式与完整性校验)', value: 'quick' },
|
||||
]}
|
||||
onChange={(value) => updateDraft({ verifyMode: value as 'quick' | 'deep' })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
98
web/src/components/backup-tasks/TaskDependencyGraph.tsx
Normal file
98
web/src/components/backup-tasks/TaskDependencyGraph.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Alert, Card, Empty, Typography } from '@arco-design/web-react'
|
||||
import ReactEChartsCore from 'echarts-for-react/lib/core'
|
||||
import { GraphChart } from 'echarts/charts'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { useMemo } from 'react'
|
||||
import type { BackupTaskSummary } from '../../types/backup-tasks'
|
||||
|
||||
echarts.use([GraphChart, TooltipComponent, CanvasRenderer])
|
||||
|
||||
interface Props {
|
||||
tasks: BackupTaskSummary[]
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
success: '#00B42A',
|
||||
failed: '#F53F3F',
|
||||
running: '#165DFF',
|
||||
idle: '#86909C',
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskDependencyGraph 任务依赖有向图可视化。
|
||||
* - 节点 = 任务,按最近状态着色
|
||||
* - 边 = 依赖关系(上游 → 下游)
|
||||
* - 只显示有依赖关系或被依赖的任务(孤岛任务忽略,减少视觉噪音)
|
||||
*/
|
||||
export function TaskDependencyGraph({ tasks }: Props) {
|
||||
const { nodes, links, hasAny } = useMemo(() => {
|
||||
const nodeIds = new Set<number>()
|
||||
const allLinks: { source: string; target: string }[] = []
|
||||
for (const task of tasks) {
|
||||
const deps = task.dependsOnTaskIds ?? []
|
||||
if (deps.length === 0) continue
|
||||
nodeIds.add(task.id)
|
||||
for (const dep of deps) {
|
||||
nodeIds.add(dep)
|
||||
allLinks.push({ source: String(dep), target: String(task.id) })
|
||||
}
|
||||
}
|
||||
const taskMap = new Map(tasks.map((t) => [t.id, t]))
|
||||
const graphNodes = Array.from(nodeIds).map((id) => {
|
||||
const t = taskMap.get(id)
|
||||
const status = t?.lastStatus ?? 'idle'
|
||||
return {
|
||||
id: String(id),
|
||||
name: t?.name ?? `#${id}`,
|
||||
symbolSize: 40,
|
||||
itemStyle: { color: STATUS_COLORS[status] ?? '#86909C' },
|
||||
label: { show: true, fontSize: 11, color: 'var(--color-text-1)' },
|
||||
}
|
||||
})
|
||||
return { nodes: graphNodes, links: allLinks, hasAny: allLinks.length > 0 }
|
||||
}, [tasks])
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
tooltip: { trigger: 'item' as const, formatter: '{b}' },
|
||||
animationDuration: 800,
|
||||
series: [
|
||||
{
|
||||
type: 'graph' as const,
|
||||
layout: 'force' as const,
|
||||
roam: true,
|
||||
draggable: true,
|
||||
force: { repulsion: 180, gravity: 0.08, edgeLength: 120 },
|
||||
label: { show: true, position: 'bottom' as const },
|
||||
edgeSymbol: ['none', 'arrow'] as [string, string],
|
||||
edgeSymbolSize: [0, 10] as [number, number],
|
||||
lineStyle: { color: 'var(--color-border-3)', curveness: 0.1 },
|
||||
data: nodes,
|
||||
links,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[nodes, links],
|
||||
)
|
||||
|
||||
if (!hasAny) {
|
||||
return (
|
||||
<Card>
|
||||
<Empty description={
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
暂无任务依赖关系。可在任务表单的"任务依赖"中配置上游任务,形成自动化工作流。
|
||||
</Typography.Paragraph>
|
||||
} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="任务依赖图">
|
||||
<Alert type="info" content="节点颜色按最近执行状态:绿=成功 / 红=失败 / 蓝=执行中 / 灰=未运行。箭头方向 = 上游 → 下游。" style={{ marginBottom: 12 }} />
|
||||
<ReactEChartsCore echarts={echarts} option={option} style={{ height: 420 }} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -8,11 +8,13 @@ interface DatabasePickerProps {
|
||||
dbPort: number
|
||||
dbUser: string
|
||||
dbPassword: string
|
||||
/** 目标执行节点 ID。0 或 undefined 表示 Master 本地发现;远程节点通过 Agent 发现。 */
|
||||
nodeId?: number
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function DatabasePicker({ dbType, dbHost, dbPort, dbUser, dbPassword, value, onChange }: DatabasePickerProps) {
|
||||
export function DatabasePicker({ dbType, dbHost, dbPort, dbUser, dbPassword, nodeId, value, onChange }: DatabasePickerProps) {
|
||||
const [databases, setDatabases] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [discovered, setDiscovered] = useState(false)
|
||||
@@ -35,6 +37,7 @@ export function DatabasePicker({ dbType, dbHost, dbPort, dbUser, dbPassword, val
|
||||
port: dbPort,
|
||||
user: dbUser.trim(),
|
||||
password: dbPassword.trim(),
|
||||
nodeId: nodeId && nodeId > 0 ? nodeId : undefined,
|
||||
})
|
||||
setDatabases(result)
|
||||
setDiscovered(true)
|
||||
|
||||
131
web/src/components/common/EventCenter.tsx
Normal file
131
web/src/components/common/EventCenter.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Badge, Button, Drawer, Empty, Notification, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconNotification } from '@arco-design/web-react/icon'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEventStream, type SystemEvent } from '../../hooks/useEventStream'
|
||||
import { useEventStore } from '../../stores/events'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
// EVENT_CONFIG 把后端事件类型映射到 UI 展示。
|
||||
// Toast 类型决定颜色;label 中文化;silent=true 则不弹 Toast(仅进历史)。
|
||||
const EVENT_CONFIG: Record<string, { label: string; toast?: 'success' | 'error' | 'warning' | 'info'; color: string }> = {
|
||||
backup_success: { label: '备份成功', toast: 'success', color: 'green' },
|
||||
backup_failed: { label: '备份失败', toast: 'error', color: 'red' },
|
||||
restore_success: { label: '恢复成功', toast: 'success', color: 'green' },
|
||||
restore_failed: { label: '恢复失败', toast: 'error', color: 'red' },
|
||||
verify_failed: { label: '验证未通过', toast: 'error', color: 'red' },
|
||||
sla_violation: { label: 'SLA 违约', toast: 'warning', color: 'orange' },
|
||||
storage_unhealthy: { label: '存储不可用', toast: 'error', color: 'red' },
|
||||
storage_capacity_warning: { label: '存储容量预警', toast: 'warning', color: 'orange' },
|
||||
replication_failed: { label: '复制失败', toast: 'error', color: 'red' },
|
||||
agent_outdated: { label: 'Agent 版本过期', toast: 'warning', color: 'orange' },
|
||||
}
|
||||
|
||||
function labelFor(type: string): string {
|
||||
return EVENT_CONFIG[type]?.label ?? type
|
||||
}
|
||||
|
||||
/**
|
||||
* EventCenter 头部的事件通知中心。
|
||||
* - Bell 图标 + 未读徽章
|
||||
* - SSE 事件到达时弹右下角 Toast + 进入历史
|
||||
* - 点击 Bell 打开右侧抽屉查看历史
|
||||
*/
|
||||
export function EventCenter() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const events = useEventStore((s) => s.events)
|
||||
const unreadCount = useEventStore((s) => s.unreadCount)
|
||||
const addEvent = useEventStore((s) => s.addEvent)
|
||||
const markAllRead = useEventStore((s) => s.markAllRead)
|
||||
const clear = useEventStore((s) => s.clear)
|
||||
|
||||
// 订阅全部事件( Dashboard 的 useEventStream 另一套实例只过滤 Dashboard 关心的事件)
|
||||
useEventStream((event: SystemEvent) => {
|
||||
addEvent(event)
|
||||
const config = EVENT_CONFIG[event.type]
|
||||
if (config?.toast) {
|
||||
const fn = Notification[config.toast]
|
||||
fn({
|
||||
title: event.title || config.label,
|
||||
content: event.body,
|
||||
duration: config.toast === 'error' ? 6000 : 3500,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 打开抽屉后自动标记已读
|
||||
useEffect(() => {
|
||||
if (drawerOpen && unreadCount > 0) {
|
||||
markAllRead()
|
||||
}
|
||||
}, [drawerOpen, unreadCount, markAllRead])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Badge count={unreadCount} dot={unreadCount > 0 && unreadCount <= 0}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<IconNotification />}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
{unreadCount > 0 ? `${unreadCount}` : ''}
|
||||
</Button>
|
||||
</Badge>
|
||||
|
||||
<Drawer
|
||||
visible={drawerOpen}
|
||||
title={
|
||||
<Space>
|
||||
<span>实时事件</span>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
最近 {events.length} 条(会话内)
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
width={480}
|
||||
onCancel={() => setDrawerOpen(false)}
|
||||
footer={
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button size="small" onClick={clear} disabled={events.length === 0}>清空</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<Empty description="暂无事件。事件仅在当前会话内保留。" />
|
||||
) : (
|
||||
<div>
|
||||
{events.map((e) => {
|
||||
const config = EVENT_CONFIG[e.type]
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: e.read ? 'var(--color-bg-1)' : 'var(--color-primary-light-1)',
|
||||
border: '1px solid var(--color-border-2)',
|
||||
}}
|
||||
>
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Space>
|
||||
<Tag color={config?.color ?? 'gray'} bordered size="small">{labelFor(e.type)}</Tag>
|
||||
<Typography.Text bold style={{ fontSize: 13 }}>{e.title}</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{formatDateTime(e.timestamp)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{e.body ? (
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 4, marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{e.body}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
169
web/src/components/common/GlobalSearch.tsx
Normal file
169
web/src/components/common/GlobalSearch.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Empty, Input, Modal, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconSearch } from '@arco-design/web-react/icon'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { globalSearch, type SearchKind, type SearchResult, type SearchResultItem } from '../../services/search'
|
||||
|
||||
const KIND_LABELS: Record<SearchKind, string> = {
|
||||
task: '任务',
|
||||
record: '备份记录',
|
||||
storage: '存储目标',
|
||||
node: '节点',
|
||||
}
|
||||
|
||||
const KIND_COLORS: Record<SearchKind, string> = {
|
||||
task: 'arcoblue',
|
||||
record: 'green',
|
||||
storage: 'orange',
|
||||
node: 'purple',
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalSearch 顶部 Header 的全局搜索入口。
|
||||
* Ctrl/Cmd+K 快捷键唤起 Modal,输入 300ms 后触发搜索,避免高频请求。
|
||||
*/
|
||||
export function GlobalSearch() {
|
||||
const navigate = useNavigate()
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [result, setResult] = useState<SearchResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const debounceTimer = useRef<number | null>(null)
|
||||
|
||||
// Ctrl/Cmd+K 快捷唤起
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
setVisible(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
const runSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setResult(null)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await globalSearch(q)
|
||||
setResult(res)
|
||||
} catch {
|
||||
setResult(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceTimer.current) {
|
||||
window.clearTimeout(debounceTimer.current)
|
||||
}
|
||||
debounceTimer.current = window.setTimeout(() => {
|
||||
void runSearch(query)
|
||||
}, 300)
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
window.clearTimeout(debounceTimer.current)
|
||||
}
|
||||
}
|
||||
}, [query, runSearch])
|
||||
|
||||
function handleNavigate(item: SearchResultItem) {
|
||||
setVisible(false)
|
||||
navigate(item.url)
|
||||
}
|
||||
|
||||
function renderSection(kind: SearchKind, items: SearchResultItem[]) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div key={kind} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{KIND_LABELS[kind]}({items.length})
|
||||
</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={`${kind}-${item.id}`}
|
||||
onClick={() => handleNavigate(item)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
marginBottom: 4,
|
||||
backgroundColor: 'var(--color-fill-1)',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-primary-light-1)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-fill-1)' }}
|
||||
>
|
||||
<Space>
|
||||
<Tag color={KIND_COLORS[kind]} bordered size="small">{KIND_LABELS[kind]}</Tag>
|
||||
<Typography.Text bold>{item.title}</Typography.Text>
|
||||
{item.subtitle && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{item.subtitle}</Typography.Text>}
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() => setVisible(true)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 12px',
|
||||
borderRadius: 16,
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'var(--color-fill-2)',
|
||||
color: 'var(--color-text-3)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<IconSearch />
|
||||
<span>搜索任务/记录/存储... (Ctrl+K)</span>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={null}
|
||||
footer={null}
|
||||
onCancel={() => setVisible(false)}
|
||||
style={{ width: 720, top: 80 }}
|
||||
unmountOnExit
|
||||
maskClosable
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
size="large"
|
||||
value={query}
|
||||
placeholder="输入关键词搜索任务、备份记录、存储目标、节点..."
|
||||
prefix={<IconSearch />}
|
||||
onChange={setQuery}
|
||||
allowClear
|
||||
/>
|
||||
<div style={{ marginTop: 16, maxHeight: 480, overflow: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
|
||||
) : !result || result.totalCount === 0 ? (
|
||||
<Empty description={query ? '未找到匹配项' : '开始输入以搜索'} />
|
||||
) : (
|
||||
<>
|
||||
{renderSection('task', result.tasks)}
|
||||
{renderSection('record', result.records)}
|
||||
{renderSection('storage', result.storage)}
|
||||
{renderSection('node', result.nodes)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
91
web/src/components/restore-records/RestoreConfirmModal.tsx
Normal file
91
web/src/components/restore-records/RestoreConfirmModal.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Alert, Descriptions, Modal, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import type { BackupRecordDetail } from '../../types/backup-records'
|
||||
import type { BackupTaskDetail } from '../../types/backup-tasks'
|
||||
|
||||
interface RestoreConfirmModalProps {
|
||||
visible: boolean
|
||||
loading: boolean
|
||||
backupRecord: BackupRecordDetail | null
|
||||
task: BackupTaskDetail | null
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
// RestoreConfirmModal 展示即将恢复的备份摘要与覆盖风险,强制用户二次确认。
|
||||
// 恢复是破坏性操作:会覆盖任务配置的源路径/数据库,不可撤销。
|
||||
export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCancel, onConfirm }: RestoreConfirmModalProps) {
|
||||
if (!backupRecord || !task) {
|
||||
return (
|
||||
<Modal visible={visible} title="确认恢复" onCancel={onCancel} onOk={onConfirm} confirmLoading={loading} unmountOnExit>
|
||||
<Alert type="info" content="正在加载任务与备份信息..." />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const restoreTarget = renderRestoreTarget(task)
|
||||
const nodeLabel = task.nodeId && task.nodeId > 0
|
||||
? (task.nodeName ? `${task.nodeName}(远程节点)` : `节点 #${task.nodeId}`)
|
||||
: '本机 Master'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="确认执行恢复"
|
||||
okText="开始恢复"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ status: 'danger', loading }}
|
||||
onCancel={onCancel}
|
||||
onOk={onConfirm}
|
||||
unmountOnExit
|
||||
>
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
content="恢复是破坏性操作:会覆盖目标位置的现有数据,不可撤销。请先确认恢复目标并在必要时保留当前状态的副本。"
|
||||
/>
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
data={[
|
||||
{ label: '任务', value: <Typography.Text bold>{task.name}</Typography.Text> },
|
||||
{ label: '类型', value: <Tag color="arcoblue" bordered>{task.type.toUpperCase()}</Tag> },
|
||||
{ label: '执行节点', value: nodeLabel },
|
||||
{ label: '源备份', value: backupRecord.fileName || '-' },
|
||||
{ label: '恢复目标', value: restoreTarget },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function renderRestoreTarget(task: BackupTaskDetail) {
|
||||
if (task.type === 'file') {
|
||||
const paths = task.sourcePaths && task.sourcePaths.length > 0
|
||||
? task.sourcePaths
|
||||
: task.sourcePath
|
||||
? [task.sourcePath]
|
||||
: []
|
||||
if (paths.length === 0) {
|
||||
return <Typography.Text type="secondary">未配置源路径</Typography.Text>
|
||||
}
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
{paths.map((p) => (
|
||||
<Typography.Text key={p} code>{p}</Typography.Text>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
if (task.type === 'sqlite') {
|
||||
return <Typography.Text code>{task.dbPath || '-'}</Typography.Text>
|
||||
}
|
||||
if (task.type === 'mysql' || task.type === 'postgresql' || task.type === 'saphana') {
|
||||
return (
|
||||
<Typography.Text>
|
||||
{task.dbUser}@{task.dbHost}:{task.dbPort} / <Typography.Text code>{task.dbName || '-'}</Typography.Text>
|
||||
</Typography.Text>
|
||||
)
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
163
web/src/components/restore-records/RestoreRecordLogDrawer.tsx
Normal file
163
web/src/components/restore-records/RestoreRecordLogDrawer.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Alert, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getRestoreRecord, streamRestoreRecordLogs } from '../../services/restore-records'
|
||||
import type { BackupLogEvent } from '../../types/backup-records'
|
||||
import type { RestoreRecordDetail, RestoreRecordStatus } from '../../types/restore-records'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatDateTime, formatDuration } from '../../utils/format'
|
||||
|
||||
interface RestoreRecordLogDrawerProps {
|
||||
visible: boolean
|
||||
restoreId?: number
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function getStatusColor(status: RestoreRecordStatus) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'green'
|
||||
case 'failed':
|
||||
return 'red'
|
||||
default:
|
||||
return 'arcoblue'
|
||||
}
|
||||
}
|
||||
|
||||
function buildLogText(record: RestoreRecordDetail | null, events: BackupLogEvent[]) {
|
||||
if (events.length > 0) {
|
||||
return events.map((item) => `[${formatDateTime(item.timestamp)}] ${item.message}`).join('\n')
|
||||
}
|
||||
return record?.logContent ?? ''
|
||||
}
|
||||
|
||||
export function RestoreRecordLogDrawer({ visible, restoreId, onCancel }: RestoreRecordLogDrawerProps) {
|
||||
const [record, setRecord] = useState<RestoreRecordDetail | null>(null)
|
||||
const [events, setEvents] = useState<BackupLogEvent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [streamError, setStreamError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !restoreId) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentId = restoreId
|
||||
let active = true
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
async function loadDetail() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const detail = await getRestoreRecord(currentId)
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setRecord(detail)
|
||||
setEvents(detail.logEvents ?? [])
|
||||
setError('')
|
||||
setStreamError('')
|
||||
|
||||
if (detail.status === 'running') {
|
||||
unsubscribe = streamRestoreRecordLogs(currentId, {
|
||||
onEvent: (event) => {
|
||||
if (!active) return
|
||||
setEvents((current) => {
|
||||
if (current.some((item) => item.sequence === event.sequence)) {
|
||||
return current
|
||||
}
|
||||
return [...current, event]
|
||||
})
|
||||
if (event.completed) {
|
||||
setRecord((current) => (current ? { ...current, status: event.status as RestoreRecordStatus } : current))
|
||||
}
|
||||
},
|
||||
onDone: () => {
|
||||
if (!active) return
|
||||
void (async () => {
|
||||
try {
|
||||
const latest = await getRestoreRecord(currentId)
|
||||
if (active) {
|
||||
setRecord(latest)
|
||||
setEvents(latest.logEvents ?? [])
|
||||
}
|
||||
} catch (refreshError) {
|
||||
if (active) {
|
||||
setStreamError(resolveErrorMessage(refreshError, '刷新恢复详情失败'))
|
||||
}
|
||||
}
|
||||
})()
|
||||
},
|
||||
onError: (message) => {
|
||||
if (active) {
|
||||
setStreamError(message)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (active) {
|
||||
setError(resolveErrorMessage(loadError, '加载恢复记录失败'))
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDetail()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [restoreId, visible])
|
||||
|
||||
const logText = useMemo(() => buildLogText(record, events), [events, record])
|
||||
|
||||
return (
|
||||
<Drawer width={720} title="恢复记录详情" visible={visible} onCancel={onCancel} footer={null}>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : error ? (
|
||||
<Alert type="error" content={error} />
|
||||
) : record ? (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{streamError ? <Alert type="warning" content={streamError} /> : null}
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
{record.taskName}
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
<Tag color={getStatusColor(record.status)} bordered>
|
||||
{record.status === 'success' ? '成功' : record.status === 'failed' ? '失败' : '执行中'}
|
||||
</Tag>
|
||||
{record.nodeName ? (
|
||||
<Tag color="arcoblue" bordered>节点: {record.nodeName}</Tag>
|
||||
) : record.nodeId === 0 ? (
|
||||
<Tag color="arcoblue" bordered>节点: 本机 Master</Tag>
|
||||
) : null}
|
||||
{record.triggeredBy && <Tag bordered>触发人: {record.triggeredBy}</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
<Descriptions
|
||||
column={1}
|
||||
data={[
|
||||
{ label: '源备份记录', value: `#${record.backupRecordId}${record.backupFileName ? ` (${record.backupFileName})` : ''}` },
|
||||
{ label: '开始时间', value: formatDateTime(record.startedAt) },
|
||||
{ label: '完成时间', value: formatDateTime(record.completedAt) },
|
||||
{ label: '耗时', value: formatDuration(record.durationSeconds) },
|
||||
{ label: '错误信息', value: record.errorMessage || '-' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Typography.Title heading={6}>执行日志</Typography.Title>
|
||||
<div className="log-viewer">{logText || '暂无日志输出'}</div>
|
||||
</div>
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Collapse, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
||||
import { Alert, Button, Collapse, Divider, Drawer, Input, InputNumber, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, isBuiltinType, buildAllTypeOptions } from './field-config'
|
||||
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets'
|
||||
@@ -16,7 +16,7 @@ interface StorageTargetFormDrawerProps {
|
||||
}
|
||||
|
||||
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
|
||||
return { name: '', type, description: '', enabled: true, config: {} }
|
||||
return { name: '', type, description: '', enabled: true, config: {}, quotaBytes: 0 }
|
||||
}
|
||||
|
||||
export function StorageTargetFormDrawer({
|
||||
@@ -51,6 +51,7 @@ export function StorageTargetFormDrawer({
|
||||
description: initialValue.description,
|
||||
enabled: initialValue.enabled,
|
||||
config: { ...initialValue.config },
|
||||
quotaBytes: initialValue.quotaBytes ?? 0,
|
||||
})
|
||||
setError('')
|
||||
setTestResult(null)
|
||||
@@ -224,6 +225,21 @@ export function StorageTargetFormDrawer({
|
||||
<Typography.Text>描述</Typography.Text>
|
||||
<Input.TextArea value={draft.description} placeholder="可选描述" onChange={(v) => setDraft((c) => ({ ...c, description: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>容量配额(GB,0 = 不限制)</Typography.Text>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
value={Math.round((draft.quotaBytes ?? 0) / (1024 * 1024 * 1024))}
|
||||
onChange={(v) => {
|
||||
const gb = Number(v ?? 0)
|
||||
setDraft((c) => ({ ...c, quotaBytes: gb > 0 ? gb * 1024 * 1024 * 1024 : 0 }))
|
||||
}}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
软配额:累计备份字节超出后拒绝新上传。与 85% 容量预警互补,防止失控。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Space align="center" size="medium">
|
||||
<Typography.Text>启用</Typography.Text>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Alert, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getVerificationRecord, streamVerificationRecordLogs } from '../../services/verification-records'
|
||||
import type { BackupLogEvent } from '../../types/backup-records'
|
||||
import type { VerificationRecordDetail, VerificationRecordStatus } from '../../types/verification-records'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatDateTime, formatDuration } from '../../utils/format'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
verifyId?: number
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function statusColor(status: VerificationRecordStatus) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'green'
|
||||
case 'failed':
|
||||
return 'red'
|
||||
default:
|
||||
return 'arcoblue'
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: VerificationRecordStatus) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '通过'
|
||||
case 'failed':
|
||||
return '未通过'
|
||||
case 'running':
|
||||
return '验证中'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
function buildLogText(record: VerificationRecordDetail | null, events: BackupLogEvent[]) {
|
||||
if (events.length > 0) {
|
||||
return events.map((item) => `[${formatDateTime(item.timestamp)}] ${item.message}`).join('\n')
|
||||
}
|
||||
return record?.logContent ?? ''
|
||||
}
|
||||
|
||||
export function VerificationRecordLogDrawer({ visible, verifyId, onCancel }: Props) {
|
||||
const [record, setRecord] = useState<VerificationRecordDetail | null>(null)
|
||||
const [events, setEvents] = useState<BackupLogEvent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [streamError, setStreamError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !verifyId) return
|
||||
const current = verifyId
|
||||
let active = true
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const detail = await getVerificationRecord(current)
|
||||
if (!active) return
|
||||
setRecord(detail)
|
||||
setEvents(detail.logEvents ?? [])
|
||||
setError('')
|
||||
setStreamError('')
|
||||
if (detail.status === 'running') {
|
||||
unsubscribe = streamVerificationRecordLogs(current, {
|
||||
onEvent: (event) => {
|
||||
if (!active) return
|
||||
setEvents((existing) => (existing.some((i) => i.sequence === event.sequence) ? existing : [...existing, event]))
|
||||
if (event.completed) {
|
||||
setRecord((existing) => (existing ? { ...existing, status: event.status as VerificationRecordStatus } : existing))
|
||||
}
|
||||
},
|
||||
onDone: () => {
|
||||
if (!active) return
|
||||
void (async () => {
|
||||
try {
|
||||
const latest = await getVerificationRecord(current)
|
||||
if (active) {
|
||||
setRecord(latest)
|
||||
setEvents(latest.logEvents ?? [])
|
||||
}
|
||||
} catch (e) {
|
||||
if (active) setStreamError(resolveErrorMessage(e, '刷新验证详情失败'))
|
||||
}
|
||||
})()
|
||||
},
|
||||
onError: (message) => {
|
||||
if (active) setStreamError(message)
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
if (active) setError(resolveErrorMessage(e, '加载验证记录失败'))
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [verifyId, visible])
|
||||
|
||||
const logText = useMemo(() => buildLogText(record, events), [events, record])
|
||||
|
||||
return (
|
||||
<Drawer width={720} title="验证记录详情" visible={visible} onCancel={onCancel} footer={null}>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : error ? (
|
||||
<Alert type="error" content={error} />
|
||||
) : record ? (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{streamError ? <Alert type="warning" content={streamError} /> : null}
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
{record.taskName}
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
<Tag color={statusColor(record.status)} bordered>{statusLabel(record.status)}</Tag>
|
||||
<Tag bordered>{record.mode === 'deep' ? '深度模式' : '快速模式'}</Tag>
|
||||
{record.triggeredBy && <Tag bordered>触发: {record.triggeredBy}</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
<Descriptions
|
||||
column={1}
|
||||
data={[
|
||||
{ label: '源备份', value: `#${record.backupRecordId}${record.backupFileName ? ` (${record.backupFileName})` : ''}` },
|
||||
{ label: '验证摘要', value: record.summary || '-' },
|
||||
{ label: '开始时间', value: formatDateTime(record.startedAt) },
|
||||
{ label: '完成时间', value: formatDateTime(record.completedAt) },
|
||||
{ label: '耗时', value: formatDuration(record.durationSeconds) },
|
||||
{ label: '错误信息', value: record.errorMessage || '-' },
|
||||
]}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Title heading={6}>执行日志</Typography.Title>
|
||||
<div className="log-viewer">{logText || '暂无日志输出'}</div>
|
||||
</div>
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
107
web/src/hooks/useEventStream.ts
Normal file
107
web/src/hooks/useEventStream.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
177
web/src/pages/admin/ApiKeysPage.tsx
Normal file
177
web/src/pages/admin/ApiKeysPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Alert, Button, Card, Empty, Form, Input, InputNumber, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createApiKey, listApiKeys, revokeApiKey, toggleApiKey, type ApiKeyCreateInput, type ApiKeySummary } from '../../services/api-keys'
|
||||
import type { UserRole } from '../../services/users'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { isAdmin, roleLabel } from '../../utils/permissions'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
const roleOptions = [
|
||||
{ label: '管理员 (admin)', value: 'admin' },
|
||||
{ label: '运维 (operator)', value: 'operator' },
|
||||
{ label: '只读 (viewer)', value: 'viewer' },
|
||||
]
|
||||
|
||||
// ApiKeysPage API Key 管理(admin 专属)。
|
||||
// 新创建的 Key 明文只返回一次,需要用户立即保存。
|
||||
export function ApiKeysPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const [items, setItems] = useState<ApiKeySummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [draft, setDraft] = useState<ApiKeyCreateInput>({ name: '', role: 'viewer', ttlHours: 0 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [plainKey, setPlainKey] = useState<string>('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setItems(await listApiKeys())
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(resolveErrorMessage(e, '加载 API Key 失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
function openCreate() {
|
||||
setDraft({ name: '', role: 'viewer', ttlHours: 0 })
|
||||
setPlainKey('')
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!draft.name.trim()) {
|
||||
Message.error('名称不能为空')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await createApiKey(draft)
|
||||
setPlainKey(result.plainKey)
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '创建失败'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(item: ApiKeySummary) {
|
||||
try {
|
||||
await toggleApiKey(item.id, !item.disabled)
|
||||
Message.success(item.disabled ? '已启用' : '已停用')
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '操作失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(item: ApiKeySummary) {
|
||||
if (!window.confirm(`确定撤销 API Key「${item.name}」?操作不可撤销。`)) return
|
||||
try {
|
||||
await revokeApiKey(item.id)
|
||||
Message.success('已撤销')
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '撤销失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPlainKey() {
|
||||
if (!plainKey) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(plainKey)
|
||||
Message.success('已复制到剪贴板')
|
||||
} catch {
|
||||
Message.info('请手动选择文本复制')
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
return <Alert type="warning" content="当前账号无权访问 API Key 管理(仅 admin)" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={4}>API Key</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
签发 API Key 供 CI/CD、监控脚本等非交互式场景访问 BackupX。在请求头加 <Typography.Text code>Authorization: Bearer bax_xxx</Typography.Text> 或 <Typography.Text code>X-Api-Key: bax_xxx</Typography.Text> 即可。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" onClick={openCreate}>生成 API Key</Button>
|
||||
</Space>
|
||||
|
||||
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
data={items}
|
||||
pagination={false}
|
||||
stripe
|
||||
noDataElement={<Empty description="暂无 API Key" />}
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '角色', dataIndex: 'role', render: (v: string) => <Tag color="arcoblue" bordered>{roleLabel(v)}</Tag> },
|
||||
{ title: 'Key 前缀', dataIndex: 'prefix', render: (v: string) => <Typography.Text code>{v}…</Typography.Text> },
|
||||
{ title: '创建者', dataIndex: 'createdBy', render: (v: string) => v || '-' },
|
||||
{ title: '最近使用', dataIndex: 'lastUsedAt', render: (v?: string) => v ? formatDateTime(v) : '从未使用' },
|
||||
{ title: '过期', dataIndex: 'expiresAt', render: (v?: string) => v ? formatDateTime(v) : '永不过期' },
|
||||
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered>已停用</Tag> : <Tag color="green" bordered>启用</Tag> },
|
||||
{ title: '操作', width: 180, render: (_: unknown, row: ApiKeySummary) => (
|
||||
<Space>
|
||||
<Button size="small" type="text" onClick={() => void handleToggle(row)}>{row.disabled ? '启用' : '停用'}</Button>
|
||||
<Button size="small" type="text" status="danger" onClick={() => void handleRevoke(row)}>撤销</Button>
|
||||
</Space>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
title="生成 API Key"
|
||||
onCancel={() => { setModalVisible(false); setPlainKey('') }}
|
||||
onOk={plainKey ? () => { setModalVisible(false); setPlainKey('') } : handleSubmit}
|
||||
okText={plainKey ? '完成' : '生成'}
|
||||
confirmLoading={submitting}
|
||||
unmountOnExit
|
||||
>
|
||||
{plainKey ? (
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Alert type="warning" content="明文 Key 只会显示一次,请立即妥善保存。" />
|
||||
<Input.TextArea value={plainKey} autoSize readOnly />
|
||||
<Button type="outline" onClick={() => void copyPlainKey()}>复制到剪贴板</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="名称" required>
|
||||
<Input value={draft.name} onChange={(v) => setDraft({ ...draft, name: v })} placeholder="例如:ci-deploy-script" />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色" required>
|
||||
<Select value={draft.role} options={roleOptions} onChange={(v: UserRole) => setDraft({ ...draft, role: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="有效期(小时,0=永不过期)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} value={draft.ttlHours ?? 0} onChange={(v) => setDraft({ ...draft, ttlHours: Number(v ?? 0) })} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
// 避免未使用告警
|
||||
void Switch
|
||||
179
web/src/pages/admin/UsersPage.tsx
Normal file
179
web/src/pages/admin/UsersPage.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Alert, Button, Card, Empty, Form, Input, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createUser, deleteUser, listUsers, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { isAdmin, roleLabel } from '../../utils/permissions'
|
||||
|
||||
const roleOptions = [
|
||||
{ label: '管理员 (admin)', value: 'admin' },
|
||||
{ label: '运维 (operator)', value: 'operator' },
|
||||
{ label: '只读 (viewer)', value: 'viewer' },
|
||||
]
|
||||
|
||||
function createEmpty(): UserUpsertPayload {
|
||||
return { username: '', password: '', displayName: '', email: '', role: 'operator', disabled: false }
|
||||
}
|
||||
|
||||
// UsersPage admin 用户管理。非 admin 角色进入路由会被路由守卫拦截。
|
||||
export function UsersPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const [items, setItems] = useState<UserSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [editing, setEditing] = useState<UserSummary | null>(null)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [draft, setDraft] = useState<UserUpsertPayload>(createEmpty())
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setItems(await listUsers())
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(resolveErrorMessage(e, '加载用户失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setDraft(createEmpty())
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
function openEdit(item: UserSummary) {
|
||||
setEditing(item)
|
||||
setDraft({
|
||||
username: item.username,
|
||||
password: '',
|
||||
displayName: item.displayName,
|
||||
email: item.email,
|
||||
role: item.role,
|
||||
disabled: item.disabled,
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!draft.username.trim() || !draft.displayName.trim()) {
|
||||
Message.error('用户名与显示名称不能为空')
|
||||
return
|
||||
}
|
||||
if (!editing && !draft.password?.trim()) {
|
||||
Message.error('创建用户必须设置初始密码')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await updateUser(editing.id, draft)
|
||||
Message.success('用户已更新')
|
||||
} else {
|
||||
await createUser(draft)
|
||||
Message.success('用户已创建')
|
||||
}
|
||||
setModalVisible(false)
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '保存失败'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: UserSummary) {
|
||||
if (!window.confirm(`确定删除用户「${item.username}」吗?`)) return
|
||||
try {
|
||||
await deleteUser(item.id)
|
||||
Message.success('已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '删除失败'))
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
return <Alert type="warning" content="当前账号无权访问用户管理(仅 admin)" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={4}>用户管理</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">管理系统账号。角色分为管理员(全权)、运维(日常运维)、只读(仪表盘)。</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" onClick={openCreate}>新建用户</Button>
|
||||
</Space>
|
||||
|
||||
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
data={items}
|
||||
pagination={false}
|
||||
stripe
|
||||
noDataElement={<Empty description="暂无用户" />}
|
||||
columns={[
|
||||
{ title: '用户名', dataIndex: 'username', render: (value: string, row: UserSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text bold>{value}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.displayName}</Typography.Text>
|
||||
</Space>
|
||||
) },
|
||||
{ title: '角色', dataIndex: 'role', render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag> },
|
||||
{ title: '邮箱', dataIndex: 'email', render: (v: string) => v || '-' },
|
||||
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered>已停用</Tag> : <Tag color="green" bordered>启用</Tag> },
|
||||
{ title: '创建时间', dataIndex: 'createdAt' },
|
||||
{ title: '操作', width: 180, render: (_: unknown, row: UserSummary) => (
|
||||
<Space>
|
||||
<Button size="small" type="text" onClick={() => openEdit(row)}>编辑</Button>
|
||||
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(row)} disabled={row.id === user?.id}>删除</Button>
|
||||
</Space>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
title={editing ? '编辑用户' : '新建用户'}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={submitting}
|
||||
unmountOnExit
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="用户名" required>
|
||||
<Input value={draft.username} onChange={(v) => setDraft({ ...draft, username: v })} disabled={!!editing} />
|
||||
</Form.Item>
|
||||
<Form.Item label="显示名称" required>
|
||||
<Input value={draft.displayName} onChange={(v) => setDraft({ ...draft, displayName: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="邮箱">
|
||||
<Input value={draft.email} onChange={(v) => setDraft({ ...draft, email: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label={editing ? '新密码(留空不修改)' : '初始密码'} required={!editing}>
|
||||
<Input.Password value={draft.password} onChange={(v) => setDraft({ ...draft, password: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色" required>
|
||||
<Select value={draft.role} options={roleOptions} onChange={(v: UserRole) => setDraft({ ...draft, role: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="停用账号">
|
||||
<Switch checked={draft.disabled} onChange={(v) => setDraft({ ...draft, disabled: v })} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { Button, DatePicker, Input, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import type { ColumnProps } from '@arco-design/web-react/es/Table'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { listAuditLogs } from '../../services/audit'
|
||||
import { exportAuditLogs, listAuditLogs } from '../../services/audit'
|
||||
import type { AuditLog } from '../../types/audit'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
@@ -90,13 +90,21 @@ export function AuditLogsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [dateRange, setDateRange] = useState<string[] | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async (cat: string, currentPage: number) => {
|
||||
const fetchData = useCallback(async (currentPage: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await listAuditLogs({
|
||||
category: cat || undefined,
|
||||
category: category || undefined,
|
||||
username: username.trim() || undefined,
|
||||
keyword: keyword.trim() || undefined,
|
||||
dateFrom: dateRange?.[0] ? new Date(dateRange[0]).toISOString() : undefined,
|
||||
dateTo: dateRange?.[1] ? new Date(dateRange[1]).toISOString() : undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (currentPage - 1) * PAGE_SIZE,
|
||||
})
|
||||
@@ -108,14 +116,35 @@ export function AuditLogsPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [category, username, keyword, dateRange])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData(category, page)
|
||||
}, [category, page, fetchData])
|
||||
void fetchData(page)
|
||||
}, [page, fetchData])
|
||||
|
||||
function handleCategoryChange(value: string) {
|
||||
setCategory(value)
|
||||
async function handleExport() {
|
||||
setExporting(true)
|
||||
try {
|
||||
await exportAuditLogs({
|
||||
category: category || undefined,
|
||||
username: username.trim() || undefined,
|
||||
keyword: keyword.trim() || undefined,
|
||||
dateFrom: dateRange?.[0] ? new Date(dateRange[0]).toISOString() : undefined,
|
||||
dateTo: dateRange?.[1] ? new Date(dateRange[1]).toISOString() : undefined,
|
||||
})
|
||||
Message.success('CSV 已开始下载')
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '导出失败'))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setCategory('')
|
||||
setUsername('')
|
||||
setKeyword('')
|
||||
setDateRange(null)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
@@ -124,17 +153,39 @@ export function AuditLogsPage() {
|
||||
<PageHeader
|
||||
style={{ paddingBottom: 0 }}
|
||||
title="审计日志"
|
||||
subTitle="记录系统中所有关键操作,保障数据操作链可溯源"
|
||||
subTitle="记录系统中所有关键操作,保障数据操作链可溯源。支持高级筛选与 CSV 导出(最多 10000 行)。"
|
||||
/>
|
||||
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={category}
|
||||
options={categoryOptions}
|
||||
onChange={handleCategoryChange}
|
||||
placeholder="筛选分类"
|
||||
onChange={(v) => { setCategory(v); setPage(1) }}
|
||||
placeholder="分类"
|
||||
/>
|
||||
<Input
|
||||
style={{ width: 160 }}
|
||||
value={username}
|
||||
placeholder="用户名"
|
||||
onChange={setUsername}
|
||||
onPressEnter={() => { setPage(1); void fetchData(1) }}
|
||||
/>
|
||||
<Input
|
||||
style={{ width: 240 }}
|
||||
value={keyword}
|
||||
placeholder="关键词(详情/目标名)"
|
||||
onChange={setKeyword}
|
||||
onPressEnter={() => { setPage(1); void fetchData(1) }}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
showTime
|
||||
value={dateRange ?? undefined}
|
||||
onChange={(v) => { setDateRange(v as string[] | null); setPage(1) }}
|
||||
/>
|
||||
<Button type="primary" onClick={() => { setPage(1); void fetchData(1) }}>查询</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
<Button type="outline" loading={exporting} onClick={() => void handleExport()}>导出 CSV</Button>
|
||||
</Space>
|
||||
<Table
|
||||
columns={columns}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { Button, Card, Empty, Message, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { Button, Card, Empty, Message, Modal, PageHeader, Select, Space, Table, Tag, Typography, Upload } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { BackupTaskDetailDrawer } from '../../components/backup-tasks/BackupTaskDetailDrawer'
|
||||
import { BackupTaskFormDrawer } from '../../components/backup-tasks/BackupTaskFormDrawer'
|
||||
import { TaskDependencyGraph } from '../../components/backup-tasks/TaskDependencyGraph'
|
||||
import { getBackupTaskStatusColor, getBackupTaskStatusLabel, getBackupTaskTypeLabel } from '../../components/backup-tasks/field-config'
|
||||
import { createBackupTask, deleteBackupTask, getBackupTask, listBackupTasks, runBackupTask, toggleBackupTask, updateBackupTask } from '../../services/backup-tasks'
|
||||
import { batchDeleteTasks, batchRunTasks, batchToggleTasks, createBackupTask, deleteBackupTask, exportBackupTasks, getBackupTask, importBackupTasks, listBackupTasks, runBackupTask, toggleBackupTask, updateBackupTask, type TaskImportResult } from '../../services/backup-tasks'
|
||||
import { listNodes } from '../../services/nodes'
|
||||
import { createStorageTarget, listStorageTargets, startGoogleDriveAuth, testStorageTarget } from '../../services/storage-targets'
|
||||
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskSummary } from '../../types/backup-tasks'
|
||||
import type { NodeSummary } from '../../types/nodes'
|
||||
import type { StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { canWrite } from '../../utils/permissions'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
export function BackupTasksPage() {
|
||||
const navigate = useNavigate()
|
||||
const currentUser = useAuthStore((state) => state.user)
|
||||
const writable = canWrite(currentUser)
|
||||
const [tasks, setTasks] = useState<BackupTaskSummary[]>([])
|
||||
const [storageTargets, setStorageTargets] = useState<StorageTargetSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -24,15 +30,42 @@ export function BackupTasksPage() {
|
||||
const [detailTask, setDetailTask] = useState<BackupTaskDetail | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [localNodeId, setLocalNodeId] = useState<number | undefined>(undefined)
|
||||
const [nodes, setNodes] = useState<NodeSummary[]>([])
|
||||
const [tagFilter, setTagFilter] = useState<string[]>([])
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
const [importResults, setImportResults] = useState<TaskImportResult[] | null>(null)
|
||||
|
||||
const enabledStorageTargets = useMemo(() => storageTargets.filter((item) => item.enabled), [storageTargets])
|
||||
|
||||
// 从全量任务中提取所有用过的标签,作为筛选器选项
|
||||
const availableTags = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const task of tasks) {
|
||||
if (!task.tags) continue
|
||||
for (const tag of task.tags.split(',').map((t) => t.trim()).filter(Boolean)) {
|
||||
set.add(tag)
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort()
|
||||
}, [tasks])
|
||||
|
||||
// 按标签筛选
|
||||
const filteredTasks = useMemo(() => {
|
||||
if (tagFilter.length === 0) return tasks
|
||||
return tasks.filter((task) => {
|
||||
const taskTags = (task.tags ?? '').split(',').map((t) => t.trim()).filter(Boolean)
|
||||
return tagFilter.every((filter) => taskTags.includes(filter))
|
||||
})
|
||||
}, [tasks, tagFilter])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [taskList, targetList, nodeList] = await Promise.all([listBackupTasks(), listStorageTargets(), listNodes()])
|
||||
setTasks(taskList)
|
||||
setStorageTargets(targetList)
|
||||
setNodes(nodeList)
|
||||
const localNode = nodeList.find((n) => n.isLocal)
|
||||
if (localNode) {
|
||||
setLocalNodeId(localNode.id)
|
||||
@@ -129,6 +162,77 @@ export function BackupTasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 导出选中或全部任务为 JSON
|
||||
async function handleExport() {
|
||||
try {
|
||||
await exportBackupTasks(selectedIds.length > 0 ? selectedIds : undefined)
|
||||
Message.success(selectedIds.length > 0 ? `已导出 ${selectedIds.length} 个任务` : '已导出全部任务')
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '导出失败'))
|
||||
}
|
||||
}
|
||||
|
||||
// 上传 JSON 并导入任务
|
||||
async function handleImport(file: File): Promise<boolean> {
|
||||
try {
|
||||
const text = await file.text()
|
||||
const payload = JSON.parse(text)
|
||||
const results = await importBackupTasks(payload)
|
||||
setImportResults(results)
|
||||
const succ = results.filter((r) => r.success && !r.skipped).length
|
||||
const skipped = results.filter((r) => r.skipped).length
|
||||
Message.success(`导入完成:创建 ${succ} / 跳过 ${skipped} / 失败 ${results.length - succ - skipped}`)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '导入失败'))
|
||||
}
|
||||
return false // 阻止 Arco Upload 自动上传
|
||||
}
|
||||
|
||||
// 批量操作辅助
|
||||
async function runBatch(
|
||||
action: 'run' | 'enable' | 'disable' | 'delete',
|
||||
) {
|
||||
if (selectedIds.length === 0) {
|
||||
Message.info('请先选择要操作的任务')
|
||||
return
|
||||
}
|
||||
if (action === 'delete' && !window.confirm(`确定删除 ${selectedIds.length} 个任务?操作不可撤销。`)) {
|
||||
return
|
||||
}
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
let results
|
||||
switch (action) {
|
||||
case 'run':
|
||||
results = await batchRunTasks(selectedIds)
|
||||
break
|
||||
case 'enable':
|
||||
results = await batchToggleTasks(selectedIds, true)
|
||||
break
|
||||
case 'disable':
|
||||
results = await batchToggleTasks(selectedIds, false)
|
||||
break
|
||||
case 'delete':
|
||||
results = await batchDeleteTasks(selectedIds)
|
||||
break
|
||||
}
|
||||
const succ = results.filter((r) => r.success).length
|
||||
const fail = results.length - succ
|
||||
if (fail === 0) {
|
||||
Message.success(`成功处理 ${succ} 个任务`)
|
||||
} else {
|
||||
Message.warning(`成功 ${succ} / 失败 ${fail},详情见通知`)
|
||||
}
|
||||
setSelectedIds([])
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '批量操作失败'))
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateStorageTarget(value: StorageTargetPayload) {
|
||||
const result = await createStorageTarget(value)
|
||||
Message.success('存储目标已创建')
|
||||
@@ -192,6 +296,30 @@ export function BackupTasksPage() {
|
||||
dataIndex: 'retentionDays',
|
||||
render: (_: unknown, record: BackupTaskSummary) => `${record.retentionDays} 天 / ${record.maxBackups} 份`,
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tags',
|
||||
render: (value: string) => {
|
||||
const items = (value ?? '').split(',').map((t) => t.trim()).filter(Boolean)
|
||||
if (items.length === 0) return <span style={{ color: 'var(--color-text-3)' }}>-</span>
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{items.map((tag) => <Tag key={tag} color="gray" bordered size="small">{tag}</Tag>)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'SLA',
|
||||
dataIndex: 'slaHoursRpo',
|
||||
render: (value: number, record: BackupTaskSummary) => {
|
||||
if (value <= 0) return <span style={{ color: 'var(--color-text-3)' }}>未配置</span>
|
||||
// 简单着色:仅根据是否启用验证/SLA 显示徽章(实时 SLA 违约见 Dashboard)
|
||||
const bits = [<Tag key="rpo" color="arcoblue" bordered size="small">RPO {value}h</Tag>]
|
||||
if (record.verifyEnabled) bits.push(<Tag key="verify" color="green" bordered size="small">定时验证</Tag>)
|
||||
return <Space size={4} wrap>{bits}</Space>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '最近状态',
|
||||
render: (value: BackupTaskSummary['lastStatus']) => {
|
||||
@@ -213,18 +341,26 @@ export function BackupTasksPage() {
|
||||
<Button size="small" type="text" onClick={() => void openDetail(record.id)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={() => void openEdit(record.id)} loading={submitting && editingTask?.id === record.id}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" type="text" status="success" onClick={() => void handleRun(record)}>
|
||||
立即执行
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={() => void handleToggle(record)}>
|
||||
{record.enabled ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
{writable && (
|
||||
<Button size="small" type="text" onClick={() => void openEdit(record.id)} loading={submitting && editingTask?.id === record.id}>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{writable && (
|
||||
<Button size="small" type="text" status="success" onClick={() => void handleRun(record)}>
|
||||
立即执行
|
||||
</Button>
|
||||
)}
|
||||
{writable && (
|
||||
<Button size="small" type="text" onClick={() => void handleToggle(record)}>
|
||||
{record.enabled ? '停用' : '启用'}
|
||||
</Button>
|
||||
)}
|
||||
{writable && (
|
||||
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -237,16 +373,32 @@ export function BackupTasksPage() {
|
||||
title="备份任务"
|
||||
subTitle="管理文件目录、MySQL、SQLite 与 PostgreSQL 的备份计划,并支持立即执行"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={enabledStorageTargets.length === 0}
|
||||
onClick={() => {
|
||||
setEditingTask(null)
|
||||
setDrawerVisible(true)
|
||||
}}
|
||||
>
|
||||
新建任务
|
||||
</Button>
|
||||
<Space>
|
||||
<Button size="small" onClick={() => void handleExport()}>
|
||||
导出 JSON
|
||||
</Button>
|
||||
{writable && (
|
||||
<Upload
|
||||
accept=".json"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => handleImport(file)}
|
||||
>
|
||||
<Button size="small">导入 JSON</Button>
|
||||
</Upload>
|
||||
)}
|
||||
{writable && (
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={enabledStorageTargets.length === 0}
|
||||
onClick={() => {
|
||||
setEditingTask(null)
|
||||
setDrawerVisible(true)
|
||||
}}
|
||||
>
|
||||
新建任务
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -257,8 +409,61 @@ export function BackupTasksPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TaskDependencyGraph tasks={tasks} />
|
||||
|
||||
{availableTags.length > 0 && (
|
||||
<Card size="small">
|
||||
<Space wrap>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>按标签筛选:</Typography.Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择标签进行过滤(多标签取交集)"
|
||||
style={{ minWidth: 300 }}
|
||||
value={tagFilter}
|
||||
options={availableTags.map((tag) => ({ label: tag, value: tag }))}
|
||||
onChange={(values) => setTagFilter(values as string[])}
|
||||
allowClear
|
||||
/>
|
||||
{tagFilter.length > 0 && (
|
||||
<Button size="small" type="text" onClick={() => setTagFilter([])}>
|
||||
清空筛选
|
||||
</Button>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
共 {filteredTasks.length} / {tasks.length} 个任务
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{writable && selectedIds.length > 0 && (
|
||||
<Card size="small" style={{ backgroundColor: 'var(--color-fill-2)' }}>
|
||||
<Space wrap>
|
||||
<Typography.Text bold>已选 {selectedIds.length} 个任务:</Typography.Text>
|
||||
<Button size="small" type="primary" loading={batchLoading} onClick={() => void runBatch('run')}>批量执行</Button>
|
||||
<Button size="small" loading={batchLoading} onClick={() => void runBatch('enable')}>批量启用</Button>
|
||||
<Button size="small" loading={batchLoading} onClick={() => void runBatch('disable')}>批量停用</Button>
|
||||
<Button size="small" status="danger" loading={batchLoading} onClick={() => void runBatch('delete')}>批量删除</Button>
|
||||
<Button size="small" type="text" onClick={() => setSelectedIds([])}>取消</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Table rowKey="id" loading={loading} columns={columns} data={tasks} pagination={{ pageSize: 10 }} stripe noDataElement={<Empty description="暂无备份任务,请先点击右上角创建任务" />} />
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
data={filteredTasks}
|
||||
pagination={{ pageSize: 10 }}
|
||||
stripe
|
||||
noDataElement={<Empty description={tagFilter.length > 0 ? "当前筛选下无任务" : "暂无备份任务,请先点击右上角创建任务"} />}
|
||||
rowSelection={writable ? {
|
||||
type: 'checkbox',
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) => setSelectedIds(keys.map((k) => Number(k))),
|
||||
} : undefined}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<BackupTaskFormDrawer
|
||||
@@ -267,6 +472,8 @@ export function BackupTasksPage() {
|
||||
initialValue={editingTask}
|
||||
storageTargets={enabledStorageTargets}
|
||||
localNodeId={localNodeId}
|
||||
nodes={nodes}
|
||||
allTasks={tasks.map((t) => ({ id: t.id, name: t.name }))}
|
||||
onCancel={() => {
|
||||
setDrawerVisible(false)
|
||||
setEditingTask(null)
|
||||
@@ -286,6 +493,33 @@ export function BackupTasksPage() {
|
||||
setDetailTask(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={importResults !== null}
|
||||
title="导入结果"
|
||||
footer={null}
|
||||
onCancel={() => setImportResults(null)}
|
||||
style={{ width: 640 }}
|
||||
>
|
||||
{importResults && (
|
||||
<Table
|
||||
rowKey="name"
|
||||
pagination={false}
|
||||
data={importResults}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: '任务名', dataIndex: 'name' },
|
||||
{ title: '状态', render: (_: unknown, r: TaskImportResult) => (
|
||||
r.skipped ? <Tag color="gray" bordered>跳过</Tag>
|
||||
: r.success ? <Tag color="green" bordered>创建</Tag>
|
||||
: <Tag color="red" bordered>失败</Tag>
|
||||
)},
|
||||
{ title: 'ID', dataIndex: 'taskId', render: (v?: number) => v ? `#${v}` : '-' },
|
||||
{ title: '说明', dataIndex: 'error', render: (v?: string) => v || '-' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Avatar, Card, Empty, Grid, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCheckCircle, IconHistory, IconSave, IconStorage } from '@arco-design/web-react/icon'
|
||||
import { Alert, Avatar, Card, Empty, Grid, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCheckCircle, IconDesktop, IconHistory, IconSafe, IconSave, IconStorage } from '@arco-design/web-react/icon'
|
||||
import ReactEChartsCore from 'echarts-for-react/lib/core'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart, PieChart } from 'echarts/charts'
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { fetchDashboardStats, fetchDashboardTimeline } from '../../services/dashboard'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { fetchDashboardBreakdown, fetchDashboardCluster, fetchDashboardNodePerformance, fetchDashboardSLA, fetchDashboardStats, fetchDashboardTimeline } from '../../services/dashboard'
|
||||
import { useEventStream } from '../../hooks/useEventStream'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { BackupTimelinePoint, DashboardStats } from '../../types/dashboard'
|
||||
import type { BackupTimelinePoint, BreakdownStats, ClusterOverview, DashboardStats, NodePerformance, SLAComplianceReport } from '../../types/dashboard'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatBytes, formatDateTime, formatPercent } from '../../utils/format'
|
||||
|
||||
echarts.use([LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
|
||||
const { Row, Col } = Grid
|
||||
|
||||
@@ -20,36 +21,53 @@ export function DashboardPage() {
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [timeline, setTimeline] = useState<BackupTimelinePoint[]>([])
|
||||
const [sla, setSla] = useState<SLAComplianceReport | null>(null)
|
||||
const [cluster, setCluster] = useState<ClusterOverview | null>(null)
|
||||
const [breakdown, setBreakdown] = useState<BreakdownStats | null>(null)
|
||||
const [nodePerf, setNodePerf] = useState<NodePerformance[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void (async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [statsResult, timelineResult] = await Promise.all([fetchDashboardStats(), fetchDashboardTimeline(30)])
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setStats(statsResult)
|
||||
setTimeline(timelineResult || [])
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
if (active) {
|
||||
setError(resolveErrorMessage(loadError, '加载仪表盘失败'))
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
// 统一的数据加载入口。SSE 事件到达时复用该方法刷新。
|
||||
const reload = useCallback(async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true)
|
||||
try {
|
||||
const [statsResult, timelineResult, slaResult, clusterResult, breakdownResult, nodePerfResult] = await Promise.all([
|
||||
fetchDashboardStats(),
|
||||
fetchDashboardTimeline(30),
|
||||
fetchDashboardSLA(),
|
||||
fetchDashboardCluster(),
|
||||
fetchDashboardBreakdown(30),
|
||||
fetchDashboardNodePerformance(30),
|
||||
])
|
||||
setStats(statsResult)
|
||||
setTimeline(timelineResult || [])
|
||||
setSla(slaResult)
|
||||
setCluster(clusterResult)
|
||||
setBreakdown(breakdownResult)
|
||||
setNodePerf(nodePerfResult || [])
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError, '加载仪表盘失败'))
|
||||
} finally {
|
||||
if (showLoading) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void reload(true)
|
||||
}, [reload])
|
||||
|
||||
// 订阅实时事件:备份完成 / 恢复完成 / SLA 违约 / 存储健康变化时自动刷新 Dashboard。
|
||||
// 只关心会影响 Dashboard 显示的事件,避免无关事件造成频繁重渲染。
|
||||
useEventStream(
|
||||
() => {
|
||||
// debounce 500ms:短时间多条事件合并一次刷新
|
||||
void reload(false)
|
||||
},
|
||||
['backup_success', 'backup_failed', 'restore_success', 'restore_failed', 'verify_failed', 'sla_violation', 'storage_unhealthy', 'storage_capacity_warning'],
|
||||
)
|
||||
|
||||
const cards = useMemo(
|
||||
() => [
|
||||
{ label: '备份任务', value: stats?.totalTasks ?? 0, helper: `${stats?.enabledTasks ?? 0} 个已启用`, icon: <IconStorage />, color: 'var(--color-primary-6)', bg: 'var(--color-primary-1)' },
|
||||
@@ -105,6 +123,53 @@ export function DashboardPage() {
|
||||
],
|
||||
}), [timeline])
|
||||
|
||||
// 任务类型分布(饼图)
|
||||
const typeChartOption = useMemo(() => {
|
||||
const data = (breakdown?.byType ?? []).map((item) => ({ name: item.label, value: item.count ?? 0 }))
|
||||
return {
|
||||
tooltip: { trigger: 'item' as const },
|
||||
legend: { bottom: 0, type: 'scroll' as const },
|
||||
series: [{
|
||||
type: 'pie' as const,
|
||||
radius: ['45%', '68%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 6, borderColor: 'var(--color-bg-2)', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
emphasis: { label: { show: true, fontSize: 13, fontWeight: 'bold' } },
|
||||
data,
|
||||
color: ['#165DFF', '#14C9C9', '#FADC19', '#FF7D00', '#722ED1', '#F53F3F'],
|
||||
}],
|
||||
}
|
||||
}, [breakdown])
|
||||
|
||||
// 节点分布(柱状图)
|
||||
const nodeChartOption = useMemo(() => {
|
||||
const items = breakdown?.byNode ?? []
|
||||
return {
|
||||
tooltip: { trigger: 'axis' as const },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: items.map((i) => i.label),
|
||||
axisLabel: { rotate: 30, fontSize: 11, color: 'var(--color-text-3)' },
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: 'var(--color-border-2)' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
minInterval: 1,
|
||||
axisLabel: { color: 'var(--color-text-3)' },
|
||||
splitLine: { lineStyle: { type: 'dashed', color: 'var(--color-border-2)' } },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar' as const,
|
||||
data: items.map((i) => i.count ?? 0),
|
||||
itemStyle: { color: 'var(--color-primary-6)', borderRadius: [4, 4, 0, 0] },
|
||||
barMaxWidth: 40,
|
||||
}],
|
||||
}
|
||||
}, [breakdown])
|
||||
|
||||
const storageChartOption = useMemo(() => {
|
||||
const data = (stats?.storageUsage ?? []).map((s) => ({
|
||||
name: s.targetName || '未命名',
|
||||
@@ -188,6 +253,190 @@ export function DashboardPage() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{breakdown && ((breakdown.byType ?? []).length > 0 || (breakdown.byNode ?? []).length > 0) ? (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card loading={loading} title="任务类型分布">
|
||||
{(breakdown.byType ?? []).length > 0 ? (
|
||||
<ReactEChartsCore echarts={echarts} option={typeChartOption} style={{ height: 260 }} />
|
||||
) : (
|
||||
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography.Text type="secondary">暂无任务</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card loading={loading} title="任务按节点分布">
|
||||
{(breakdown.byNode ?? []).length > 0 ? (
|
||||
<ReactEChartsCore echarts={echarts} option={nodeChartOption} style={{ height: 260 }} />
|
||||
) : (
|
||||
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography.Text type="secondary">暂无数据</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{cluster && cluster.totalNodes > 0 ? (
|
||||
<Card loading={loading} title={
|
||||
<Space>
|
||||
<IconDesktop />
|
||||
<span>集群概览</span>
|
||||
<Tag bordered>Master {cluster.masterVersion || '-'}</Tag>
|
||||
</Space>
|
||||
}>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>节点总数</Typography.Text>
|
||||
<Typography.Title heading={4} style={{ margin: '4px 0 0' }}>{cluster.totalNodes}</Typography.Title>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>在线</Typography.Text>
|
||||
<Typography.Title heading={4} style={{ margin: '4px 0 0', color: 'var(--color-success-6)' }}>{cluster.onlineNodes}</Typography.Title>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>离线</Typography.Text>
|
||||
<Typography.Title heading={4} style={{ margin: '4px 0 0', color: cluster.offlineNodes > 0 ? 'var(--color-danger-6)' : undefined }}>{cluster.offlineNodes}</Typography.Title>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Agent 过期</Typography.Text>
|
||||
<Typography.Title heading={4} style={{ margin: '4px 0 0', color: cluster.outdatedAgents > 0 ? 'var(--color-warning-6)' : undefined }}>{cluster.outdatedAgents}</Typography.Title>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Table
|
||||
style={{ marginTop: 16 }}
|
||||
rowKey="id"
|
||||
stripe
|
||||
pagination={false}
|
||||
data={cluster.nodes}
|
||||
columns={[
|
||||
{ title: '节点', dataIndex: 'name', render: (v: string, row) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text bold>{v}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.hostname || '-'}</Typography.Text>
|
||||
</Space>
|
||||
)},
|
||||
{ title: '状态', dataIndex: 'status', render: (s: string) => <Tag color={s === 'online' ? 'green' : 'red'} bordered>{s === 'online' ? '在线' : '离线'}</Tag> },
|
||||
{ title: '版本', dataIndex: 'agentVersion', render: (v: string, row) => {
|
||||
const color = row.versionStatus === 'outdated' ? 'orange' : row.versionStatus === 'unknown' ? 'gray' : 'arcoblue'
|
||||
const label = row.versionStatus === 'outdated' ? '过期' : row.versionStatus === 'unknown' ? '未知' : '当前'
|
||||
return <Space><Typography.Text>{v || '-'}</Typography.Text><Tag color={color} bordered size="small">{label}</Tag></Space>
|
||||
}},
|
||||
{ title: '任务', dataIndex: 'taskCount', render: (v: number) => `${v} 个` },
|
||||
{ title: '最近心跳', dataIndex: 'lastSeen', render: (v: string) => formatDateTime(v) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{nodePerf.length > 0 && nodePerf.some((n) => n.totalRuns > 0) ? (
|
||||
<Card loading={loading} title="节点执行表现(近 30 天)">
|
||||
<Table
|
||||
rowKey={(r: NodePerformance) => `${r.nodeId}-${r.nodeName}`}
|
||||
stripe
|
||||
pagination={false}
|
||||
data={nodePerf.filter((n) => n.totalRuns > 0)}
|
||||
columns={[
|
||||
{ title: '节点', render: (_: unknown, r: NodePerformance) => (
|
||||
<Space>
|
||||
<Typography.Text bold>{r.nodeName}</Typography.Text>
|
||||
{r.isLocal && <Tag bordered size="small">Master</Tag>}
|
||||
</Space>
|
||||
)},
|
||||
{ title: '执行次数', dataIndex: 'totalRuns', render: (v: number) => `${v}` },
|
||||
{ title: '成功 / 失败', render: (_: unknown, r: NodePerformance) => (
|
||||
<Space>
|
||||
<Typography.Text style={{ color: 'var(--color-success-6)' }}>{r.successRuns}</Typography.Text>
|
||||
<Typography.Text type="secondary">/</Typography.Text>
|
||||
<Typography.Text style={{ color: r.failedRuns > 0 ? 'var(--color-danger-6)' : undefined }}>{r.failedRuns}</Typography.Text>
|
||||
</Space>
|
||||
)},
|
||||
{ title: '成功率', dataIndex: 'successRate', render: (v: number) => {
|
||||
const rate = v * 100
|
||||
const color = rate >= 95 ? 'var(--color-success-6)' : rate >= 80 ? 'var(--color-warning-6)' : 'var(--color-danger-6)'
|
||||
return <Typography.Text style={{ color }}>{rate.toFixed(1)}%</Typography.Text>
|
||||
}},
|
||||
{ title: '备份总量', dataIndex: 'totalBytes', render: (v: number) => formatBytes(v) },
|
||||
{ title: '平均耗时', dataIndex: 'avgDurationSecs', render: (v: number) => {
|
||||
if (v <= 0) return '-'
|
||||
if (v < 60) return `${v.toFixed(0)} 秒`
|
||||
return `${(v / 60).toFixed(1)} 分`
|
||||
}},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{sla && sla.totalTasksWithSla > 0 ? (
|
||||
<Card loading={loading} title={
|
||||
<Space>
|
||||
<IconSafe />
|
||||
<span>SLA 合规</span>
|
||||
<Tag color={sla.violated === 0 ? 'green' : 'red'} bordered>
|
||||
{sla.violated === 0 ? '全部达标' : `${sla.violated} 个违约`}
|
||||
</Tag>
|
||||
</Space>
|
||||
}>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>参与 SLA 任务数</Typography.Text>
|
||||
<Typography.Title heading={4} style={{ margin: '4px 0 0' }}>{sla.totalTasksWithSla}</Typography.Title>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>达标</Typography.Text>
|
||||
<Typography.Title heading={4} style={{ margin: '4px 0 0', color: 'var(--color-success-6)' }}>{sla.compliant}</Typography.Title>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>合规率</Typography.Text>
|
||||
<Typography.Title heading={4} style={{ margin: '4px 0 0' }}>{formatPercent(sla.coverageRate)}</Typography.Title>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
{sla.violations.length > 0 && (
|
||||
<>
|
||||
<Alert type="warning" style={{ marginTop: 16 }} content={`有 ${sla.violations.length} 个任务的 RPO 超标,请尽快排查:`} />
|
||||
<Table
|
||||
style={{ marginTop: 12 }}
|
||||
noDataElement={<Empty description="无违约任务" />}
|
||||
rowKey="taskId"
|
||||
columns={[
|
||||
{ title: '任务', dataIndex: 'taskName', render: (value: string, record: SLAComplianceReport['violations'][number]) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text bold>{value}</Typography.Text>
|
||||
{record.nodeName ? <Typography.Text type="secondary" style={{ fontSize: 12 }}>节点: {record.nodeName}</Typography.Text> : null}
|
||||
</Space>
|
||||
) },
|
||||
{ title: 'RPO 目标', dataIndex: 'slaHoursRpo', render: (value: number) => `${value} 小时` },
|
||||
{ title: '距上次成功', dataIndex: 'hoursSinceLastSuccess', render: (value: number, record: SLAComplianceReport['violations'][number]) =>
|
||||
record.neverSucceeded ? <Tag color="red" bordered>从未成功</Tag> : `${value.toFixed(1)} 小时`,
|
||||
},
|
||||
{ title: '最近成功', dataIndex: 'lastSuccessAt', render: (value?: string) => formatDateTime(value) },
|
||||
]}
|
||||
data={sla.violations}
|
||||
pagination={false}
|
||||
stripe
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card loading={loading} title="最近备份记录">
|
||||
<Table
|
||||
noDataElement={<Empty description="暂无近期运行记录" />}
|
||||
|
||||
116
web/src/pages/replication-records/ReplicationRecordsPage.tsx
Normal file
116
web/src/pages/replication-records/ReplicationRecordsPage.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Card, Empty, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { listReplicationRecords, type ReplicationRecordSummary, type ReplicationStatus } from '../../services/replication-records'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '执行中', value: 'running' },
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]
|
||||
|
||||
function statusColor(s: ReplicationStatus) {
|
||||
switch (s) {
|
||||
case 'success': return 'green'
|
||||
case 'failed': return 'red'
|
||||
default: return 'arcoblue'
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(s: ReplicationStatus) {
|
||||
switch (s) {
|
||||
case 'success': return '成功'
|
||||
case 'failed': return '失败'
|
||||
case 'running': return '执行中'
|
||||
default: return s
|
||||
}
|
||||
}
|
||||
|
||||
// ReplicationRecordsPage 展示备份复制(3-2-1 规则)执行历史。
|
||||
export function ReplicationRecordsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [records, setRecords] = useState<ReplicationRecordSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const status = (searchParams.get('status') ?? '') as ReplicationStatus | ''
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setRecords(await listReplicationRecords({ status }))
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(resolveErrorMessage(e, '加载复制记录失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [status])
|
||||
|
||||
useEffect(() => { void load() }, [load])
|
||||
|
||||
function setStatus(v?: string) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (!v) next.delete('status')
|
||||
else next.set('status', v)
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={4}>备份复制</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
3-2-1 规则核心:每份备份至少存在于 2 个独立存储、1 份异地。启用后系统会在每次备份成功后自动镜像到副本目标。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Space wrap>
|
||||
<div>
|
||||
<Typography.Text>状态筛选</Typography.Text>
|
||||
<Select style={{ width: 180 }} value={status} options={statusOptions} onChange={(v) => setStatus(v ? String(v) : undefined)} />
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
|
||||
|
||||
<Card>
|
||||
{records.length === 0 && !loading ? (
|
||||
<Empty description="暂无复制记录" />
|
||||
) : (
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
data={records}
|
||||
stripe
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{ title: '任务/状态', render: (_: unknown, r: ReplicationRecordSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text bold>任务 #{r.taskId}</Typography.Text>
|
||||
<Tag color={statusColor(r.status)} bordered>{statusLabel(r.status)}</Tag>
|
||||
</Space>
|
||||
)},
|
||||
{ title: '源 → 目标', render: (_: unknown, r: ReplicationRecordSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{r.sourceTargetName || `#${r.sourceTargetId}`}</Typography.Text>
|
||||
<Typography.Text type="secondary">↓ {r.destTargetName || `#${r.destTargetId}`}</Typography.Text>
|
||||
</Space>
|
||||
)},
|
||||
{ title: '大小', dataIndex: 'fileSize', render: (v: number) => formatBytes(v) },
|
||||
{ title: '耗时', dataIndex: 'durationSeconds', render: (v: number) => formatDuration(v) },
|
||||
{ title: '触发', dataIndex: 'triggeredBy', render: (v: string) => v || '-' },
|
||||
{ title: '开始时间', dataIndex: 'startedAt', render: (v: string) => formatDateTime(v) },
|
||||
{ title: '错误', dataIndex: 'errorMessage', render: (v: string) => v || '-' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
183
web/src/pages/restore-records/RestoreRecordsPage.tsx
Normal file
183
web/src/pages/restore-records/RestoreRecordsPage.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Button, Card, Empty, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { RestoreRecordLogDrawer } from '../../components/restore-records/RestoreRecordLogDrawer'
|
||||
import { listRestoreRecords } from '../../services/restore-records'
|
||||
import type { RestoreRecordStatus, RestoreRecordSummary } from '../../types/restore-records'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatDateTime, formatDuration } from '../../utils/format'
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '执行中', value: 'running' },
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]
|
||||
|
||||
function statusColor(status: RestoreRecordStatus) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'green'
|
||||
case 'failed':
|
||||
return 'red'
|
||||
default:
|
||||
return 'arcoblue'
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: RestoreRecordStatus) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '成功'
|
||||
case 'failed':
|
||||
return '失败'
|
||||
case 'running':
|
||||
return '执行中'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
export function RestoreRecordsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [records, setRecords] = useState<RestoreRecordSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const selectedRestoreId = Number(searchParams.get('restoreId') ?? 0) || undefined
|
||||
const selectedStatus = (searchParams.get('status') ?? '') as RestoreRecordStatus | ''
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await listRestoreRecords({ status: selectedStatus })
|
||||
setRecords(items)
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError, '加载恢复记录失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selectedStatus])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
function updateSearchParam(key: 'status' | 'restoreId', value?: string) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (!value) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.set(key, value)
|
||||
}
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '任务 / 状态',
|
||||
dataIndex: 'taskName',
|
||||
render: (_: unknown, record: RestoreRecordSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text bold>{record.taskName}</Typography.Text>
|
||||
<Space>
|
||||
<Tag color={statusColor(record.status)} bordered>{statusLabel(record.status)}</Tag>
|
||||
{record.nodeName ? (
|
||||
<Tag color="arcoblue" bordered>{record.nodeName}</Tag>
|
||||
) : record.nodeId === 0 ? (
|
||||
<Tag color="arcoblue" bordered>本机 Master</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '源备份',
|
||||
render: (_: unknown, record: RestoreRecordSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{record.backupFileName || `#${record.backupRecordId}`}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>备份记录 ID: {record.backupRecordId}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '开始 / 完成',
|
||||
dataIndex: 'startedAt',
|
||||
render: (_: unknown, record: RestoreRecordSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{formatDateTime(record.startedAt)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{formatDateTime(record.completedAt)}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'durationSeconds',
|
||||
render: (value: number) => formatDuration(value),
|
||||
},
|
||||
{
|
||||
title: '触发人',
|
||||
dataIndex: 'triggeredBy',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '错误信息',
|
||||
dataIndex: 'errorMessage',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'actions',
|
||||
width: 120,
|
||||
render: (_: unknown, record: RestoreRecordSummary) => (
|
||||
<Button size="small" type="text" onClick={() => updateSearchParam('restoreId', String(record.id))}>
|
||||
查看日志
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={4}>恢复记录</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
查看备份恢复的执行结果与实时日志。恢复会在任务绑定的节点上执行(本机 Master 或远程 Agent)。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Space wrap>
|
||||
<div>
|
||||
<Typography.Text>状态筛选</Typography.Text>
|
||||
<Select style={{ width: 180 }} value={selectedStatus} options={statusOptions} onChange={(value) => updateSearchParam('status', value ? String(value) : undefined)} />
|
||||
</div>
|
||||
<Button type="outline" onClick={() => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('status')
|
||||
setSearchParams(next, { replace: true })
|
||||
}}>
|
||||
重置筛选
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
|
||||
|
||||
<Card>
|
||||
{records.length === 0 && !loading ? (
|
||||
<Empty description="暂无恢复记录" />
|
||||
) : (
|
||||
<Table rowKey="id" loading={loading} columns={columns} data={records} pagination={{ pageSize: 10 }} stripe noDataElement={<Empty description="暂无符合条件的恢复记录" />} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<RestoreRecordLogDrawer
|
||||
visible={Boolean(selectedRestoreId)}
|
||||
restoreId={selectedRestoreId}
|
||||
onCancel={() => updateSearchParam('restoreId', undefined)}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Alert, Button, Card, Empty, Grid, Message, PageHeader, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import { Alert, Button, Card, Empty, Grid, Message, PageHeader, Progress, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import axios from 'axios'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
createStorageTarget,
|
||||
deleteStorageTarget,
|
||||
getStorageTarget,
|
||||
getStorageTargetUsage,
|
||||
listStorageTargets,
|
||||
startGoogleDriveAuth,
|
||||
testSavedStorageTarget,
|
||||
testStorageTarget,
|
||||
toggleStorageTargetStar,
|
||||
type StorageTargetUsage,
|
||||
updateStorageTarget,
|
||||
} from '../../services/storage-targets'
|
||||
import { formatBytes } from '../../utils/format'
|
||||
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
import { getStorageTargetTypeLabel } from '../../components/storage-targets/field-config'
|
||||
import { StorageTargetFormDrawer } from '../../components/storage-targets/StorageTargetFormDrawer'
|
||||
@@ -42,6 +45,7 @@ export function StorageTargetsPage() {
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const [editingTarget, setEditingTarget] = useState<StorageTargetDetail | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [usageMap, setUsageMap] = useState<Record<number, StorageTargetUsage>>({})
|
||||
|
||||
const loadTargets = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -49,6 +53,22 @@ export function StorageTargetsPage() {
|
||||
const result = await listStorageTargets()
|
||||
setTargets(result)
|
||||
setError('')
|
||||
// 异步加载每个启用目标的使用量(容量 About)。失败不阻塞列表展示。
|
||||
const usageEntries = await Promise.all(
|
||||
result.filter((t) => t.enabled).map(async (t) => {
|
||||
try {
|
||||
const u = await getStorageTargetUsage(t.id)
|
||||
return [t.id, u] as const
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
const next: Record<number, StorageTargetUsage> = {}
|
||||
for (const entry of usageEntries) {
|
||||
if (entry) next[entry[0]] = entry[1]
|
||||
}
|
||||
setUsageMap(next)
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError))
|
||||
} finally {
|
||||
@@ -218,6 +238,37 @@ export function StorageTargetsPage() {
|
||||
{target.lastTestMessage ? (
|
||||
<Typography.Paragraph type="secondary">最近测试:{target.lastTestMessage}</Typography.Paragraph>
|
||||
) : null}
|
||||
{(() => {
|
||||
const usage = usageMap[target.id]
|
||||
if (!usage) return null
|
||||
const disk = usage.diskUsage
|
||||
// 优先后端 About(远端真实容量),否则展示"已用量"(累计备份大小)
|
||||
if (disk && disk.total && disk.used !== undefined) {
|
||||
const rate = disk.total > 0 ? disk.used / disk.total : 0
|
||||
const percent = Math.round(rate * 100)
|
||||
const color = rate >= 0.85 ? '#F53F3F' : rate >= 0.7 ? '#FF7D00' : '#00B42A'
|
||||
return (
|
||||
<div>
|
||||
<Space size="mini" style={{ marginBottom: 4 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>使用率 {percent}%</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatBytes(disk.used)} / {formatBytes(disk.total)}
|
||||
</Typography.Text>
|
||||
{rate >= 0.85 && <Tag color="red" bordered size="small">容量预警</Tag>}
|
||||
</Space>
|
||||
<Progress percent={percent} color={color} size="small" showText={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (usage.totalSize > 0) {
|
||||
return (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
已用备份:{formatBytes(usage.totalSize)}({usage.recordCount} 个记录)
|
||||
</Typography.Text>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
<Typography.Text type="secondary">更新时间:{target.updatedAt}</Typography.Text>
|
||||
|
||||
<Space wrap size="mini">
|
||||
|
||||
207
web/src/pages/task-templates/TaskTemplatesPage.tsx
Normal file
207
web/src/pages/task-templates/TaskTemplatesPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Alert, Button, Card, Empty, Form, Input, InputNumber, Message, Modal, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { applyTaskTemplate, deleteTaskTemplate, getTaskTemplate, listTaskTemplates, type TaskTemplateApplyResult, type TaskTemplateSummary, type TaskTemplateVariables } from '../../services/task-templates'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { canWrite } from '../../utils/permissions'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
interface VariableRow extends TaskTemplateVariables {
|
||||
key: string
|
||||
}
|
||||
|
||||
function newRow(defaults?: Partial<TaskTemplateVariables>): VariableRow {
|
||||
return {
|
||||
key: Math.random().toString(36).slice(2),
|
||||
name: '',
|
||||
sourcePath: defaults?.sourcePath ?? '',
|
||||
dbHost: defaults?.dbHost ?? '',
|
||||
dbName: defaults?.dbName ?? '',
|
||||
tags: defaults?.tags ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// TaskTemplatesPage 任务模板管理 + 批量创建。
|
||||
// 仅 operator/admin 角色看到全部操作,viewer 仅查看列表。
|
||||
export function TaskTemplatesPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const writable = canWrite(user)
|
||||
const [items, setItems] = useState<TaskTemplateSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [applyVisible, setApplyVisible] = useState(false)
|
||||
const [applyTemplateId, setApplyTemplateId] = useState<number | null>(null)
|
||||
const [applyTemplateName, setApplyTemplateName] = useState('')
|
||||
const [rows, setRows] = useState<VariableRow[]>([newRow()])
|
||||
const [applyResult, setApplyResult] = useState<TaskTemplateApplyResult[] | null>(null)
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setItems(await listTaskTemplates())
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(resolveErrorMessage(e, '加载任务模板失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { void load() }, [load])
|
||||
|
||||
async function openApply(item: TaskTemplateSummary) {
|
||||
try {
|
||||
const detail = await getTaskTemplate(item.id)
|
||||
setApplyTemplateId(item.id)
|
||||
setApplyTemplateName(item.name)
|
||||
setRows([newRow({ sourcePath: detail.payload.sourcePath, dbHost: detail.payload.dbHost, dbName: detail.payload.dbName, tags: detail.payload.tags })])
|
||||
setApplyResult(null)
|
||||
setApplyVisible(true)
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '加载模板失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
if (!applyTemplateId) return
|
||||
const variables: TaskTemplateVariables[] = rows
|
||||
.filter((r) => r.name.trim())
|
||||
.map((r) => ({
|
||||
name: r.name.trim(),
|
||||
sourcePath: r.sourcePath?.trim() || undefined,
|
||||
dbHost: r.dbHost?.trim() || undefined,
|
||||
dbName: r.dbName?.trim() || undefined,
|
||||
tags: r.tags?.trim() || undefined,
|
||||
nodeId: r.nodeId,
|
||||
}))
|
||||
if (variables.length === 0) {
|
||||
Message.error('至少填写一条任务名称')
|
||||
return
|
||||
}
|
||||
setApplying(true)
|
||||
try {
|
||||
const result = await applyTaskTemplate(applyTemplateId, variables)
|
||||
setApplyResult(result)
|
||||
const succ = result.filter((r) => r.success).length
|
||||
Message.success(`已创建 ${succ}/${result.length} 个任务`)
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '应用模板失败'))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: TaskTemplateSummary) {
|
||||
if (!window.confirm(`确定删除模板「${item.name}」?`)) return
|
||||
try {
|
||||
await deleteTaskTemplate(item.id)
|
||||
Message.success('已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '删除失败'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={4}>任务模板</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
保存常用任务参数预设,一次性批量创建任务。适合大规模场景(100+ 主机)。在任务表单点击"保存为模板"可创建模板。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
{error ? <Alert type="error" content={error} /> : null}
|
||||
|
||||
<Card>
|
||||
{items.length === 0 && !loading ? (
|
||||
<Empty description="暂无模板。在创建任务时勾选'保存为模板'或通过 API 创建。" />
|
||||
) : (
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
data={items}
|
||||
stripe
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '名称', render: (_: unknown, r: TaskTemplateSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text bold>{r.name}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{r.description || '-'}</Typography.Text>
|
||||
</Space>
|
||||
)},
|
||||
{ title: '类型', dataIndex: 'taskType', render: (v: string) => <Tag color="arcoblue" bordered>{v.toUpperCase()}</Tag> },
|
||||
{ title: '创建者', dataIndex: 'createdBy', render: (v: string) => v || '-' },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', render: (v: string) => formatDateTime(v) },
|
||||
{ title: '操作', width: 240, render: (_: unknown, r: TaskTemplateSummary) => (
|
||||
<Space>
|
||||
{writable && <Button size="small" type="primary" onClick={() => void openApply(r)}>应用</Button>}
|
||||
{writable && <Button size="small" type="text" status="danger" onClick={() => void handleDelete(r)}>删除</Button>}
|
||||
</Space>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
visible={applyVisible}
|
||||
title={`应用模板:${applyTemplateName}`}
|
||||
onCancel={() => setApplyVisible(false)}
|
||||
onOk={applyResult ? () => setApplyVisible(false) : handleApply}
|
||||
okText={applyResult ? '完成' : '批量创建'}
|
||||
confirmLoading={applying}
|
||||
style={{ width: 780 }}
|
||||
unmountOnExit
|
||||
>
|
||||
{applyResult ? (
|
||||
<Table
|
||||
rowKey="name"
|
||||
pagination={false}
|
||||
data={applyResult}
|
||||
columns={[
|
||||
{ title: '任务名', dataIndex: 'name' },
|
||||
{ title: '结果', dataIndex: 'success', render: (v: boolean) => v ? <Tag color="green" bordered>成功</Tag> : <Tag color="red" bordered>失败</Tag> },
|
||||
{ title: '任务 ID', dataIndex: 'taskId', render: (v?: number) => v ? `#${v}` : '-' },
|
||||
{ title: '错误', dataIndex: 'error', render: (v?: string) => v || '-' },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Alert type="info" content="每行一个任务。仅 name 必填;其他字段非空时覆盖模板。" />
|
||||
<Table
|
||||
rowKey="key"
|
||||
pagination={false}
|
||||
data={rows}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: '任务名 *', width: 160, render: (_: unknown, r: VariableRow, idx: number) => (
|
||||
<Input value={r.name} onChange={(v) => setRows((list) => list.map((x, i) => i === idx ? { ...x, name: v } : x))} placeholder="如:prod-web-1" />
|
||||
)},
|
||||
{ title: '源路径', width: 200, render: (_: unknown, r: VariableRow, idx: number) => (
|
||||
<Input value={r.sourcePath} onChange={(v) => setRows((list) => list.map((x, i) => i === idx ? { ...x, sourcePath: v } : x))} placeholder="/var/www" />
|
||||
)},
|
||||
{ title: '数据库主机', width: 140, render: (_: unknown, r: VariableRow, idx: number) => (
|
||||
<Input value={r.dbHost} onChange={(v) => setRows((list) => list.map((x, i) => i === idx ? { ...x, dbHost: v } : x))} placeholder="host-1" />
|
||||
)},
|
||||
{ title: '数据库名', width: 140, render: (_: unknown, r: VariableRow, idx: number) => (
|
||||
<Input value={r.dbName} onChange={(v) => setRows((list) => list.map((x, i) => i === idx ? { ...x, dbName: v } : x))} />
|
||||
)},
|
||||
{ title: '', width: 60, render: (_: unknown, _r: VariableRow, idx: number) => (
|
||||
<Button size="mini" type="text" status="danger" onClick={() => setRows((list) => list.filter((_, i) => i !== idx))}>删除</Button>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
<Button type="outline" long onClick={() => setRows((list) => [...list, newRow()])}>+ 新增一行</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
// 避免未使用变量警告
|
||||
void Form
|
||||
void InputNumber
|
||||
void Select
|
||||
176
web/src/pages/verification-records/VerificationRecordsPage.tsx
Normal file
176
web/src/pages/verification-records/VerificationRecordsPage.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Button, Card, Empty, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { VerificationRecordLogDrawer } from '../../components/verification-records/VerificationRecordLogDrawer'
|
||||
import { listVerificationRecords } from '../../services/verification-records'
|
||||
import type { VerificationRecordStatus, VerificationRecordSummary } from '../../types/verification-records'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatDateTime, formatDuration } from '../../utils/format'
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '验证中', value: 'running' },
|
||||
{ label: '通过', value: 'success' },
|
||||
{ label: '未通过', value: 'failed' },
|
||||
]
|
||||
|
||||
function statusColor(status: VerificationRecordStatus) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'green'
|
||||
case 'failed':
|
||||
return 'red'
|
||||
default:
|
||||
return 'arcoblue'
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: VerificationRecordStatus) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '通过'
|
||||
case 'failed':
|
||||
return '未通过'
|
||||
case 'running':
|
||||
return '验证中'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
export function VerificationRecordsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [records, setRecords] = useState<VerificationRecordSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const selectedVerifyId = Number(searchParams.get('verifyId') ?? 0) || undefined
|
||||
const selectedStatus = (searchParams.get('status') ?? '') as VerificationRecordStatus | ''
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await listVerificationRecords({ status: selectedStatus })
|
||||
setRecords(items)
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(resolveErrorMessage(e, '加载验证记录失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selectedStatus])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
function updateSearchParam(key: 'status' | 'verifyId', value?: string) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (!value) next.delete(key)
|
||||
else next.set(key, value)
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '任务 / 结果',
|
||||
render: (_: unknown, record: VerificationRecordSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text bold>{record.taskName}</Typography.Text>
|
||||
<Space>
|
||||
<Tag color={statusColor(record.status)} bordered>{statusLabel(record.status)}</Tag>
|
||||
<Tag bordered>{record.mode === 'deep' ? '深度' : '快速'}</Tag>
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '摘要 / 源备份',
|
||||
render: (_: unknown, record: VerificationRecordSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{record.summary || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
源备份 #{record.backupRecordId}{record.backupFileName ? ` (${record.backupFileName})` : ''}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '开始 / 完成',
|
||||
render: (_: unknown, record: VerificationRecordSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{formatDateTime(record.startedAt)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{formatDateTime(record.completedAt)}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'durationSeconds',
|
||||
render: (value: number) => formatDuration(value),
|
||||
},
|
||||
{
|
||||
title: '触发',
|
||||
dataIndex: 'triggeredBy',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '错误信息',
|
||||
dataIndex: 'errorMessage',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'actions',
|
||||
width: 120,
|
||||
render: (_: unknown, record: VerificationRecordSummary) => (
|
||||
<Button size="small" type="text" onClick={() => updateSearchParam('verifyId', String(record.id))}>
|
||||
查看日志
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={4}>验证演练</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
自动化校验备份的可恢复性(企业合规刚需)。定时从最新成功备份执行完整性/格式校验,不改动任何源数据。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Space wrap>
|
||||
<div>
|
||||
<Typography.Text>状态筛选</Typography.Text>
|
||||
<Select style={{ width: 180 }} value={selectedStatus} options={statusOptions} onChange={(value) => updateSearchParam('status', value ? String(value) : undefined)} />
|
||||
</div>
|
||||
<Button type="outline" onClick={() => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('status')
|
||||
setSearchParams(next, { replace: true })
|
||||
}}>
|
||||
重置筛选
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
|
||||
|
||||
<Card>
|
||||
{records.length === 0 && !loading ? (
|
||||
<Empty description="暂无验证记录" />
|
||||
) : (
|
||||
<Table rowKey="id" loading={loading} columns={columns} data={records} pagination={{ pageSize: 10 }} stripe noDataElement={<Empty description="暂无验证记录" />} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<VerificationRecordLogDrawer
|
||||
visible={Boolean(selectedVerifyId)}
|
||||
verifyId={selectedVerifyId}
|
||||
onCancel={() => updateSearchParam('verifyId', undefined)}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
45
web/src/services/api-keys.ts
Normal file
45
web/src/services/api-keys.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) ?? []
|
||||
}
|
||||
|
||||
@@ -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) ?? []
|
||||
}
|
||||
|
||||
@@ -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 ?? []
|
||||
}
|
||||
|
||||
53
web/src/services/replication-records.ts
Normal file
53
web/src/services/replication-records.ts
Normal 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)
|
||||
}
|
||||
134
web/src/services/restore-records.ts
Normal file
134
web/src/services/restore-records.ts
Normal 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()
|
||||
}
|
||||
26
web/src/services/search.ts
Normal file
26
web/src/services/search.ts
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
69
web/src/services/task-templates.ts
Normal file
69
web/src/services/task-templates.ts
Normal 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
42
web/src/services/users.ts
Normal 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)
|
||||
}
|
||||
105
web/src/services/verification-records.ts
Normal file
105
web/src/services/verification-records.ts
Normal 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
46
web/src/stores/events.ts
Normal 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 }),
|
||||
}))
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
32
web/src/types/restore-records.ts
Normal file
32
web/src/types/restore-records.ts
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
34
web/src/types/verification-records.ts
Normal file
34
web/src/types/verification-records.ts
Normal 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
|
||||
}
|
||||
40
web/src/utils/permissions.ts
Normal file
40
web/src/utils/permissions.ts
Normal 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 ?? '-'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user