功能: 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

@@ -1,6 +1,9 @@
package model
import "time"
import (
"strings"
"time"
)
const (
NodeStatusOnline = "online"
@@ -29,8 +32,42 @@ type Node struct {
// BandwidthLimit 该节点上传带宽上限rclone 可识别格式10M / 1G / 0=不限)。
// 对集群感知的上传场景有效Master 本地与 Agent 运行时均会应用)。
BandwidthLimit string `gorm:"column:bandwidth_limit;size:32" json:"bandwidthLimit"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Labels 节点标签CSV如 "prod,db-host,high-mem")。
// 用于任务调度的节点池选择:任务配置 NodePoolTag 时,调度器会从 Labels 包含该 tag 的
// 在线节点中自动挑选一台执行(按当前运行中任务数升序)。单节点可属多个池。
Labels string `gorm:"column:labels;size:500" json:"labels"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// LabelSet 把 CSV Labels 解析为 set便于做成员判定。
// 空白与空 token 自动忽略。
func (n *Node) LabelSet() map[string]struct{} {
if n == nil {
return nil
}
out := make(map[string]struct{})
for _, raw := range strings.Split(n.Labels, ",") {
label := strings.TrimSpace(raw)
if label != "" {
out[label] = struct{}{}
}
}
return out
}
// HasLabel 判断节点是否属于指定池。nil/空 tag 返回 false。
func (n *Node) HasLabel(tag string) bool {
tag = strings.TrimSpace(tag)
if n == nil || tag == "" {
return false
}
for _, raw := range strings.Split(n.Labels, ",") {
if strings.TrimSpace(raw) == tag {
return true
}
}
return false
}
func (Node) TableName() string {