功能: 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:
Wu Qing
2026-04-21 14:05:48 +08:00
committed by GitHub
parent e2baa6bd17
commit eff48342c8
16 changed files with 701 additions and 15 deletions

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -9,6 +9,10 @@ export interface NodeSummary {
arch: string
agentVersion: string
lastSeen: string
maxConcurrent?: number
bandwidthLimit?: string
/** CSV 节点标签;任务的 NodePoolTag 命中这里任一即会被调度到本节点 */
labels?: string
createdAt: string
}