Files
BackupX/server/internal/service/node_pool_scheduler_test.go
Wu Qing eff48342c8 功能: 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 全绿
2026-04-21 14:05:48 +08:00

84 lines
2.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}