mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-12 02:20:36 +08:00
功能: v2.2 节点池调度 + Grafana Dashboard + 版本漂移 UI (#49)
节点池动态调度(企业集群核心需求): - model.Node 新增 Labels CSV;Node.HasLabel / LabelSet 辅助方法 - model.BackupTask 新增 NodePoolTag;与 NodeID 互斥(校验层拒绝同时设置) - BackupExecutionService.selectPoolNode:匹配标签的在线节点中选"运行中任务最少" 并列按 ID 升序稳定;空池返回 NODE_POOL_EMPTY 让用户立即感知 - 选中节点仅写 BackupRecord,不回写 task.NodeID —— 每次执行重选实现真轮转均衡 Grafana Dashboard(v2.1 指标的可视化闭环): - deploy/grafana/backupx-dashboard.json:11 个面板覆盖概览/时序/容量/集群 - deploy/grafana/README.md:Prometheus 抓取配置 + 告警建议 - release workflow 打包 grafana/ + nginx.conf 到 tar.gz 前端: - 节点列表:Agent 版本 vs Master 不一致时橙红 Tag + Tooltip 提示升级 - 节点列表新增"标签/节点池"列,支持 CSV 编辑 + 并发/带宽一起改 - 任务表单新增 NodePoolTag 输入框,与节点选择器互斥禁用 测试: - model/node_label_test.go:HasLabel / LabelSet / nil 安全 - service/node_pool_scheduler_test.go:负载最低优先 / 空池错误 / nil repo 降级 - go test ./... + npm run build 全绿
This commit is contained in:
@@ -59,6 +59,7 @@ function createEmptyDraft(storageTargets?: StorageTargetSummary[]): BackupTaskPa
|
||||
storageTargetId: defaultIds[0] ?? 0,
|
||||
storageTargetIds: defaultIds,
|
||||
nodeId: 0,
|
||||
nodePoolTag: '',
|
||||
tags: '',
|
||||
retentionDays: 30,
|
||||
compression: 'gzip',
|
||||
@@ -127,6 +128,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
storageTargetId: editTargetIds[0] ?? 0,
|
||||
storageTargetIds: editTargetIds,
|
||||
nodeId: (initialValue as any).nodeId ?? 0,
|
||||
nodePoolTag: (initialValue as any).nodePoolTag ?? '',
|
||||
tags: initialValue.tags ?? '',
|
||||
retentionDays: initialValue.retentionDays,
|
||||
compression: initialValue.compression,
|
||||
@@ -297,12 +299,28 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
<Select
|
||||
value={draft.nodeId ?? 0}
|
||||
options={nodeOptions}
|
||||
onChange={(value) => updateDraft({ nodeId: Number(value ?? 0) })}
|
||||
onChange={(value) => {
|
||||
const nodeId = Number(value ?? 0)
|
||||
// 固定节点与节点池互斥:切到固定节点时清空 NodePoolTag
|
||||
updateDraft(nodeId > 0 ? { nodeId, nodePoolTag: '' } : { nodeId })
|
||||
}}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
任务在所选节点上执行备份与恢复;源路径/数据库以该节点视角解析。远程节点需先在"节点管理"中安装 Agent。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>节点池标签(可选)</Typography.Text>
|
||||
<Input
|
||||
placeholder="填写标签后从节点池动态调度(与固定节点互斥)"
|
||||
value={draft.nodePoolTag ?? ''}
|
||||
disabled={(draft.nodeId ?? 0) > 0}
|
||||
onChange={(value) => updateDraft({ nodePoolTag: value })}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
执行节点选"本机 / 未指定"时可启用;从节点 Labels 命中此 tag 的在线节点中按当前运行任务数最少的挑选一台执行。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>Cron 表达式</Typography.Text>
|
||||
<CronInput value={draft.cronExpr} onChange={(value) => updateDraft({ cronExpr: value })} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Table, Button, Space, Tag, Typography, PageHeader, Modal, Input, Message, Badge, Popconfirm, Card,
|
||||
Empty, Dropdown, Menu,
|
||||
Empty, Dropdown, Menu, Tooltip, InputNumber,
|
||||
} from '@arco-design/web-react'
|
||||
import {
|
||||
IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit, IconMore,
|
||||
@@ -25,6 +25,9 @@ export default function NodesPage() {
|
||||
const [editVisible, setEditVisible] = useState(false)
|
||||
const [editNode, setEditNode] = useState<NodeSummary | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editLabels, setEditLabels] = useState('')
|
||||
const [editMaxConcurrent, setEditMaxConcurrent] = useState<number>(0)
|
||||
const [editBandwidthLimit, setEditBandwidthLimit] = useState('')
|
||||
|
||||
const fetchNodes = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -63,7 +66,12 @@ export default function NodesPage() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateNode(editNode.id, { name: editName.trim() })
|
||||
await updateNode(editNode.id, {
|
||||
name: editName.trim(),
|
||||
labels: editLabels.trim(),
|
||||
maxConcurrent: editMaxConcurrent,
|
||||
bandwidthLimit: editBandwidthLimit.trim(),
|
||||
})
|
||||
Message.success('节点更新成功')
|
||||
setEditVisible(false)
|
||||
fetchNodes()
|
||||
@@ -117,7 +125,18 @@ export default function NodesPage() {
|
||||
render: (_: string, record: NodeSummary) => record.os
|
||||
? <Tag bordered>{record.os}/{record.arch}</Tag> : '-',
|
||||
},
|
||||
{ title: 'Agent 版本', dataIndex: 'agentVersion', width: 100, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: 'Agent 版本', dataIndex: 'agentVersion', width: 140,
|
||||
render: (v: string) => renderAgentVersion(v, masterVersion),
|
||||
},
|
||||
{
|
||||
title: '标签 / 节点池', dataIndex: 'labels', width: 180,
|
||||
render: (v: string) => {
|
||||
const tags = (v || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
if (tags.length === 0) return <Text type="secondary">-</Text>
|
||||
return <Space wrap size={4}>{tags.map(tag => <Tag key={tag} color="arcoblue">{tag}</Tag>)}</Space>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '最后活跃', dataIndex: 'lastSeen', width: 170,
|
||||
render: (v: string) => v ? new Date(v).toLocaleString('zh-CN') : '-',
|
||||
@@ -127,7 +146,13 @@ export default function NodesPage() {
|
||||
render: (_: unknown, record: NodeSummary) => (
|
||||
<Space>
|
||||
<Button type="text" icon={<IconEdit />} size="small"
|
||||
onClick={() => { setEditNode(record); setEditName(record.name); setEditVisible(true) }} />
|
||||
onClick={() => {
|
||||
setEditNode(record); setEditName(record.name)
|
||||
setEditLabels(record.labels || '')
|
||||
setEditMaxConcurrent(record.maxConcurrent || 0)
|
||||
setEditBandwidthLimit(record.bandwidthLimit || '')
|
||||
setEditVisible(true)
|
||||
}} />
|
||||
{!record.isLocal && (
|
||||
<>
|
||||
<Dropdown trigger="click" droplist={(
|
||||
@@ -181,12 +206,46 @@ export default function NodesPage() {
|
||||
|
||||
<Modal title="编辑节点" visible={editVisible}
|
||||
onCancel={() => setEditVisible(false)} onOk={handleEdit}
|
||||
okText="保存" cancelText="取消">
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text type="secondary">节点名称</Text>
|
||||
</div>
|
||||
okText="保存" cancelText="取消" style={{ width: 520 }}>
|
||||
<div style={{ marginBottom: 8 }}><Text type="secondary">节点名称</Text></div>
|
||||
<Input placeholder="输入节点名称" value={editName} onChange={setEditName} />
|
||||
|
||||
<div style={{ margin: '16px 0 8px 0' }}>
|
||||
<Text type="secondary">标签 / 节点池</Text>
|
||||
<Tooltip content="以英文逗号分隔,如 prod,db,high-mem。任务配置节点池标签时会从命中的在线节点中按负载最低选一台执行。">
|
||||
<Text type="secondary" style={{ marginLeft: 8, cursor: 'help' }}>ⓘ</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input placeholder="例如:prod,db,high-mem" value={editLabels} onChange={setEditLabels} />
|
||||
|
||||
<div style={{ margin: '16px 0 8px 0' }}><Text type="secondary">最大并发任务数(0 = 不限)</Text></div>
|
||||
<InputNumber min={0} max={64} value={editMaxConcurrent} onChange={v => setEditMaxConcurrent(v ?? 0)} style={{ width: '100%' }} />
|
||||
|
||||
<div style={{ margin: '16px 0 8px 0' }}>
|
||||
<Text type="secondary">带宽限速</Text>
|
||||
<Tooltip content="rclone 格式,如 10M 表示 10MB/s,留空走全局默认">
|
||||
<Text type="secondary" style={{ marginLeft: 8, cursor: 'help' }}>ⓘ</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input placeholder="例如:10M 或 1G;留空使用全局默认" value={editBandwidthLimit} onChange={setEditBandwidthLimit} />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Agent 版本 + 与 Master 的漂移状态。
|
||||
* 空版本 → "-"(未上报)
|
||||
* 与 Master 相同 → 原样显示
|
||||
* 不同(且非本机) → 红色 Tag + 提示升级
|
||||
*/
|
||||
function renderAgentVersion(agentVer: string, masterVer: string | null): React.ReactNode {
|
||||
if (!agentVer) return <Text type="secondary">-</Text>
|
||||
if (!masterVer) return agentVer
|
||||
if (agentVer === masterVer) return agentVer
|
||||
return (
|
||||
<Tooltip content={`Master 版本 ${masterVer},建议重新生成安装命令升级 Agent`}>
|
||||
<Tag color="orangered" style={{ cursor: 'help' }}>{agentVer} ≠ {masterVer}</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,14 @@ export async function createNode(name: string) {
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function updateNode(id: number, data: { name: string }) {
|
||||
export interface NodeUpdateInput {
|
||||
name: string
|
||||
labels?: string
|
||||
maxConcurrent?: number
|
||||
bandwidthLimit?: string
|
||||
}
|
||||
|
||||
export async function updateNode(id: number, data: NodeUpdateInput) {
|
||||
const response = await http.put<ApiEnvelope<NodeSummary>>(`/nodes/${id}`, data)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface BackupTaskSummary {
|
||||
storageTargetNames: string[]
|
||||
nodeId: number
|
||||
nodeName?: string
|
||||
/** 节点池标签(summary):当任务绑定节点池而非固定节点时显示 */
|
||||
nodePoolTag?: string
|
||||
tags: string
|
||||
retentionDays: number
|
||||
compression: BackupCompression
|
||||
@@ -64,6 +66,8 @@ export interface BackupTaskPayload {
|
||||
storageTargetId: number
|
||||
storageTargetIds: number[]
|
||||
nodeId: number
|
||||
/** 节点池标签(创建/更新)。与 nodeId 互斥:nodeId=0 且本字段非空时触发动态调度。 */
|
||||
nodePoolTag?: string
|
||||
tags: string
|
||||
retentionDays: number
|
||||
compression: BackupCompression
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface NodeSummary {
|
||||
arch: string
|
||||
agentVersion: string
|
||||
lastSeen: string
|
||||
maxConcurrent?: number
|
||||
bandwidthLimit?: string
|
||||
/** CSV 节点标签;任务的 NodePoolTag 命中这里任一即会被调度到本节点 */
|
||||
labels?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user