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

@@ -0,0 +1,83 @@
package service
import (
"context"
"errors"
"testing"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
)
// nodeRepoStub 返回预设节点切片;仅关注 List/FindByID。
// 其余方法返回零值,避免在调度路径被调用到。
type nodeRepoStub struct {
nodes []model.Node
}
func (s *nodeRepoStub) List(context.Context) ([]model.Node, error) { return s.nodes, nil }
func (s *nodeRepoStub) FindByID(_ context.Context, id uint) (*model.Node, error) {
for i := range s.nodes {
if s.nodes[i].ID == id {
return &s.nodes[i], nil
}
}
return nil, nil
}
func (s *nodeRepoStub) FindByToken(context.Context, string) (*model.Node, error) { return nil, nil }
func (s *nodeRepoStub) FindLocal(context.Context) (*model.Node, error) { return nil, nil }
func (s *nodeRepoStub) Create(context.Context, *model.Node) error { return nil }
func (s *nodeRepoStub) BatchCreate(context.Context, []*model.Node) error { return nil }
func (s *nodeRepoStub) Update(context.Context, *model.Node) error { return nil }
func (s *nodeRepoStub) Delete(context.Context, uint) error { return nil }
func (s *nodeRepoStub) MarkStaleOffline(context.Context, time.Time) (int64, error) {
return 0, nil
}
func TestSelectPoolNode_PicksLeastLoaded(t *testing.T) {
nodes := []model.Node{
{ID: 1, Name: "node-a", Status: model.NodeStatusOnline, Labels: "prod,db"},
{ID: 2, Name: "node-b", Status: model.NodeStatusOnline, Labels: "prod,db"},
{ID: 3, Name: "node-offline", Status: model.NodeStatusOffline, Labels: "prod,db"},
{ID: 4, Name: "node-other-pool", Status: model.NodeStatusOnline, Labels: "staging"},
}
svc := &BackupExecutionService{
nodeRepo: &nodeRepoStub{nodes: nodes},
records: nil, // 触发 countRunningOnNode 返回 0节点并列时按 ID 升序
}
chosen, err := svc.selectPoolNode(context.Background(), "db")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if chosen == nil || chosen.ID != 1 {
t.Fatalf("expected node-a (ID=1), got %#v", chosen)
}
}
func TestSelectPoolNode_EmptyPoolReturnsError(t *testing.T) {
svc := &BackupExecutionService{
nodeRepo: &nodeRepoStub{nodes: []model.Node{
{ID: 1, Status: model.NodeStatusOnline, Labels: "prod"},
}},
}
_, err := svc.selectPoolNode(context.Background(), "missing-pool")
if err == nil {
t.Fatal("expected empty-pool error")
}
var apperr *apperror.AppError
if !errors.As(err, &apperr) || apperr.Code != "NODE_POOL_EMPTY" {
t.Errorf("expected NODE_POOL_EMPTY, got %v", err)
}
}
func TestSelectPoolNode_NilRepoDegradesGracefully(t *testing.T) {
svc := &BackupExecutionService{}
got, err := svc.selectPoolNode(context.Background(), "any")
if err != nil {
t.Errorf("nil repo should degrade silently, got err %v", err)
}
if got != nil {
t.Errorf("nil repo should return nil node, got %v", got)
}
}