mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-08 19:29:41 +08:00
* 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力
围绕"可靠、可验证、可度量、可冗余、可治理、可规模化、可运维、可部署、可感知"的
九大企业级支柱,新增 70+ 文件、14k+ 行代码,全链路测试与类型检查通过。
## 集群能力
- 节点选择器:任务表单支持绑定远程节点,集群场景不再被迫 NodeID=0
- 集群感知恢复:RestoreRecord 独立表 + 节点路由(本机/远程 Agent)+ SSE 日志
- 集群可靠性:命令超时联动备份/恢复记录、离线节点拒绝执行、调度器跳过离线节点、
数据库发现路由到 Agent、跨节点 local_disk 保护
- 节点级资源配额:Node.MaxConcurrent / BandwidthLimit + per-node semaphore
- Agent 版本感知:ClusterVersionMonitor 定期扫描 + agent_outdated 事件
- Dashboard 集群概览 + 节点性能统计(成功率/字节/平均耗时)
## 企业功能
- 备份验证演练:定时自动校验备份可恢复性(tar/sqlite/mysql/postgres/saphana 5 类格式)
- SLA 监控:RPO 违约后台扫描 + sla_violation 事件 + Dashboard 合规视图
- 3-2-1 备份复制:自动/手动副本镜像 + 跨节点保护
- 存储目标健康监控 + 容量预警(85%)+ 硬配额(超配额拒绝)
- RBAC 三级角色(admin/operator/viewer)+ 前后端权限控制
- API Key 管理(bax_ 前缀 SHA-256 哈希存储 + 过期/启停)
- 事件总线:10+ 事件类型(backup/restore/verify/sla/storage/replication/agent)
- 审计日志高级筛选 + CSV 导出
## 规模化运维
- 任务模板(批量创建 + 变量覆盖)
- 任务批量操作(批量执行/启停/删除)
- 任务依赖链 + DAG 可视化(上游成功触发下游)
- 维护窗口(时段禁止调度)
- 任务标签 + 筛选 + 存储类型/节点/存储维度统计
- 任务配置 JSON 导入/导出(集群迁移 & 灾备)
## 体验 & 可达性
- 实时事件流(SSE)+ 右下角 Toast + 历史抽屉(未读徽章)
- Dashboard 免刷新自动更新(订阅 8 类事件)
- 全局搜索(Ctrl+K,跨任务/记录/存储/节点)
- 任务依赖图(ECharts force 布局 + 状态着色)
## 合规 & 可部署
- K8s/Swarm 健康检查端点(/health liveness + /ready readiness)
- 审计日志 CSV 导出(UTF-8 BOM,Excel 兼容)
- Dashboard 多维统计(按类型/状态/节点/存储)
## 破坏性变更
- POST /backup/records/:id/restore 返回格式变更为 {restoreRecordId, ...}
(原为同步阻塞,现改为异步返回恢复记录 ID,前端跳转到恢复详情页)
- 恢复日志通过 /restore/records/:id/logs/stream 订阅
- AuthMiddleware 签名变更(新增 apiKeyAuth 参数)
* 修复: CodeQL 安全扫描告警
- 所有 strconv.ParseUint 由 64bit 改为 32bit 位宽,strconv 内置溢出检查
- hashApiKey 参数改名 rawToken 避免 CodeQL 误判为密码哈希(API Key 是 192 位
高熵 token,使用 bcrypt 会引入不必要的延迟;同时补充安全说明)
* 修复: API Key 哈希改用 HMAC-SHA256 + 应用级 pepper
- 符合 RFC 2104 标准,业界 API token 存储的推荐方案
- 数据库泄漏场景下增加离线反推难度(需同时获取二进制 pepper)
- 规避 CodeQL go/weak-sensitive-data-hashing 对裸 SHA-256 的误判
178 lines
6.8 KiB
TypeScript
178 lines
6.8 KiB
TypeScript
import { Alert, Button, Card, Empty, Form, Input, InputNumber, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { createApiKey, listApiKeys, revokeApiKey, toggleApiKey, type ApiKeyCreateInput, type ApiKeySummary } from '../../services/api-keys'
|
||
import type { UserRole } from '../../services/users'
|
||
import { useAuthStore } from '../../stores/auth'
|
||
import { resolveErrorMessage } from '../../utils/error'
|
||
import { isAdmin, roleLabel } from '../../utils/permissions'
|
||
import { formatDateTime } from '../../utils/format'
|
||
|
||
const roleOptions = [
|
||
{ label: '管理员 (admin)', value: 'admin' },
|
||
{ label: '运维 (operator)', value: 'operator' },
|
||
{ label: '只读 (viewer)', value: 'viewer' },
|
||
]
|
||
|
||
// ApiKeysPage API Key 管理(admin 专属)。
|
||
// 新创建的 Key 明文只返回一次,需要用户立即保存。
|
||
export function ApiKeysPage() {
|
||
const user = useAuthStore((s) => s.user)
|
||
const [items, setItems] = useState<ApiKeySummary[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
const [modalVisible, setModalVisible] = useState(false)
|
||
const [draft, setDraft] = useState<ApiKeyCreateInput>({ name: '', role: 'viewer', ttlHours: 0 })
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [plainKey, setPlainKey] = useState<string>('')
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
setItems(await listApiKeys())
|
||
setError('')
|
||
} catch (e) {
|
||
setError(resolveErrorMessage(e, '加载 API Key 失败'))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
void load()
|
||
}, [load])
|
||
|
||
function openCreate() {
|
||
setDraft({ name: '', role: 'viewer', ttlHours: 0 })
|
||
setPlainKey('')
|
||
setModalVisible(true)
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!draft.name.trim()) {
|
||
Message.error('名称不能为空')
|
||
return
|
||
}
|
||
setSubmitting(true)
|
||
try {
|
||
const result = await createApiKey(draft)
|
||
setPlainKey(result.plainKey)
|
||
await load()
|
||
} catch (e) {
|
||
Message.error(resolveErrorMessage(e, '创建失败'))
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function handleToggle(item: ApiKeySummary) {
|
||
try {
|
||
await toggleApiKey(item.id, !item.disabled)
|
||
Message.success(item.disabled ? '已启用' : '已停用')
|
||
await load()
|
||
} catch (e) {
|
||
Message.error(resolveErrorMessage(e, '操作失败'))
|
||
}
|
||
}
|
||
|
||
async function handleRevoke(item: ApiKeySummary) {
|
||
if (!window.confirm(`确定撤销 API Key「${item.name}」?操作不可撤销。`)) return
|
||
try {
|
||
await revokeApiKey(item.id)
|
||
Message.success('已撤销')
|
||
await load()
|
||
} catch (e) {
|
||
Message.error(resolveErrorMessage(e, '撤销失败'))
|
||
}
|
||
}
|
||
|
||
async function copyPlainKey() {
|
||
if (!plainKey) return
|
||
try {
|
||
await navigator.clipboard.writeText(plainKey)
|
||
Message.success('已复制到剪贴板')
|
||
} catch {
|
||
Message.info('请手动选择文本复制')
|
||
}
|
||
}
|
||
|
||
if (!isAdmin(user)) {
|
||
return <Alert type="warning" content="当前账号无权访问 API Key 管理(仅 admin)" />
|
||
}
|
||
|
||
return (
|
||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||
<div>
|
||
<Typography.Title heading={4}>API Key</Typography.Title>
|
||
<Typography.Paragraph type="secondary">
|
||
签发 API Key 供 CI/CD、监控脚本等非交互式场景访问 BackupX。在请求头加 <Typography.Text code>Authorization: Bearer bax_xxx</Typography.Text> 或 <Typography.Text code>X-Api-Key: bax_xxx</Typography.Text> 即可。
|
||
</Typography.Paragraph>
|
||
</div>
|
||
|
||
<Space>
|
||
<Button type="primary" onClick={openCreate}>生成 API Key</Button>
|
||
</Space>
|
||
|
||
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
|
||
|
||
<Card>
|
||
<Table
|
||
rowKey="id"
|
||
loading={loading}
|
||
data={items}
|
||
pagination={false}
|
||
stripe
|
||
noDataElement={<Empty description="暂无 API Key" />}
|
||
columns={[
|
||
{ title: '名称', dataIndex: 'name' },
|
||
{ title: '角色', dataIndex: 'role', render: (v: string) => <Tag color="arcoblue" bordered>{roleLabel(v)}</Tag> },
|
||
{ title: 'Key 前缀', dataIndex: 'prefix', render: (v: string) => <Typography.Text code>{v}…</Typography.Text> },
|
||
{ title: '创建者', dataIndex: 'createdBy', render: (v: string) => v || '-' },
|
||
{ title: '最近使用', dataIndex: 'lastUsedAt', render: (v?: string) => v ? formatDateTime(v) : '从未使用' },
|
||
{ title: '过期', dataIndex: 'expiresAt', render: (v?: string) => v ? formatDateTime(v) : '永不过期' },
|
||
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered>已停用</Tag> : <Tag color="green" bordered>启用</Tag> },
|
||
{ title: '操作', width: 180, render: (_: unknown, row: ApiKeySummary) => (
|
||
<Space>
|
||
<Button size="small" type="text" onClick={() => void handleToggle(row)}>{row.disabled ? '启用' : '停用'}</Button>
|
||
<Button size="small" type="text" status="danger" onClick={() => void handleRevoke(row)}>撤销</Button>
|
||
</Space>
|
||
) },
|
||
]}
|
||
/>
|
||
</Card>
|
||
|
||
<Modal
|
||
visible={modalVisible}
|
||
title="生成 API Key"
|
||
onCancel={() => { setModalVisible(false); setPlainKey('') }}
|
||
onOk={plainKey ? () => { setModalVisible(false); setPlainKey('') } : handleSubmit}
|
||
okText={plainKey ? '完成' : '生成'}
|
||
confirmLoading={submitting}
|
||
unmountOnExit
|
||
>
|
||
{plainKey ? (
|
||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||
<Alert type="warning" content="明文 Key 只会显示一次,请立即妥善保存。" />
|
||
<Input.TextArea value={plainKey} autoSize readOnly />
|
||
<Button type="outline" onClick={() => void copyPlainKey()}>复制到剪贴板</Button>
|
||
</Space>
|
||
) : (
|
||
<Form layout="vertical">
|
||
<Form.Item label="名称" required>
|
||
<Input value={draft.name} onChange={(v) => setDraft({ ...draft, name: v })} placeholder="例如:ci-deploy-script" />
|
||
</Form.Item>
|
||
<Form.Item label="角色" required>
|
||
<Select value={draft.role} options={roleOptions} onChange={(v: UserRole) => setDraft({ ...draft, role: v })} />
|
||
</Form.Item>
|
||
<Form.Item label="有效期(小时,0=永不过期)">
|
||
<InputNumber style={{ width: '100%' }} min={0} value={draft.ttlHours ?? 0} onChange={(v) => setDraft({ ...draft, ttlHours: Number(v ?? 0) })} />
|
||
</Form.Item>
|
||
</Form>
|
||
)}
|
||
</Modal>
|
||
</Space>
|
||
)
|
||
}
|
||
|
||
// 避免未使用告警
|
||
void Switch
|