diff --git a/.gitignore b/.gitignore index ac8d584..bfeb8e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ web/node_modules/ web/dist/ -server/bin/ \ No newline at end of file +server/bin/ +.claude/ \ No newline at end of file diff --git a/docs/superpowers/specs/2026-04-19-cluster-restore-and-node-selector-design.md b/docs/superpowers/specs/2026-04-19-cluster-restore-and-node-selector-design.md new file mode 100644 index 0000000..2545e9c --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-cluster-restore-and-node-selector-design.md @@ -0,0 +1,288 @@ +# 设计文档:集群感知恢复功能 & 任务节点选择器 + +- 日期:2026-04-19 +- 状态:已通过(用户授权自主执行) +- 影响范围:server、web、agent +- 关联讨论:社区反馈"PVE 服务器能备份吗?有没有一键恢复"及作者回复"好像写成 bug 了"、"一键恢复后续优化" + +## 1. 问题定义 + +### 1.1 B1 — 任务表单缺少执行节点选择器(Bug) + +`web/src/components/backup-tasks/BackupTaskFormDrawer.tsx` 的草稿对象里有 `nodeId: 0` 字段,编辑时也能从 `initialValue.nodeId` 回填,但三步表单(基础/源/存储策略)**完全没有任何 Select 让用户选择节点**。结果: + +- 所有任务被迫以 `nodeId = 0` 创建(Master 本地执行) +- 已安装的远程 Agent 根本拉不到 `run_task` 命令 +- 多节点集群的核心价值失效 + +后端 `BackupExecutionService.startTask` 通过 `isRemoteNode(task.NodeID)` 判断路由,能力本就支持远程执行,缺口只在 UI。 + +### 1.2 恢复功能底层错误(架构级) + +`server/internal/service/backup_execution_service.go:175 RestoreRecord`: + +1. **同步阻塞**:HTTP POST 同步执行完整恢复流程,大文件/大库必超时 +2. **忽视节点路由**:总是在 Master 本地 `runner.Restore`,无论任务绑定哪个节点 +3. **无日志/无记录**:传 `backup.NopLogWriter{}`,用户看不到任何进度或失败原因;未建独立恢复记录 +4. **前端误用状态**:`BackupRecordLogDrawer.handleRestore` 把"恢复已提交"塞进 `setStreamError`,UI 渲染为黄色警告 + +**架构后果**:任务绑定到 Agent 节点 A(源文件/数据库只在 A 可达)时,点击恢复 → Master 下载备份 → Master 本地恢复 → **文件写到 Master 的 `/var/www`、连 Master 本地不存在的数据库**。完全错的机器。 + +Agent 端 `server/internal/agent/executor.go` 只实现了 `handleRunTask` 与 `handleListDir`,从设计上就没有恢复能力。 + +## 2. 设计目标 + +- 恢复与备份**对称**:支持本地/远程节点路由,同一套设施(AgentCommand 队列、日志流) +- 恢复一等公民:独立 `RestoreRecord` 模型 + 异步执行 + LogHub SSE + 列表页 +- 破坏性操作必须**可见且可确认**:前端恢复前弹窗展示目标位置、覆盖警告 +- 复用现有基建,不引入新依赖/新抽象层 + +## 3. 架构设计 + +### 3.1 数据层 + +```go +// model/restore_record.go +type RestoreRecord struct { + ID uint + BackupRecordID uint // 源备份记录 + TaskID uint // 冗余:便于筛选 + NodeID uint // 在哪个节点执行 + Status string // running|success|failed + ErrorMessage string + LogContent string + DurationSeconds int + StartedAt time.Time + CompletedAt *time.Time + TriggeredBy string // 用户名(审计冗余) + CreatedAt, UpdatedAt +} +``` + +迁移:`database.go` 的 `AutoMigrate` 增加 `&model.RestoreRecord{}`。 + +### 3.2 服务层 + +新增 `service.RestoreService`: + +```go +type RestoreService struct { + restores repository.RestoreRecordRepository + records repository.BackupRecordRepository + tasks repository.BackupTaskRepository + targets repository.StorageTargetRepository + nodeRepo repository.NodeRepository + storage *storage.Registry + runners *backup.Registry + logHub *backup.LogHub + cipher *codec.ConfigCipher + dispatcher AgentDispatcher + // ...依赖同 BackupExecutionService +} + +// 启动恢复:同步创建 RestoreRecord → 判断路由 → 返回记录 +func (s *RestoreService) Start(ctx, backupRecordID, triggeredBy) (*RestoreRecordDetail, error) + +// Master 本地执行:下载 → 解密/解压 → runner.Restore(LogSink → LogHub) +func (s *RestoreService) executeLocally(ctx, restoreID) + +// Agent 路由:EnqueueCommand("restore_record", {restoreRecordId}) +func (s *RestoreService) dispatchToAgent(ctx, restore *model.RestoreRecord) +``` + +路由决策: + +``` +restore := 创建 RestoreRecord(status=running, nodeId=task.NodeID) +if isRemoteNode(task.NodeID): + EnqueueCommand(nodeID, "restore_record", {restoreRecordId: restore.ID}) +else: + go executeLocally(restore.ID) // 复用 BackupExecutionService.semaphore? 不,独立通道避免阻塞备份 +return restore +``` + +### 3.3 Agent 端 + +#### 3.3.1 新增命令类型 + +`model/agent_command.go`: + +```go +const AgentCommandTypeRestoreRecord = "restore_record" // Payload: {"restoreRecordId": N} +``` + +#### 3.3.2 Master ↔ Agent API(复用 Agent API 组) + +- `GET /api/agent/restores/:id/spec` → 返回 `AgentRestoreSpec`(已解密存储配置、任务 spec、备份记录 storagePath/fileName) +- `POST /api/agent/restores/:id` → `AgentRestoreUpdate`(status / errorMessage / logAppend) + +`AgentRestoreSpec`: + +```go +type AgentRestoreSpec struct { + RestoreRecordID uint + BackupRecordID uint + TaskID uint + TaskName, Type string + SourcePath string + SourcePaths string + DBHost, DBName string + // ... 同 AgentTaskSpec 的任务字段 + Storage AgentStorageTargetConfig // 只需下载源目标 + StoragePath string // 远端对象 key + FileName string + Compression string + Encrypt bool // 当前 Agent 不支持加密恢复,直接返回失败 +} +``` + +#### 3.3.3 Agent Executor + +`agent/executor.go` 新增 `ExecuteRestore(restoreRecordID)`: + +1. `client.GetRestoreSpec(restoreRecordID)` +2. 若 `Encrypt == true` → `UpdateRestoreRecord(status=failed, errorMessage="Agent 不支持加密恢复")` +3. 临时目录下载备份文件(通过 storage provider `Download`) +4. `.enc` 或 `.gz` 的逆向处理(当前不支持加密;`.gz` 调 `compress.GunzipFile`) +5. `runner.Restore(backupSpec, preparedPath, restoreLogger)` — logger 把每行通过 `UpdateRestoreRecord{LogAppend}` 回传 +6. 成功 → `UpdateRestoreRecord(status=success)` + +`agent/agent.go` 的 `switch cmd.Type` 增加 `"restore_record": handleRestoreRecord`。 + +### 3.4 HTTP 层 + +新增 handler `restore_record_handler.go`: + +``` +POST /api/backup/records/:id/restore → 202,body: {restoreRecordId} +GET /api/restore/records → 列表(支持 ?taskId, ?status 筛选) +GET /api/restore/records/:id → 详情(含 logContent) +GET /api/restore/records/:id/logs/stream → SSE(复用 LogHub,sequence 事件协议) +``` + +Agent 端点 `agent_handler.go`: + +``` +GET /api/agent/restores/:id/spec +POST /api/agent/restores/:id +``` + +`router.go` 对应注册。注意:`LogHub` 的 recordID 命名空间是 `uint`,恢复记录 ID 可能与备份记录 ID 冲突 → 决策: + +- **方案**:LogHub 加 `topic` 维度 —— 工作量较大 +- **简化方案**:恢复记录用 `restoreID + 常量偏移` 或使用独立 `LogHub` 实例 + +本次选择**独立 LogHub 实例**(`RestoreLogHub`),彻底隔离,代码量最小。 + +### 3.5 前端 + +#### 3.5.1 修 B1 — 节点选择器 + +`BackupTaskFormDrawer.tsx`: + +- 已有 `localNodeId` prop +- 新增 `nodes: NodeSummary[]` prop(由父组件传入) +- `renderBasicStep()` 增加: + +```tsx +
+ 执行节点 + updateTaskType(value as BackupTaskType)} />
+
+ 执行节点 + updateDraft({ tags: value })} + /> +
备份后加密 updateDraft({ encrypt: checked })} /> + + + SLA 与告警(企业合规) + +
+ RPO 目标(小时,0=不监控) + updateDraft({ slaHoursRpo: Number(value ?? 0) })} + /> + + 距最近一次成功备份超过此小时数视为 SLA 违约,Dashboard 会高亮。 + +
+
+ 连续失败几次再告警 + updateDraft({ alertOnConsecutiveFails: Number(value ?? 1) })} + /> + + 避免偶发失败的告警噪音。设为 1 表示每次失败都告警。 + +
+ + + 维护窗口(避开业务高峰) + +
+ 允许执行的时段 + updateDraft({ maintenanceWindows: v })} + /> + + 留空 = 无限制。非窗口时间调度会自动跳过,手动执行会被拒绝。多段用 ; 分隔。 + +
+ + + 任务依赖(工作流) + +
+ 上游任务(完成后触发本任务) + !(draft.storageTargetIds ?? []).includes(opt.value as number))} + onChange={(values: number[]) => updateDraft({ replicationTargetIds: values })} + /> + + 备份成功后自动镜像到副本存储。满足 3-2-1 规则:至少 2 份副本、至少 1 份异地。建议选不同 provider 的目标。 + +
+ + + 验证演练(可恢复性保证) + + + 启用定时验证 + updateDraft({ verifyEnabled: checked })} /> + + {draft.verifyEnabled && ( + <> +
+ 验证 Cron 表达式 + updateDraft({ verifyCronExpr: value })} /> + + 定期从最新成功备份自动校验可恢复性,满足企业合规(SOC2/ISO27001)。 + +
+
+ 验证模式 + } + onChange={setQuery} + allowClear + /> +
+ {loading ? ( +
+ ) : !result || result.totalCount === 0 ? ( + + ) : ( + <> + {renderSection('task', result.tasks)} + {renderSection('record', result.records)} + {renderSection('storage', result.storage)} + {renderSection('node', result.nodes)} + + )} +
+ + + ) +} diff --git a/web/src/components/restore-records/RestoreConfirmModal.tsx b/web/src/components/restore-records/RestoreConfirmModal.tsx new file mode 100644 index 0000000..569d7b5 --- /dev/null +++ b/web/src/components/restore-records/RestoreConfirmModal.tsx @@ -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 ( + + + + ) + } + + const restoreTarget = renderRestoreTarget(task) + const nodeLabel = task.nodeId && task.nodeId > 0 + ? (task.nodeName ? `${task.nodeName}(远程节点)` : `节点 #${task.nodeId}`) + : '本机 Master' + + return ( + + + + {task.name} }, + { label: '类型', value: {task.type.toUpperCase()} }, + { label: '执行节点', value: nodeLabel }, + { label: '源备份', value: backupRecord.fileName || '-' }, + { label: '恢复目标', value: restoreTarget }, + ]} + /> + + + ) +} + +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 未配置源路径 + } + return ( + + {paths.map((p) => ( + {p} + ))} + + ) + } + if (task.type === 'sqlite') { + return {task.dbPath || '-'} + } + if (task.type === 'mysql' || task.type === 'postgresql' || task.type === 'saphana') { + return ( + + {task.dbUser}@{task.dbHost}:{task.dbPort} / {task.dbName || '-'} + + ) + } + return '-' +} diff --git a/web/src/components/restore-records/RestoreRecordLogDrawer.tsx b/web/src/components/restore-records/RestoreRecordLogDrawer.tsx new file mode 100644 index 0000000..bde98c1 --- /dev/null +++ b/web/src/components/restore-records/RestoreRecordLogDrawer.tsx @@ -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(null) + const [events, setEvents] = useState([]) + 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 ( + + {loading ? ( + + ) : error ? ( + + ) : record ? ( + + {streamError ? : null} +
+ + {record.taskName} + + + + {record.status === 'success' ? '成功' : record.status === 'failed' ? '失败' : '执行中'} + + {record.nodeName ? ( + 节点: {record.nodeName} + ) : record.nodeId === 0 ? ( + 节点: 本机 Master + ) : null} + {record.triggeredBy && 触发人: {record.triggeredBy}} + +
+ + +
+ 执行日志 +
{logText || '暂无日志输出'}
+
+
+ ) : null} +
+ ) +} diff --git a/web/src/components/storage-targets/StorageTargetFormDrawer.tsx b/web/src/components/storage-targets/StorageTargetFormDrawer.tsx index ec96203..fb93d66 100644 --- a/web/src/components/storage-targets/StorageTargetFormDrawer.tsx +++ b/web/src/components/storage-targets/StorageTargetFormDrawer.tsx @@ -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({ 描述 setDraft((c) => ({ ...c, description: v }))} />
+
+ 容量配额(GB,0 = 不限制) + { + const gb = Number(v ?? 0) + setDraft((c) => ({ ...c, quotaBytes: gb > 0 ? gb * 1024 * 1024 * 1024 : 0 })) + }} + /> + + 软配额:累计备份字节超出后拒绝新上传。与 85% 容量预警互补,防止失控。 + +
启用 diff --git a/web/src/components/verification-records/VerificationRecordLogDrawer.tsx b/web/src/components/verification-records/VerificationRecordLogDrawer.tsx new file mode 100644 index 0000000..542603d --- /dev/null +++ b/web/src/components/verification-records/VerificationRecordLogDrawer.tsx @@ -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(null) + const [events, setEvents] = useState([]) + 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 ( + + {loading ? ( + + ) : error ? ( + + ) : record ? ( + + {streamError ? : null} +
+ + {record.taskName} + + + {statusLabel(record.status)} + {record.mode === 'deep' ? '深度模式' : '快速模式'} + {record.triggeredBy && 触发: {record.triggeredBy}} + +
+ +
+ 执行日志 +
{logText || '暂无日志输出'}
+
+
+ ) : null} +
+ ) +} diff --git a/web/src/hooks/useEventStream.ts b/web/src/hooks/useEventStream.ts new file mode 100644 index 0000000..e8398df --- /dev/null +++ b/web/src/hooks/useEventStream.ts @@ -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 + 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 + } +} diff --git a/web/src/layouts/AppLayout.tsx b/web/src/layouts/AppLayout.tsx index 82e6d9d..cc6b045 100644 --- a/web/src/layouts/AppLayout.tsx +++ b/web/src/layouts/AppLayout.tsx @@ -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: }, { key: '/backup/tasks', label: '备份任务', icon: }, { key: '/backup/records', label: '备份记录', icon: }, + { key: '/restore/records', label: '恢复记录', icon: }, + { key: '/verify/records', label: '验证演练', icon: }, + { key: '/replication/records', label: '备份复制', icon: }, + { key: '/task-templates', label: '任务模板', icon: }, { key: '/storage-targets', label: '存储目标', icon: }, { key: '/nodes', label: '节点管理', icon: }, { key: '/settings/notifications', label: '通知配置', icon: }, + { key: '/admin/users', label: '用户管理', icon: , adminOnly: true }, + { key: '/admin/api-keys', label: 'API Key', icon: , adminOnly: true }, { key: '/audit', label: '审计日志', icon: }, { key: '/settings', label: '系统设置', icon: }, ] @@ -113,12 +153,14 @@ export function AppLayout() { {!collapsed && BackupX} navigate(key)}> - {menuItems.map((item) => ( - - {item.icon} - {item.label} - - ))} + {menuItems + .filter((item) => !item.adminOnly || isAdmin(user)) + .map((item) => ( + + {item.icon} + {item.label} + + ))} {!collapsed && (
@@ -128,18 +170,23 @@ export function AppLayout() {
- diff --git a/web/src/pages/admin/ApiKeysPage.tsx b/web/src/pages/admin/ApiKeysPage.tsx new file mode 100644 index 0000000..56c9b56 --- /dev/null +++ b/web/src/pages/admin/ApiKeysPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [modalVisible, setModalVisible] = useState(false) + const [draft, setDraft] = useState({ name: '', role: 'viewer', ttlHours: 0 }) + const [submitting, setSubmitting] = useState(false) + const [plainKey, setPlainKey] = useState('') + + 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 + } + + return ( + +
+ API Key + + 签发 API Key 供 CI/CD、监控脚本等非交互式场景访问 BackupX。在请求头加 Authorization: Bearer bax_xxxX-Api-Key: bax_xxx 即可。 + +
+ + + + + + {error ? {error} : null} + + + } + columns={[ + { title: '名称', dataIndex: 'name' }, + { title: '角色', dataIndex: 'role', render: (v: string) => {roleLabel(v)} }, + { title: 'Key 前缀', dataIndex: 'prefix', render: (v: string) => {v}… }, + { 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 ? 已停用 : 启用 }, + { title: '操作', width: 180, render: (_: unknown, row: ApiKeySummary) => ( + + + + + ) }, + ]} + /> + + + { setModalVisible(false); setPlainKey('') }} + onOk={plainKey ? () => { setModalVisible(false); setPlainKey('') } : handleSubmit} + okText={plainKey ? '完成' : '生成'} + confirmLoading={submitting} + unmountOnExit + > + {plainKey ? ( + + + + + + ) : ( +
+ + setDraft({ ...draft, name: v })} placeholder="例如:ci-deploy-script" /> + + +
} + columns={[ + { title: '用户名', dataIndex: 'username', render: (value: string, row: UserSummary) => ( + + {value} + {row.displayName} + + ) }, + { title: '角色', dataIndex: 'role', render: (value: string) => {roleLabel(value)} }, + { title: '邮箱', dataIndex: 'email', render: (v: string) => v || '-' }, + { title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? 已停用 : 启用 }, + { title: '创建时间', dataIndex: 'createdAt' }, + { title: '操作', width: 180, render: (_: unknown, row: UserSummary) => ( + + + + + ) }, + ]} + /> + + + setModalVisible(false)} + onOk={handleSubmit} + confirmLoading={submitting} + unmountOnExit + > + + + setDraft({ ...draft, username: v })} disabled={!!editing} /> + + + setDraft({ ...draft, displayName: v })} /> + + + setDraft({ ...draft, email: v })} /> + + + setDraft({ ...draft, password: v })} /> + + + { setCategory(v); setPage(1) }} + placeholder="分类" /> + { setPage(1); void fetchData(1) }} + /> + { setPage(1); void fetchData(1) }} + /> + { setDateRange(v as string[] | null); setPage(1) }} + /> + + +
state.user) + const writable = canWrite(currentUser) const [tasks, setTasks] = useState([]) const [storageTargets, setStorageTargets] = useState([]) const [loading, setLoading] = useState(true) @@ -24,15 +30,42 @@ export function BackupTasksPage() { const [detailTask, setDetailTask] = useState(null) const [error, setError] = useState('') const [localNodeId, setLocalNodeId] = useState(undefined) + const [nodes, setNodes] = useState([]) + const [tagFilter, setTagFilter] = useState([]) + const [selectedIds, setSelectedIds] = useState([]) + const [batchLoading, setBatchLoading] = useState(false) + const [importResults, setImportResults] = useState(null) const enabledStorageTargets = useMemo(() => storageTargets.filter((item) => item.enabled), [storageTargets]) + // 从全量任务中提取所有用过的标签,作为筛选器选项 + const availableTags = useMemo(() => { + const set = new Set() + 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 { + 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 - + return ( + + {items.map((tag) => {tag})} + + ) + }, + }, + { + title: 'SLA', + dataIndex: 'slaHoursRpo', + render: (value: number, record: BackupTaskSummary) => { + if (value <= 0) return 未配置 + // 简单着色:仅根据是否启用验证/SLA 显示徽章(实时 SLA 违约见 Dashboard) + const bits = [RPO {value}h] + if (record.verifyEnabled) bits.push(定时验证) + return {bits} + }, + }, { title: '最近状态', render: (value: BackupTaskSummary['lastStatus']) => { @@ -213,18 +341,26 @@ export function BackupTasksPage() { - - - - + {writable && ( + + )} + {writable && ( + + )} + {writable && ( + + )} + {writable && ( + + )} ), }, @@ -237,16 +373,32 @@ export function BackupTasksPage() { title="备份任务" subTitle="管理文件目录、MySQL、SQLite 与 PostgreSQL 的备份计划,并支持立即执行" extra={ - + + + {writable && ( + handleImport(file)} + > + + + )} + {writable && ( + + )} + } /> @@ -257,8 +409,61 @@ export function BackupTasksPage() { ) : null} + + + {availableTags.length > 0 && ( + + + 按标签筛选: +
} /> +
0 ? "当前筛选下无任务" : "暂无备份任务,请先点击右上角创建任务"} />} + rowSelection={writable ? { + type: 'checkbox', + selectedRowKeys: selectedIds, + onChange: (keys) => setSelectedIds(keys.map((k) => Number(k))), + } : undefined} + /> ({ id: t.id, name: t.name }))} onCancel={() => { setDrawerVisible(false) setEditingTask(null) @@ -286,6 +493,33 @@ export function BackupTasksPage() { setDetailTask(null) }} /> + + setImportResults(null)} + style={{ width: 640 }} + > + {importResults && ( +
( + r.skipped ? 跳过 + : r.success ? 创建 + : 失败 + )}, + { title: 'ID', dataIndex: 'taskId', render: (v?: number) => v ? `#${v}` : '-' }, + { title: '说明', dataIndex: 'error', render: (v?: string) => v || '-' }, + ]} + /> + )} + ) } diff --git a/web/src/pages/dashboard/DashboardPage.tsx b/web/src/pages/dashboard/DashboardPage.tsx index 37770b7..9cc99d0 100644 --- a/web/src/pages/dashboard/DashboardPage.tsx +++ b/web/src/pages/dashboard/DashboardPage.tsx @@ -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(null) const [timeline, setTimeline] = useState([]) + const [sla, setSla] = useState(null) + const [cluster, setCluster] = useState(null) + const [breakdown, setBreakdown] = useState(null) + const [nodePerf, setNodePerf] = useState([]) 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: , 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() { + {breakdown && ((breakdown.byType ?? []).length > 0 || (breakdown.byNode ?? []).length > 0) ? ( + + + + {(breakdown.byType ?? []).length > 0 ? ( + + ) : ( +
+ 暂无任务 +
+ )} +
+ + + + {(breakdown.byNode ?? []).length > 0 ? ( + + ) : ( +
+ 暂无数据 +
+ )} +
+ + + ) : null} + + {cluster && cluster.totalNodes > 0 ? ( + + + 集群概览 + Master {cluster.masterVersion || '-'} + + }> + + +
+ 节点总数 + {cluster.totalNodes} +
+ + +
+ 在线 + {cluster.onlineNodes} +
+ + +
+ 离线 + 0 ? 'var(--color-danger-6)' : undefined }}>{cluster.offlineNodes} +
+ + +
+ Agent 过期 + 0 ? 'var(--color-warning-6)' : undefined }}>{cluster.outdatedAgents} +
+ + +
( + + {v} + {row.hostname || '-'} + + )}, + { title: '状态', dataIndex: 'status', render: (s: string) => {s === 'online' ? '在线' : '离线'} }, + { 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 {v || '-'}{label} + }}, + { title: '任务', dataIndex: 'taskCount', render: (v: number) => `${v} 个` }, + { title: '最近心跳', dataIndex: 'lastSeen', render: (v: string) => formatDateTime(v) }, + ]} + /> + + ) : null} + + {nodePerf.length > 0 && nodePerf.some((n) => n.totalRuns > 0) ? ( + +
`${r.nodeId}-${r.nodeName}`} + stripe + pagination={false} + data={nodePerf.filter((n) => n.totalRuns > 0)} + columns={[ + { title: '节点', render: (_: unknown, r: NodePerformance) => ( + + {r.nodeName} + {r.isLocal && Master} + + )}, + { title: '执行次数', dataIndex: 'totalRuns', render: (v: number) => `${v}` }, + { title: '成功 / 失败', render: (_: unknown, r: NodePerformance) => ( + + {r.successRuns} + / + 0 ? 'var(--color-danger-6)' : undefined }}>{r.failedRuns} + + )}, + { 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 {rate.toFixed(1)}% + }}, + { 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)} 分` + }}, + ]} + /> + + ) : null} + + {sla && sla.totalTasksWithSla > 0 ? ( + + + SLA 合规 + + {sla.violated === 0 ? '全部达标' : `${sla.violated} 个违约`} + + + }> + + +
+ 参与 SLA 任务数 + {sla.totalTasksWithSla} +
+ + +
+ 达标 + {sla.compliant} +
+ + +
+ 合规率 + {formatPercent(sla.coverageRate)} +
+ + + {sla.violations.length > 0 && ( + <> + +
} + rowKey="taskId" + columns={[ + { title: '任务', dataIndex: 'taskName', render: (value: string, record: SLAComplianceReport['violations'][number]) => ( + + {value} + {record.nodeName ? 节点: {record.nodeName} : null} + + ) }, + { title: 'RPO 目标', dataIndex: 'slaHoursRpo', render: (value: number) => `${value} 小时` }, + { title: '距上次成功', dataIndex: 'hoursSinceLastSuccess', render: (value: number, record: SLAComplianceReport['violations'][number]) => + record.neverSucceeded ? 从未成功 : `${value.toFixed(1)} 小时`, + }, + { title: '最近成功', dataIndex: 'lastSuccessAt', render: (value?: string) => formatDateTime(value) }, + ]} + data={sla.violations} + pagination={false} + stripe + /> + + )} + + ) : null} +
} diff --git a/web/src/pages/replication-records/ReplicationRecordsPage.tsx b/web/src/pages/replication-records/ReplicationRecordsPage.tsx new file mode 100644 index 0000000..f6565be --- /dev/null +++ b/web/src/pages/replication-records/ReplicationRecordsPage.tsx @@ -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([]) + 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 ( + +
+ 备份复制 + + 3-2-1 规则核心:每份备份至少存在于 2 个独立存储、1 份异地。启用后系统会在每次备份成功后自动镜像到副本目标。 + +
+ + + +
+ 状态筛选 +
( + + 任务 #{r.taskId} + {statusLabel(r.status)} + + )}, + { title: '源 → 目标', render: (_: unknown, r: ReplicationRecordSummary) => ( + + {r.sourceTargetName || `#${r.sourceTargetId}`} + ↓ {r.destTargetName || `#${r.destTargetId}`} + + )}, + { 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 || '-' }, + ]} + /> + )} + + + ) +} diff --git a/web/src/pages/restore-records/RestoreRecordsPage.tsx b/web/src/pages/restore-records/RestoreRecordsPage.tsx new file mode 100644 index 0000000..46b04dc --- /dev/null +++ b/web/src/pages/restore-records/RestoreRecordsPage.tsx @@ -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([]) + 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) => ( + + {record.taskName} + + {statusLabel(record.status)} + {record.nodeName ? ( + {record.nodeName} + ) : record.nodeId === 0 ? ( + 本机 Master + ) : null} + + + ), + }, + { + title: '源备份', + render: (_: unknown, record: RestoreRecordSummary) => ( + + {record.backupFileName || `#${record.backupRecordId}`} + 备份记录 ID: {record.backupRecordId} + + ), + }, + { + title: '开始 / 完成', + dataIndex: 'startedAt', + render: (_: unknown, record: RestoreRecordSummary) => ( + + {formatDateTime(record.startedAt)} + {formatDateTime(record.completedAt)} + + ), + }, + { + 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) => ( + + ), + }, + ] + + return ( + +
+ 恢复记录 + + 查看备份恢复的执行结果与实时日志。恢复会在任务绑定的节点上执行(本机 Master 或远程 Agent)。 + +
+ + + +
+ 状态筛选 +
} /> + )} + + + updateSearchParam('restoreId', undefined)} + /> + + ) +} diff --git a/web/src/pages/storage-targets/StorageTargetsPage.tsx b/web/src/pages/storage-targets/StorageTargetsPage.tsx index 636e340..c987f0a 100644 --- a/web/src/pages/storage-targets/StorageTargetsPage.tsx +++ b/web/src/pages/storage-targets/StorageTargetsPage.tsx @@ -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(null) const [error, setError] = useState('') + const [usageMap, setUsageMap] = useState>({}) 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 = {} + 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 ? ( 最近测试:{target.lastTestMessage} ) : 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 ( +
+ + 使用率 {percent}% + + {formatBytes(disk.used)} / {formatBytes(disk.total)} + + {rate >= 0.85 && 容量预警} + + +
+ ) + } + if (usage.totalSize > 0) { + return ( + + 已用备份:{formatBytes(usage.totalSize)}({usage.recordCount} 个记录) + + ) + } + return null + })()} 更新时间:{target.updatedAt} diff --git a/web/src/pages/task-templates/TaskTemplatesPage.tsx b/web/src/pages/task-templates/TaskTemplatesPage.tsx new file mode 100644 index 0000000..e45801e --- /dev/null +++ b/web/src/pages/task-templates/TaskTemplatesPage.tsx @@ -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): 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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [applyVisible, setApplyVisible] = useState(false) + const [applyTemplateId, setApplyTemplateId] = useState(null) + const [applyTemplateName, setApplyTemplateName] = useState('') + const [rows, setRows] = useState([newRow()]) + const [applyResult, setApplyResult] = useState(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 ( + +
+ 任务模板 + + 保存常用任务参数预设,一次性批量创建任务。适合大规模场景(100+ 主机)。在任务表单点击"保存为模板"可创建模板。 + +
+ + {error ? : null} + + + {items.length === 0 && !loading ? ( + + ) : ( +
( + + {r.name} + {r.description || '-'} + + )}, + { title: '类型', dataIndex: 'taskType', render: (v: string) => {v.toUpperCase()} }, + { title: '创建者', dataIndex: 'createdBy', render: (v: string) => v || '-' }, + { title: '创建时间', dataIndex: 'createdAt', render: (v: string) => formatDateTime(v) }, + { title: '操作', width: 240, render: (_: unknown, r: TaskTemplateSummary) => ( + + {writable && } + {writable && } + + )}, + ]} + /> + )} + + + setApplyVisible(false)} + onOk={applyResult ? () => setApplyVisible(false) : handleApply} + okText={applyResult ? '完成' : '批量创建'} + confirmLoading={applying} + style={{ width: 780 }} + unmountOnExit + > + {applyResult ? ( +
v ? 成功 : 失败 }, + { title: '任务 ID', dataIndex: 'taskId', render: (v?: number) => v ? `#${v}` : '-' }, + { title: '错误', dataIndex: 'error', render: (v?: string) => 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) => ( + setRows((list) => list.map((x, i) => i === idx ? { ...x, sourcePath: v } : x))} placeholder="/var/www" /> + )}, + { title: '数据库主机', width: 140, render: (_: unknown, r: VariableRow, idx: number) => ( + setRows((list) => list.map((x, i) => i === idx ? { ...x, dbHost: v } : x))} placeholder="host-1" /> + )}, + { title: '数据库名', width: 140, render: (_: unknown, r: VariableRow, idx: number) => ( + setRows((list) => list.map((x, i) => i === idx ? { ...x, dbName: v } : x))} /> + )}, + { title: '', width: 60, render: (_: unknown, _r: VariableRow, idx: number) => ( + + )}, + ]} + /> + + + )} + + + ) +} + +// 避免未使用变量警告 +void Form +void InputNumber +void Select diff --git a/web/src/pages/verification-records/VerificationRecordsPage.tsx b/web/src/pages/verification-records/VerificationRecordsPage.tsx new file mode 100644 index 0000000..33462f4 --- /dev/null +++ b/web/src/pages/verification-records/VerificationRecordsPage.tsx @@ -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([]) + 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) => ( + + {record.taskName} + + {statusLabel(record.status)} + {record.mode === 'deep' ? '深度' : '快速'} + + + ), + }, + { + title: '摘要 / 源备份', + render: (_: unknown, record: VerificationRecordSummary) => ( + + {record.summary || '-'} + + 源备份 #{record.backupRecordId}{record.backupFileName ? ` (${record.backupFileName})` : ''} + + + ), + }, + { + title: '开始 / 完成', + render: (_: unknown, record: VerificationRecordSummary) => ( + + {formatDateTime(record.startedAt)} + {formatDateTime(record.completedAt)} + + ), + }, + { + 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) => ( + + ), + }, + ] + + return ( + +
+ 验证演练 + + 自动化校验备份的可恢复性(企业合规刚需)。定时从最新成功备份执行完整性/格式校验,不改动任何源数据。 + +
+ + + +
+ 状态筛选 +
} /> + )} + + + updateSearchParam('verifyId', undefined)} + /> + + ) +} diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 60c6dda..6136f20 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/web/src/services/api-keys.ts b/web/src/services/api-keys.ts new file mode 100644 index 0000000..8e444dc --- /dev/null +++ b/web/src/services/api-keys.ts @@ -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>('/api-keys') + return unwrapApiEnvelope(response.data) +} + +export async function createApiKey(payload: ApiKeyCreateInput) { + const response = await http.post>('/api-keys', payload) + return unwrapApiEnvelope(response.data) +} + +export async function toggleApiKey(id: number, disabled: boolean) { + const response = await http.put>(`/api-keys/${id}/toggle`, { disabled }) + return unwrapApiEnvelope(response.data) +} + +export async function revokeApiKey(id: number) { + const response = await http.delete>(`/api-keys/${id}`) + return unwrapApiEnvelope(response.data) +} diff --git a/web/src/services/audit.ts b/web/src/services/audit.ts index 7825d08..a5de840 100644 --- a/web/src/services/audit.ts +++ b/web/src/services/audit.ts @@ -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) +} diff --git a/web/src/services/backup-records.ts b/web/src/services/backup-records.ts index 31a9bd0..b158564 100644 --- a/web/src/services/backup-records.ts +++ b/web/src/services/backup-records.ts @@ -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>(`/backup/records/${id}/restore`) + const response = await http.post>(`/backup/records/${id}/restore`) return unwrapApiEnvelope(response.data) } diff --git a/web/src/services/backup-tasks.ts b/web/src/services/backup-tasks.ts index 59aa0f6..3189016 100644 --- a/web/src/services/backup-tasks.ts +++ b/web/src/services/backup-tasks.ts @@ -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>(`/backup/tasks/${id}/run`) return unwrapApiEnvelope(response.data) } + +export async function listBackupTaskTags() { + const response = await http.get>('/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>('/backup/tasks/batch/toggle', { ids, enabled }) + return unwrapApiEnvelope(response.data) ?? [] +} + +export async function batchDeleteTasks(ids: number[]) { + const response = await http.post>('/backup/tasks/batch/delete', { ids }) + return unwrapApiEnvelope(response.data) ?? [] +} + +export async function batchRunTasks(ids: number[]) { + const response = await http.post>('/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 { + 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>('/backup/tasks/import', payload) + return unwrapApiEnvelope(response.data) ?? [] +} diff --git a/web/src/services/dashboard.ts b/web/src/services/dashboard.ts index 9e5bcc5..07044f7 100644 --- a/web/src/services/dashboard.ts +++ b/web/src/services/dashboard.ts @@ -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>('/dashboard/stats') @@ -10,3 +10,23 @@ export async function fetchDashboardTimeline(days = 30) { const response = await http.get>('/dashboard/timeline', { params: { days } }) return unwrapApiEnvelope(response.data) } + +export async function fetchDashboardSLA() { + const response = await http.get>('/dashboard/sla') + return unwrapApiEnvelope(response.data) +} + +export async function fetchDashboardCluster() { + const response = await http.get>('/dashboard/cluster') + return unwrapApiEnvelope(response.data) +} + +export async function fetchDashboardBreakdown(days = 30) { + const response = await http.get>('/dashboard/breakdown', { params: { days } }) + return unwrapApiEnvelope(response.data) +} + +export async function fetchDashboardNodePerformance(days = 30) { + const response = await http.get>('/dashboard/node-performance', { params: { days } }) + return unwrapApiEnvelope(response.data) ?? [] +} diff --git a/web/src/services/database.ts b/web/src/services/database.ts index 1f5d123..6389df5 100644 --- a/web/src/services/database.ts +++ b/web/src/services/database.ts @@ -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 { - const response = await http.post>('/database/discover', payload, { timeout: 10000 }) + const response = await http.post>('/database/discover', payload, { timeout: 20000 }) return unwrapApiEnvelope(response.data).databases ?? [] } diff --git a/web/src/services/replication-records.ts b/web/src/services/replication-records.ts new file mode 100644 index 0000000..e3d905d --- /dev/null +++ b/web/src/services/replication-records.ts @@ -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 = {} + 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>('/replication/records', { params: buildQuery(filter) }) + return unwrapApiEnvelope(response.data) +} + +export async function getReplicationRecord(id: number) { + const response = await http.get>(`/replication/records/${id}`) + return unwrapApiEnvelope(response.data) +} + +export async function startReplication(backupRecordId: number, destTargetId: number) { + const response = await http.post>(`/backup/records/${backupRecordId}/replicate`, { destTargetId }) + return unwrapApiEnvelope(response.data) +} diff --git a/web/src/services/restore-records.ts b/web/src/services/restore-records.ts new file mode 100644 index 0000000..ae79889 --- /dev/null +++ b/web/src/services/restore-records.ts @@ -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 = {} + 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>('/restore/records', { params: buildQuery(filter) }) + return unwrapApiEnvelope(response.data) +} + +export async function getRestoreRecord(id: number) { + const response = await http.get>(`/restore/records/${id}`) + return unwrapApiEnvelope(response.data) +} + +// startRestoreFromBackup 通过源备份记录启动恢复。返回新建的恢复记录详情。 +export async function startRestoreFromBackup(backupRecordId: number) { + const response = await http.post>(`/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() +} diff --git a/web/src/services/search.ts b/web/src/services/search.ts new file mode 100644 index 0000000..77489eb --- /dev/null +++ b/web/src/services/search.ts @@ -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 { + const response = await http.get>('/search', { params: { q: query } }) + return unwrapApiEnvelope(response.data) +} diff --git a/web/src/services/storage-targets.ts b/web/src/services/storage-targets.ts index 3ecf867..1d83d2a 100644 --- a/web/src/services/storage-targets.ts +++ b/web/src/services/storage-targets.ts @@ -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) { diff --git a/web/src/services/task-templates.ts b/web/src/services/task-templates.ts new file mode 100644 index 0000000..165be74 --- /dev/null +++ b/web/src/services/task-templates.ts @@ -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>('/task-templates') + return unwrapApiEnvelope(response.data) ?? [] +} + +export async function getTaskTemplate(id: number) { + const response = await http.get>(`/task-templates/${id}`) + return unwrapApiEnvelope(response.data) +} + +export async function createTaskTemplate(payload: TaskTemplateUpsertPayload) { + const response = await http.post>('/task-templates', payload) + return unwrapApiEnvelope(response.data) +} + +export async function updateTaskTemplate(id: number, payload: TaskTemplateUpsertPayload) { + const response = await http.put>(`/task-templates/${id}`, payload) + return unwrapApiEnvelope(response.data) +} + +export async function deleteTaskTemplate(id: number) { + const response = await http.delete>(`/task-templates/${id}`) + return unwrapApiEnvelope(response.data) +} + +export async function applyTaskTemplate(id: number, variables: TaskTemplateVariables[]) { + const response = await http.post>(`/task-templates/${id}/apply`, { variables }) + return unwrapApiEnvelope(response.data) +} diff --git a/web/src/services/users.ts b/web/src/services/users.ts new file mode 100644 index 0000000..5519c86 --- /dev/null +++ b/web/src/services/users.ts @@ -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>('/users') + return unwrapApiEnvelope(response.data) +} + +export async function createUser(payload: UserUpsertPayload) { + const response = await http.post>('/users', payload) + return unwrapApiEnvelope(response.data) +} + +export async function updateUser(id: number, payload: UserUpsertPayload) { + const response = await http.put>(`/users/${id}`, payload) + return unwrapApiEnvelope(response.data) +} + +export async function deleteUser(id: number) { + const response = await http.delete>(`/users/${id}`) + return unwrapApiEnvelope(response.data) +} diff --git a/web/src/services/verification-records.ts b/web/src/services/verification-records.ts new file mode 100644 index 0000000..918b60e --- /dev/null +++ b/web/src/services/verification-records.ts @@ -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 = {} + 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>('/verify/records', { params: buildQuery(filter) }) + return unwrapApiEnvelope(response.data) +} + +export async function getVerificationRecord(id: number) { + const response = await http.get>(`/verify/records/${id}`) + return unwrapApiEnvelope(response.data) +} + +// startVerifyByTask 使用任务的最新成功备份触发验证。 +export async function startVerifyByTask(taskId: number, mode: VerificationMode = 'quick') { + const response = await http.post>(`/backup/tasks/${taskId}/verify`, { mode }) + return unwrapApiEnvelope(response.data) +} + +// startVerifyByRecord 指定备份记录触发验证。 +export async function startVerifyByRecord(backupRecordId: number, mode: VerificationMode = 'quick') { + const response = await http.post>(`/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() +} diff --git a/web/src/stores/events.ts b/web/src/stores/events.ts new file mode 100644 index 0000000..3b9dd61 --- /dev/null +++ b/web/src/stores/events.ts @@ -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((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 }), +})) diff --git a/web/src/types/backup-tasks.ts b/web/src/types/backup-tasks.ts index fce3f65..cb3e74d 100644 --- a/web/src/types/backup-tasks.ts +++ b/web/src/types/backup-tasks.ts @@ -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 + verifyEnabled: boolean + verifyCronExpr: string + verifyMode: 'quick' | 'deep' + slaHoursRpo: number + alertOnConsecutiveFails: number + replicationTargetIds: number[] + maintenanceWindows: string + dependsOnTaskIds: number[] } export interface BackupTaskTogglePayload { diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index 03cef09..64e0769 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -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 +} diff --git a/web/src/types/restore-records.ts b/web/src/types/restore-records.ts new file mode 100644 index 0000000..2d76fa2 --- /dev/null +++ b/web/src/types/restore-records.ts @@ -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 +} diff --git a/web/src/types/storage-targets.ts b/web/src/types/storage-targets.ts index a0c26b8..5b1c1a6 100644 --- a/web/src/types/storage-targets.ts +++ b/web/src/types/storage-targets.ts @@ -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 + /** 软配额(字节),0 = 不限制 */ + quotaBytes?: number } export interface StorageConnectionTestResult { diff --git a/web/src/types/verification-records.ts b/web/src/types/verification-records.ts new file mode 100644 index 0000000..3775574 --- /dev/null +++ b/web/src/types/verification-records.ts @@ -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 +} diff --git a/web/src/utils/permissions.ts b/web/src/utils/permissions.ts new file mode 100644 index 0000000..ae731bf --- /dev/null +++ b/web/src/utils/permissions.ts @@ -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 ?? '-' + } +}