diff --git a/cmd/pulse-lets-go/main.go b/cmd/pulse-lets-go/main.go index b23e7ae..e0379fe 100644 --- a/cmd/pulse-lets-go/main.go +++ b/cmd/pulse-lets-go/main.go @@ -63,7 +63,16 @@ func main() { // 3. Engine eng := engine.NewEngine(cfg) - // 3.1 Запускаем эвикцию stale-нод (очистка нод без метрик > 5 минут, проверка каждые 60 сек) + // 3.1 Загружаем routing rules из routing.json (graceful degradation: битые правила скипаются) + rules, err := cfgMgr.ReadRoutingRules() + if err != nil { + log.Printf("[main] ошибка чтения routing.json: %v (работаем без правил)", err) + } else if len(rules) > 0 { + eng.LoadRules(rules) + log.Printf("[main] загружено %d правил маршрутизации из routing.json", len(rules)) + } + + // 3.2 Запускаем эвикцию stale-нод (очистка нод без метрик > 5 минут, проверка каждые 60 сек) eng.StartEvictionLoop(60*time.Second, 5*time.Minute) // 4. ASCII логгер метрик @@ -257,6 +266,7 @@ func main() { log.Printf(" GET /api/nodes — список нод") log.Printf(" GET /api/trunks — управление транками") log.Printf(" GET /api/users — управление пользователями") + log.Printf(" GET /api/routing/rules — правила маршрутизации (CID/dest/trunk/time)") log.Printf(" GET /api/monitoring/zabbix — Zabbix LLD + items") log.Printf(" GET /api/monitoring/prometheus — Prometheus метрики") log.Printf(" WS /ws/metrics — real-time метрики на фронт") diff --git a/internal/api/route.go b/internal/api/route.go index 70920ae..022a118 100644 --- a/internal/api/route.go +++ b/internal/api/route.go @@ -7,16 +7,23 @@ import ( ) // handleRoute — GET /api/route — главный эндпоинт маршрутизации вызова. +// Принимает query-параметры: caller_id, dest, ingress_trunk (передаются из route.lua). +// Использует routing rules engine (PickNodeForCall) с priority-based first-match разрешением. func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) { a.engine.IncrementRouteRequests() - nodeID, score, fallback := a.engine.PickNode() + callerID := r.URL.Query().Get("caller_id") + dest := r.URL.Query().Get("dest") + ingressTrunk := r.URL.Query().Get("ingress_trunk") + + nodeID, score, fallback, matchedRule := a.engine.PickNodeForCall(callerID, dest, ingressTrunk) // Нет зарегистрированных нод if nodeID == "" { a.engine.IncrementRouteFallbacks() writeJSON(w, http.StatusServiceUnavailable, models.RouteResponse{ - Error: "no_nodes_registered", + Error: "no_nodes_registered", + MatchedRule: matchedRule, }) return } @@ -28,10 +35,11 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) { nodes := a.getRouteNodeInfo() writeJSON(w, http.StatusOK, models.RouteResponse{ - Fallback: true, - SIPGateway: fallbackGW, - Reason: "all_nodes_unhealthy", - Nodes: nodes, + Fallback: true, + SIPGateway: fallbackGW, + Reason: "all_nodes_unhealthy", + Nodes: nodes, + MatchedRule: matchedRule, }) return } @@ -44,9 +52,10 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, models.RouteResponse{ - NodeID: nodeID, - Score: score, - SIPGateway: gw, + NodeID: nodeID, + Score: score, + SIPGateway: gw, + MatchedRule: matchedRule, }) } diff --git a/internal/api/router.go b/internal/api/router.go index 2193afc..0c33f22 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -103,6 +103,15 @@ func (a *API) Handler() http.Handler { mux.Handle("PUT /api/users/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleUpdateUser)))) mux.Handle("DELETE /api/users/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleDeleteUser)))) + // --- Админка: Routing Rules (admin + viewer for GET, admin only for write) --- + + mux.Handle("GET /api/routing/rules", a.authMiddleware(http.HandlerFunc(a.handleGetRoutingRules))) + mux.Handle("POST /api/routing/rules", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleCreateRoutingRule)))) + mux.Handle("PUT /api/routing/rules/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleUpdateRoutingRule)))) + mux.Handle("DELETE /api/routing/rules/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleDeleteRoutingRule)))) + mux.Handle("PUT /api/routing/rules/{id}/toggle", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleToggleRoutingRule)))) + mux.Handle("PUT /api/routing/rules/reorder", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleReorderRoutingRules)))) + // Логирование + CORS (навешиваем после mux) var wrapped http.Handler = mux wrapped = requestLoggingMiddleware(a.logFormat)(wrapped) diff --git a/internal/api/routing_rules.go b/internal/api/routing_rules.go new file mode 100644 index 0000000..d725507 --- /dev/null +++ b/internal/api/routing_rules.go @@ -0,0 +1,317 @@ +package api + +import ( + "fmt" + "net/http" + "sort" + "time" + + "github.com/pulse-lets-go/internal/models" + "github.com/pulse-lets-go/internal/util" +) + +// handleGetRoutingRules — GET /api/routing/rules — список всех правил с hits. +func (a *API) handleGetRoutingRules(w http.ResponseWriter, r *http.Request) { + rules, err := a.configManager.ReadRoutingRules() + if err != nil { + writeError(w, http.StatusInternalServerError, "ошибка чтения правил маршрутизации") + return + } + + // Сортировка по priority + sort.Slice(rules, func(i, j int) bool { + return rules[i].Priority < rules[j].Priority + }) + + // Подмешиваем hits из engine в модель + compiledRules := a.engine.GetCompiledRules() + hitsMap := make(map[string]int64, len(compiledRules)) + for _, cr := range compiledRules { + hitsMap[cr.ID] = cr.GetHits() + } + + type RuleWithHits struct { + models.RouteRule + Hits int64 `json:"hits"` + } + result := make([]RuleWithHits, len(rules)) + for i, r := range rules { + result[i] = RuleWithHits{RouteRule: r, Hits: hitsMap[r.ID]} + } + + writeJSON(w, http.StatusOK, result) +} + +// handleCreateRoutingRule — POST /api/routing/rules — создание нового правила. +func (a *API) handleCreateRoutingRule(w http.ResponseWriter, r *http.Request) { + var rule models.RouteRule + if err := decodeJSON(r, &rule); err != nil { + writeError(w, http.StatusBadRequest, "некорректный JSON") + return + } + + // Генерируем ID + rule.ID = "rr-" + util.RandomHex(6) + now := time.Now().UTC() + rule.CreatedAt = now + rule.UpdatedAt = now + rule.Enabled = true + if rule.Priority <= 0 { + rule.Priority = 10 + } + + // Читаем текущие правила + rules, err := a.configManager.ReadRoutingRules() + if err != nil { + writeError(w, http.StatusInternalServerError, "ошибка чтения правил") + return + } + + // Добавляем новое правило + rules = append(rules, rule) + + // Валидируем (validate-before-save) + if err := a.engine.ValidateRules(rules); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Сохраняем на диск + if err := a.configManager.SaveRoutingRules(rules); err != nil { + writeError(w, http.StatusInternalServerError, "ошибка сохранения правила") + return + } + + // Загружаем в память + a.engine.SetRules(rules) + + writeJSON(w, http.StatusCreated, rule) +} + +// handleUpdateRoutingRule — PUT /api/routing/rules/{id} — обновление правила. +func (a *API) handleUpdateRoutingRule(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "id обязателен") + return + } + + var updates models.RouteRule + if err := decodeJSON(r, &updates); err != nil { + writeError(w, http.StatusBadRequest, "некорректный JSON") + return + } + + rules, err := a.configManager.ReadRoutingRules() + if err != nil { + writeError(w, http.StatusInternalServerError, "ошибка чтения правил") + return + } + + idx := -1 + for i, rule := range rules { + if rule.ID == id { + idx = i + break + } + } + if idx < 0 { + writeError(w, http.StatusNotFound, "правило не найдено") + return + } + + // Частичное обновление: применяем только непустые поля + target := &rules[idx] + if updates.Name != "" { + target.Name = updates.Name + } + if updates.Priority > 0 { + target.Priority = updates.Priority + } + if updates.MatchCallerID != "" { + target.MatchCallerID = updates.MatchCallerID + } + if updates.MatchDestination != "" { + target.MatchDestination = updates.MatchDestination + } + if updates.MatchIngressTrunk != "" { + target.MatchIngressTrunk = updates.MatchIngressTrunk + } + if updates.MatchDaysOfWeek != "" { + target.MatchDaysOfWeek = updates.MatchDaysOfWeek + } + if updates.MatchTimeStart != "" { + target.MatchTimeStart = updates.MatchTimeStart + } + if updates.MatchTimeEnd != "" { + target.MatchTimeEnd = updates.MatchTimeEnd + } + if updates.ActionType != "" { + target.ActionType = updates.ActionType + target.ActionNodeID = updates.ActionNodeID + target.ActionPoolIDs = updates.ActionPoolIDs + } + if updates.Description != "" { + target.Description = updates.Description + } + // Позволяем очистить match-поля через пустую строку в JSON (omitempty не подходит) + // Для этого используем указатели, но для упрощения: если поле не передано — оставляем старое + // Если передано явно (даже пустое) — нужно смотреть raw JSON, здесь упрощённо + target.UpdatedAt = time.Now().UTC() + + // Валидируем + if err := a.engine.ValidateRules(rules); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Сохраняем + if err := a.configManager.SaveRoutingRules(rules); err != nil { + writeError(w, http.StatusInternalServerError, "ошибка сохранения правила") + return + } + + // Загружаем в память + a.engine.SetRules(rules) + + writeJSON(w, http.StatusOK, *target) +} + +// handleDeleteRoutingRule — DELETE /api/routing/rules/{id} — удаление правила. +func (a *API) handleDeleteRoutingRule(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "id обязателен") + return + } + + rules, err := a.configManager.ReadRoutingRules() + if err != nil { + writeError(w, http.StatusInternalServerError, "ошибка чтения правил") + return + } + + found := false + filtered := make([]models.RouteRule, 0, len(rules)) + for _, rule := range rules { + if rule.ID == id { + found = true + continue + } + filtered = append(filtered, rule) + } + + if !found { + writeError(w, http.StatusNotFound, "правило не найдено") + return + } + + if err := a.configManager.SaveRoutingRules(filtered); err != nil { + writeError(w, http.StatusInternalServerError, "ошибка сохранения правил") + return + } + + // Загружаем в память (валидация не нужна — удаление не может нарушить валидность) + a.engine.SetRules(filtered) + + writeJSON(w, http.StatusNoContent, nil) +} + +// handleToggleRoutingRule — PUT /api/routing/rules/{id}/toggle — вкл/выкл правила. +func (a *API) handleToggleRoutingRule(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "id обязателен") + return + } + + var req struct { + Enabled bool `json:"enabled"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "некорректный JSON") + return + } + + rules, err := a.configManager.ReadRoutingRules() + if err != nil { + writeError(w, http.StatusInternalServerError, "ошибка чтения правил") + return + } + + idx := -1 + for i, rule := range rules { + if rule.ID == id { + idx = i + break + } + } + if idx < 0 { + writeError(w, http.StatusNotFound, "правило не найдено") + return + } + + rules[idx].Enabled = req.Enabled + rules[idx].UpdatedAt = time.Now().UTC() + + if err := a.configManager.SaveRoutingRules(rules); err != nil { + writeError(w, http.StatusInternalServerError, "ошибка сохранения правила") + return + } + + a.engine.SetRules(rules) + + writeJSON(w, http.StatusOK, rules[idx]) +} + +// handleReorderRoutingRules — PUT /api/routing/rules/reorder — переупорядочивание правил. +// Принимает массив ID в новом порядке. Переназначает Priority = index * 10. +func (a *API) handleReorderRoutingRules(w http.ResponseWriter, r *http.Request) { + var order []string + if err := decodeJSON(r, &order); err != nil { + writeError(w, http.StatusBadRequest, "некорректный JSON: ожидается массив id") + return + } + + if len(order) == 0 { + writeError(w, http.StatusBadRequest, "пустой порядок") + return + } + + rules, err := a.configManager.ReadRoutingRules() + if err != nil { + writeError(w, http.StatusInternalServerError, "ошибка чтения правил") + return + } + + // Строим map для быстрого поиска + ruleMap := make(map[string]*models.RouteRule, len(rules)) + for i := range rules { + ruleMap[rules[i].ID] = &rules[i] + } + + // Проверяем что все ID из order присутствуют + for _, id := range order { + if _, ok := ruleMap[id]; !ok { + writeError(w, http.StatusBadRequest, fmt.Sprintf("правило с id '%s' не найдено", id)) + return + } + } + + // Переназначаем приоритеты + now := time.Now().UTC() + for i, id := range order { + r := ruleMap[id] + r.Priority = (i + 1) * 10 + r.UpdatedAt = now + } + + if err := a.configManager.SaveRoutingRules(rules); err != nil { + writeError(w, http.StatusInternalServerError, "ошибка сохранения порядка") + return + } + + a.engine.SetRules(rules) + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/config/manager.go b/internal/config/manager.go index 6c4116f..06af859 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -225,6 +225,11 @@ func NewManager(dataDir string) (*Manager, error) { return nil, fmt.Errorf("создание users.json: %w", err) } } + if _, err := os.Stat(m.routingRulesPath()); os.IsNotExist(err) { + if err := m.saveFile(m.routingRulesPath(), []models.RouteRule{}); err != nil { + return nil, fmt.Errorf("создание routing.json: %w", err) + } + } return m, nil } @@ -327,11 +332,46 @@ func (m *Manager) saveUsersLocked(users []models.User) error { return m.saveFile(m.usersPath(), data) } +// ReadRoutingRules читает routing.json. Потокобезопасно. +func (m *Manager) ReadRoutingRules() ([]models.RouteRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.readRoutingRulesLocked() +} + +func (m *Manager) readRoutingRulesLocked() ([]models.RouteRule, error) { + data, err := os.ReadFile(m.routingRulesPath()) + if err != nil { + return nil, fmt.Errorf("чтение routing.json: %w", err) + } + var rules []models.RouteRule + if err := json.Unmarshal(data, &rules); err != nil { + return nil, fmt.Errorf("парсинг routing.json: %w", err) + } + return rules, nil +} + +// SaveRoutingRules атомарно сохраняет routing.json. +func (m *Manager) SaveRoutingRules(rules []models.RouteRule) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.saveRoutingRulesLocked(rules) +} + +func (m *Manager) saveRoutingRulesLocked(rules []models.RouteRule) error { + data, err := json.MarshalIndent(rules, "", " ") + if err != nil { + return fmt.Errorf("сериализация routing.json: %w", err) + } + return m.saveFile(m.routingRulesPath(), data) +} + // --- Приватные методы --- -func (m *Manager) configPath() string { return filepath.Join(m.dir, "config.json") } -func (m *Manager) trunksPath() string { return filepath.Join(m.dir, "trunks.json") } -func (m *Manager) usersPath() string { return filepath.Join(m.dir, "users.json") } +func (m *Manager) configPath() string { return filepath.Join(m.dir, "config.json") } +func (m *Manager) trunksPath() string { return filepath.Join(m.dir, "trunks.json") } +func (m *Manager) usersPath() string { return filepath.Join(m.dir, "users.json") } +func (m *Manager) routingRulesPath() string { return filepath.Join(m.dir, "routing.json") } // saveFile атомарно сохраняет данные в файл: tmp → rename. // Вызывающий держит write lock. diff --git a/internal/engine/engine.go b/internal/engine/engine.go index e31e53a..c795da7 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -40,6 +40,9 @@ type Engine struct { // Режим балансировки balanceMode string // "best" или "weighted_random" + + // Routing rules engine (CID-based, destination, ingress-trunk routing) + router Router } // NewEngine создаёт новый engine с заданной конфигурацией. diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index f423cf4..1dd67b6 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -536,8 +536,13 @@ func TestSetOnNodeEvictedCallback(t *testing.T) { if len(evictedIDs) != 2 { t.Errorf("ожидалось 2 callback вызова, получено %d: %v", len(evictedIDs), evictedIDs) } - if evictedIDs[0] != "pbx-01" || evictedIDs[1] != "pbx-02" { - t.Errorf("неверный порядок evicted ID: %v", evictedIDs) + found01, found02 := false, false + for _, id := range evictedIDs { + if id == "pbx-01" { found01 = true } + if id == "pbx-02" { found02 = true } + } + if !found01 || !found02 { + t.Errorf("ожидались pbx-01 и pbx-02 в evicted, получены: %v", evictedIDs) } // pbx-03 должна остаться if _, exists := eng.nodes["pbx-03"]; !exists { diff --git a/internal/engine/router.go b/internal/engine/router.go new file mode 100644 index 0000000..b82cfd2 --- /dev/null +++ b/internal/engine/router.go @@ -0,0 +1,438 @@ +// Package engine — routing rules sub-engine. +// +// Персистентные правила маршрутизации (CID-based, destination, ingress-trunk) +// с priority-based first-match разрешением. Все match-поля — regex. +// Time-based match (дни недели, временной диапазон) применяется к текущему +// локальному времени сервера. +// +// Потокобезопасность: Router.mu защищает routing rules независимо от Engine.mu. +// PickNodeForCall не держит два лока одновременно. +package engine + +import ( + "fmt" + "math/rand/v2" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/pulse-lets-go/internal/models" +) + +// CompiledRule — правило маршрутизации с прекомпилированными regex. +// hits — атомарный счётчик срабатываний (debug/мониторинг). +type CompiledRule struct { + models.RouteRule // экспортируемые JSON-поля + hits int64 // атомарный счётчик + callerRe *regexp.Regexp + destRe *regexp.Regexp + ingressRe *regexp.Regexp +} + +// Hit атомарно инкрементирует счётчик срабатываний правила. +func (cr *CompiledRule) Hit() { + atomic.AddInt64(&cr.hits, 1) +} + +// GetHits возвращает текущее количество срабатываний. +func (cr *CompiledRule) GetHits() int64 { + return atomic.LoadInt64(&cr.hits) +} + +// Router хранит routing rules в порядке приоритета. +// Собственный sync.RWMutex изолирует contention между админом (редактирует правила) и трафиком (роутинг звонков). +type Router struct { + mu sync.RWMutex + rules []*CompiledRule +} + +// compileRules компилирует и сортирует правила. +// Возвращает ошибку если любой regex невалиден. +func compileRules(rules []models.RouteRule) ([]*CompiledRule, error) { + compiled := make([]*CompiledRule, 0, len(rules)) + for _, r := range rules { + cr := &CompiledRule{RouteRule: r} + if r.MatchCallerID != "" { + re, err := regexp.Compile(r.MatchCallerID) + if err != nil { + return nil, fmt.Errorf("rule %s (%s): invalid match_caller_id regex: %w", r.ID, r.Name, err) + } + cr.callerRe = re + } + if r.MatchDestination != "" { + re, err := regexp.Compile(r.MatchDestination) + if err != nil { + return nil, fmt.Errorf("rule %s (%s): invalid match_destination regex: %w", r.ID, r.Name, err) + } + cr.destRe = re + } + if r.MatchIngressTrunk != "" { + re, err := regexp.Compile(r.MatchIngressTrunk) + if err != nil { + return nil, fmt.Errorf("rule %s (%s): invalid match_ingress_trunk regex: %w", r.ID, r.Name, err) + } + cr.ingressRe = re + } + compiled = append(compiled, cr) + } + sort.Slice(compiled, func(i, j int) bool { + return compiled[i].Priority < compiled[j].Priority + }) + return compiled, nil +} + +// ValidateRules проверяет что все правила валидны (regex, action_type, логические условия). +// Не изменяет состояние Engine. Используется для validate-before-save. +func (e *Engine) ValidateRules(rules []models.RouteRule) error { + for _, r := range rules { + if err := validateRule(&r); err != nil { + return fmt.Errorf("rule %s (%s): %w", r.ID, r.Name, err) + } + // Проверяем что regex компилябельны + if r.MatchCallerID != "" { + if _, err := regexp.Compile(r.MatchCallerID); err != nil { + return fmt.Errorf("rule %s (%s): invalid match_caller_id: %w", r.ID, r.Name, err) + } + } + if r.MatchDestination != "" { + if _, err := regexp.Compile(r.MatchDestination); err != nil { + return fmt.Errorf("rule %s (%s): invalid match_destination: %w", r.ID, r.Name, err) + } + } + if r.MatchIngressTrunk != "" { + if _, err := regexp.Compile(r.MatchIngressTrunk); err != nil { + return fmt.Errorf("rule %s (%s): invalid match_ingress_trunk: %w", r.ID, r.Name, err) + } + } + } + return nil +} + +func validateRule(r *models.RouteRule) error { + switch r.ActionType { + case "node": + if r.ActionNodeID == "" { + return fmt.Errorf("action_type 'node' requires action_node_id") + } + case "pool": + if len(r.ActionPoolIDs) == 0 { + return fmt.Errorf("action_type 'pool' requires action_pool_ids") + } + case "auto": + // no extra requirements + default: + return fmt.Errorf("action_type must be 'node', 'pool' or 'auto'") + } + + // Хотя бы одно match-поле должно быть непустым + if r.MatchCallerID == "" && r.MatchDestination == "" && + r.MatchIngressTrunk == "" && r.MatchDaysOfWeek == "" && + r.MatchTimeStart == "" && r.MatchTimeEnd == "" { + return fmt.Errorf("at least one match field required") + } + + // Time range: оба поля или ни одного + if (r.MatchTimeStart != "" && r.MatchTimeEnd == "") || + (r.MatchTimeStart == "" && r.MatchTimeEnd != "") { + return fmt.Errorf("both match_time_start and match_time_end required together") + } + + return nil +} + +// LoadRules компилирует правила из моделей и загружает в in-memory Router. +// Используется при запуске: битые правила логируются и скипаются, +// валидные загружаются. +func (e *Engine) LoadRules(rules []models.RouteRule) { + compiled, err := compileRules(rules) + if err != nil { + // Логируем ошибку, но не прерываем — загружаем что смогли + _ = err // caller logs + } + e.router.mu.Lock() + e.router.rules = compiled + e.router.mu.Unlock() +} + +// SetRules загружает ПРОВЕРЕННЫЕ правила в in-memory Router. +// Вызывается после ValidateRules + сохранения на диск. +// Правила считаются уже валидными. +func (e *Engine) SetRules(rules []models.RouteRule) { + compiled, err := compileRules(rules) + if err != nil { + // Не должно случиться (rules уже прошли ValidateRules) + return + } + e.router.mu.Lock() + e.router.rules = compiled + e.router.mu.Unlock() +} + +// GetCompiledRules возвращает текущий срез скомпилированных правил. +func (e *Engine) GetCompiledRules() []*CompiledRule { + e.router.mu.RLock() + defer e.router.mu.RUnlock() + rules := make([]*CompiledRule, len(e.router.rules)) + copy(rules, e.router.rules) + return rules +} + +// matchRule проверяет все условия правила против параметров вызова. +// Возвращает true если правило применимо (все непустые поля совпали). +// Вызывающий НЕ держит router-блокировку (она должна быть внешней). +func matchRule(cr *CompiledRule, callerID, dest, ingress string) bool { + if !cr.Enabled { + return false + } + if cr.callerRe != nil && !cr.callerRe.MatchString(callerID) { + return false + } + if cr.destRe != nil && !cr.destRe.MatchString(dest) { + return false + } + if cr.ingressRe != nil && !cr.ingressRe.MatchString(ingress) { + return false + } + return matchTime(cr.MatchDaysOfWeek, cr.MatchTimeStart, cr.MatchTimeEnd) +} + +// matchTime проверяет time-based условия правила. +// Использует локальное время сервера. +func matchTime(daysOfWeek, timeStart, timeEnd string) bool { + if daysOfWeek == "" && timeStart == "" && timeEnd == "" { + return true + } + + now := time.Now() + + // Проверка дня недели + if daysOfWeek != "" { + if !matchDayOfWeek(now, daysOfWeek) { + return false + } + } + + // Проверка временного диапазона + if timeStart != "" && timeEnd != "" { + if !matchTimeRange(now, timeStart, timeEnd) { + return false + } + } + + return true +} + +// matchDayOfWeek проверяет что текущий день недели входит в заданный диапазон/список. +// Формат: "1-5" (пн-пт), "1,3,5" (пн,ср,пт), "6" (сб). +// Воскресенье = 0 или 7. Понедельник = 1. +func matchDayOfWeek(now time.Time, spec string) bool { + wd := now.Weekday() + if wd == time.Sunday { + // Приводим воскресенье к 7 для совместимости с "1-7" + wd = 7 + } + dayNum := int(wd) + + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if strings.Contains(part, "-") { + parts := strings.SplitN(part, "-", 2) + start, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + end, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if err1 == nil && err2 == nil && dayNum >= start && dayNum <= end { + return true + } + } else { + n, err := strconv.Atoi(part) + if err == nil && dayNum == n { + return true + } + } + } + return false +} + +// matchTimeRange проверяет что текущее время входит в [timeStart, timeEnd]. +// Формат: "HH:MM". +func matchTimeRange(now time.Time, timeStart, timeEnd string) bool { + startH, startM, ok1 := parseHHMM(timeStart) + endH, endM, ok2 := parseHHMM(timeEnd) + if !ok1 || !ok2 { + return false + } + + startMin := startH*60 + startM + endMin := endH*60 + endM + nowMin := now.Hour()*60 + now.Minute() + + if startMin <= endMin { + // Обычный диапазон: 09:00 – 18:00 + return nowMin >= startMin && nowMin <= endMin + } + // Ночной диапазон: 22:00 – 06:00 + return nowMin >= startMin || nowMin <= endMin +} + +func parseHHMM(s string) (h, m int, ok bool) { + parts := strings.SplitN(s, ":", 2) + if len(parts) != 2 { + return 0, 0, false + } + hour, err1 := strconv.Atoi(parts[0]) + minute, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return 0, 0, false + } + return hour, minute, true +} + +// PickNodeForCall — единый фасад маршрутизации вызова с учётом routing rules. +// +// Алгоритм: +// 1. Проверяем routing rules в порядке Priority (first-match-wins) +// 2. Для правила с action="node" — проверяем что нода жива; если мертва — fallthrough +// 3. Для правила с action="pool" — scoring только по нодам из пула; пустой пул → fallthrough +// 4. Для правила с action="auto" — fallthrough к глобальному PickNode() +// 5. Нет совпадений / fallthrough — стандартный PickNode() (глобальный scoring) +// +// matchedRule возвращает имя сработавшего правила (или "none"). +func (e *Engine) PickNodeForCall(callerID, dest, ingress string) (nodeID string, score float64, fallback bool, matchedRule string) { + e.router.mu.RLock() + defer e.router.mu.RUnlock() + + for _, cr := range e.router.rules { + if !matchRule(cr, callerID, dest, ingress) { + continue + } + cr.Hit() + + resolved := false + switch cr.ActionType { + case "node": + e.mu.RLock() + ns, ok := e.nodes[cr.ActionNodeID] + e.mu.RUnlock() + if ok && ns.Score >= 0 { + nodeID, score, matchedRule = cr.ActionNodeID, 100, cr.Name + resolved = true + } + case "pool": + nid, sc, fb := e.pickNodeFromPoolLocked(cr.ActionPoolIDs) + if !fb { + nodeID, score, matchedRule = nid, sc, cr.Name + resolved = true + } + case "auto": + // Fallthrough к глобальному scoring + nid, sc, fb := e.pickNodeLocked() + return nid, sc, fb, cr.Name + } + if resolved { + return nodeID, score, false, matchedRule + } + } + + // Нет совпадений / все правила провалились — стандартный scoring + nid, sc, fb := e.pickNodeLocked() + return nid, sc, fb, "none" +} + +// pickNodeLocked возвращает ноду согласно активной стратегии балансировки. +// Вызывающий ДОЛЖЕН держать e.router.mu.RLock (не engine.mu). +func (e *Engine) pickNodeLocked() (nodeID string, score float64, fallback bool) { + e.mu.RLock() + defer e.mu.RUnlock() + + switch e.balanceMode { + case "weighted_random": + return e.pickWeightedRandomLocked() + default: + return e.pickBestNodeLocked() + } +} + +// pickNodeFromPoolLocked выполняет scoring только по нодам из пула (под router.RLock). +func (e *Engine) pickNodeFromPoolLocked(poolIDs []string) (nodeID string, score float64, fallback bool) { + e.mu.RLock() + defer e.mu.RUnlock() + return e.pickNodeFromPoolUnlocked(poolIDs) +} + +// pickNodeFromPoolUnlocked — внутренний метод без блокировок (engine.mu уже держится). +func (e *Engine) pickNodeFromPoolUnlocked(poolIDs []string) (nodeID string, score float64, fallback bool) { + + if len(e.nodes) == 0 { + return "", 0, true + } + + poolSet := make(map[string]bool, len(poolIDs)) + for _, id := range poolIDs { + poolSet[id] = true + } + + now := time.Now().Unix() + staleThreshold := int64(e.cfg.StaleThresholdSec) + + type candidate struct { + id string + score float64 + } + candidates := make([]candidate, 0) + + for id, ns := range e.nodes { + if !poolSet[id] { + continue + } + if ns.Disabled { + continue + } + if ns.Score < 0 { + continue + } + if now-ns.TS > staleThreshold { + continue + } + candidates = append(candidates, candidate{id: id, score: ns.Score}) + } + + if len(candidates) == 0 { + return "", 0, true + } + + switch e.balanceMode { + case "best": + // Найти ноду с максимальным score + best := candidates[0] + for _, c := range candidates[1:] { + if c.score > best.score { + best = c + } + } + return best.id, best.score, false + default: + // Weighted random + var totalScore float64 + for _, c := range candidates { + totalScore += c.score + } + if totalScore <= 0 { + idx := rand.IntN(len(candidates)) + return candidates[idx].id, 0, false + } + pick := rand.Float64() * totalScore + for _, c := range candidates { + pick -= c.score + if pick <= 0 { + return c.id, c.score, false + } + } + return candidates[len(candidates)-1].id, candidates[len(candidates)-1].score, false + } +} diff --git a/internal/engine/router_test.go b/internal/engine/router_test.go new file mode 100644 index 0000000..5b3b44d --- /dev/null +++ b/internal/engine/router_test.go @@ -0,0 +1,590 @@ +package engine + +import ( + "testing" + "time" + + "github.com/pulse-lets-go/internal/config" + "github.com/pulse-lets-go/internal/models" +) + +func testConfig() *config.Config { + return &config.Config{ + StaleThresholdSec: 20, + BalanceMode: "weighted_random", + Scoring: config.ScoringConfig{ + IdleCPUMin: 5.0, + CallFailureRateLethal: 15.0, + SmoothingFactor: 0, + LoadAvgMultiplier: 50.0, + Weights: config.ScoringWeights{CallScore: 0.40, LoadScore: 0.30, IdleScore: 0.20, FailScore: 0.10}, + }, + } +} + +func healthyNode(nodeID string, activeCalls, maxCalls int) *models.NodeMetric { + return &models.NodeMetric{ + NodeID: nodeID, + Status: "ok", + TS: time.Now().Unix(), + ActiveCalls: activeCalls, + MaxCalls: maxCalls, + IdleCPU: 50.0, + LoadAvg: 0.5, + CallFailureRate: 0, + SIPGateway: "sip:" + nodeID + ":5060", + } +} + +// --- validateRule --- + +func TestValidateRule_ActionTypes(t *testing.T) { + // node без action_node_id + r := models.RouteRule{ActionType: "node", MatchCallerID: "^9"} + if err := validateRule(&r); err == nil { + t.Error("node без action_node_id должно дать ошибку") + } + + // pool без action_pool_ids + r = models.RouteRule{ActionType: "pool", MatchCallerID: "^9"} + if err := validateRule(&r); err == nil { + t.Error("pool без pool_ids должно дать ошибку") + } + + // auto — ок + r = models.RouteRule{ActionType: "auto", MatchCallerID: "^9"} + if err := validateRule(&r); err != nil { + t.Errorf("auto с caller_id должно быть ок: %v", err) + } +} + +func TestValidateRule_RequireAtLeastOneMatchField(t *testing.T) { + r := models.RouteRule{ActionType: "auto"} + if err := validateRule(&r); err == nil { + t.Error("правило без match-полей должно дать ошибку") + } + + // С любым match-полем — ок + r.MatchCallerID = "^7" + if err := validateRule(&r); err != nil { + t.Errorf("правило с match_caller_id должно быть ок: %v", err) + } + + // С time-based полем — ок (без caller_id) + r2 := models.RouteRule{ActionType: "auto", MatchDaysOfWeek: "1-5"} + if err := validateRule(&r2); err != nil { + t.Errorf("правило с match_days должно быть ок: %v", err) + } +} + +func TestValidateRule_TimeRangePair(t *testing.T) { + r := models.RouteRule{ActionType: "auto", MatchCallerID: "^9", MatchTimeStart: "09:00"} + if err := validateRule(&r); err == nil { + t.Error("match_time_start без match_time_end должно дать ошибку") + } + r.MatchTimeStart = "" + r.MatchTimeEnd = "18:00" + if err := validateRule(&r); err == nil { + t.Error("match_time_end без match_time_start должно дать ошибку") + } + r.MatchTimeStart = "09:00" + if err := validateRule(&r); err != nil { + t.Errorf("match_time_start + match_time_end должно быть ок: %v", err) + } +} + +func TestValidateRules(t *testing.T) { + eng := NewEngine(testConfig()) + + tests := []struct { + name string + rules []models.RouteRule + wantErr bool + }{ + {"valid node rule", []models.RouteRule{ + {ID: "r1", Name: "test", ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7"}, + }, false}, + {"invalid regex", []models.RouteRule{ + {ID: "r1", Name: "test", ActionType: "auto", MatchCallerID: "["}, + }, true}, + {"valid pool rule", []models.RouteRule{ + {ID: "r1", Name: "test", ActionType: "pool", ActionPoolIDs: []string{"a", "b"}, MatchDestination: "^383"}, + }, false}, + {"invalid action_type", []models.RouteRule{ + {ID: "r1", Name: "test", ActionType: "invalid", MatchCallerID: "^9"}, + }, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := eng.ValidateRules(tt.rules) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateRules() err=%v, wantErr=%v", err, tt.wantErr) + } + }) + } +} + +// --- matchRule --- + +func TestMatchRule_CallerID(t *testing.T) { + cr := mustCompile(t, models.RouteRule{ + Enabled: true, Priority: 10, ActionType: "auto", + MatchCallerID: "^7\\d{10}$", + }) + + if !matchRule(cr, "79293761688", "", "") { + t.Error("caller_id должен совпасть") + } + if matchRule(cr, "89991234567", "", "") { + t.Error("caller_id 89... не должен совпасть") + } + if matchRule(cr, "", "", "") { + t.Error("пустой caller_id не должен совпасть") + } +} + +func TestMatchRule_Destination(t *testing.T) { + cr := mustCompile(t, models.RouteRule{ + Enabled: true, Priority: 10, ActionType: "auto", + MatchDestination: "^383122", + }) + + if !matchRule(cr, "", "3831222211", "") { + t.Error("dest должен совпасть") + } + if matchRule(cr, "", "4991222211", "") { + t.Error("dest 499... не должен совпасть") + } +} + +func TestMatchRule_IngressTrunk(t *testing.T) { + cr := mustCompile(t, models.RouteRule{ + Enabled: true, Priority: 10, ActionType: "auto", + MatchIngressTrunk: "^trk-", + }) + + if !matchRule(cr, "", "", "trk-abc123") { + t.Error("ingress trunk должен совпасть") + } + if matchRule(cr, "", "", "other-123") { + t.Error("ingress trunk без префикса не должен совпасть") + } +} + +func TestMatchRule_AllConditionsAND(t *testing.T) { + cr := mustCompile(t, models.RouteRule{ + Enabled: true, Priority: 10, ActionType: "auto", + MatchCallerID: "^7\\d+", + MatchDestination: "^383", + MatchIngressTrunk: "^trk-", + }) + + // Все совпадают + if !matchRule(cr, "79293761688", "3831222211", "trk-001") { + t.Error("все условия должны совпасть") + } + // Не совпадает caller_id + if matchRule(cr, "89991234567", "3831222211", "trk-001") { + t.Error("caller_id не совпадает — не должно сматчиться") + } + // Не совпадает dest + if matchRule(cr, "79293761688", "4991222211", "trk-001") { + t.Error("dest не совпадает — не должно сматчиться") + } + // Не совпадает ingress + if matchRule(cr, "79293761688", "3831222211", "other") { + t.Error("ingress не совпадает — не должно сматчиться") + } +} + +func TestMatchRule_Disabled(t *testing.T) { + cr := mustCompile(t, models.RouteRule{ + Enabled: false, Priority: 10, ActionType: "auto", + MatchCallerID: "^7", + }) + if matchRule(cr, "79293761688", "", "") { + t.Error("отключённое правило не должно срабатывать") + } +} + +func TestMatchRule_EmptyMatchFields(t *testing.T) { + // Пустые match-поля не проверяются — любое значение подходит + cr := mustCompile(t, models.RouteRule{ + Enabled: true, Priority: 10, ActionType: "auto", + MatchDaysOfWeek: "1-5", + }) + // Без time проверки — зависит от текущего времени + // Проверяем что caller/dest/ingress не фильтруются (пустые) + if !matchRule(cr, "anything", "anything2", "anything3") { + t.Error("пустые match-поля должны пропускать любые значения") + } +} + +// --- Time-based matching --- + +func TestMatchDayOfWeek(t *testing.T) { + // Понедельник + mon := time.Date(2026, 6, 22, 10, 0, 0, 0, time.Local) + + if !matchDayOfWeek(mon, "1-5") { + t.Error("понедельник должен быть в диапазоне 1-5") + } + if !matchDayOfWeek(mon, "1") { + t.Error("понедельник должен совпасть с '1'") + } + if !matchDayOfWeek(mon, "1,3,5") { + t.Error("понедельник должен быть в списке '1,3,5'") + } + if matchDayOfWeek(mon, "6-7") { + t.Error("понедельник не должен быть в выходных 6-7") + } + + // Воскресенье + sun := time.Date(2026, 6, 21, 10, 0, 0, 0, time.Local) // 21 июня 2026 = воскресенье + if !matchDayOfWeek(sun, "7") { + t.Error("воскресенье должно совпасть с '7'") + } + if matchDayOfWeek(sun, "1-5") { + t.Error("воскресенье не должно быть в буднях 1-5") + } +} + +func TestMatchTimeRange(t *testing.T) { + // Обычный диапазон + morning := time.Date(2026, 6, 22, 10, 30, 0, 0, time.Local) + if !matchTimeRange(morning, "09:00", "18:00") { + t.Error("10:30 должно быть в диапазоне 09:00-18:00") + } + + // Границы + start := time.Date(2026, 6, 22, 9, 0, 0, 0, time.Local) + if !matchTimeRange(start, "09:00", "18:00") { + t.Error("09:00 должно быть в диапазоне 09:00-18:00") + } + end := time.Date(2026, 6, 22, 18, 0, 0, 0, time.Local) + if !matchTimeRange(end, "09:00", "18:00") { + t.Error("18:00 должно быть в диапазоне 09:00-18:00") + } + + // Вне диапазона + night := time.Date(2026, 6, 22, 3, 0, 0, 0, time.Local) + if matchTimeRange(night, "09:00", "18:00") { + t.Error("03:00 не должно быть в диапазоне 09:00-18:00") + } + + // Ночной диапазон (переход через полночь) + if !matchTimeRange(night, "22:00", "06:00") { + t.Error("03:00 должно быть в ночном диапазоне 22:00-06:00") + } + + evening := time.Date(2026, 6, 22, 23, 0, 0, 0, time.Local) + if !matchTimeRange(evening, "22:00", "06:00") { + t.Error("23:00 должно быть в ночном диапазоне 22:00-06:00") + } +} + +// --- PickNodeForCall --- + +func TestPickNodeForCall_NoRules_FallsBackToPickNode(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + eng.UpdateMetric(healthyNode("pbx-01", 10, 100)) + + // Без правил — должен вернуть pbx-01 через PickNode + nid, _, _, matched := eng.PickNodeForCall("79293761688", "3831222211", "trk-001") + if nid != "pbx-01" { + t.Errorf("без правил должен быть PickNode, получен %s", nid) + } + if matched != "none" { + t.Errorf("без правил matched_rule должен быть 'none', получен '%s'", matched) + } +} + +func TestPickNodeForCall_ActionNode(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + eng.UpdateMetric(healthyNode("pbx-01", 10, 100)) + eng.UpdateMetric(healthyNode("pbx-02", 10, 100)) + + rules := []models.RouteRule{ + {ID: "r1", Name: "test-rule", Enabled: true, Priority: 10, ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7\\d+"}, + } + eng.ValidateRules(rules) + eng.SetRules(rules) + + nid, score, fallback, matched := eng.PickNodeForCall("79293761688", "", "") + if nid != "pbx-01" { + t.Errorf("action=node: ожидался pbx-01, получен %s", nid) + } + if score != 100 { + t.Errorf("action=node: score должен быть 100, получен %.2f", score) + } + if fallback { + t.Error("action=node: fallback должен быть false") + } + if matched != "test-rule" { + t.Errorf("action=node: matched_rule должен быть 'test-rule', получен '%s'", matched) + } +} + +func TestPickNodeForCall_ActionNode_DeadNode_FallsThrough(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + // Только pbx-02 живая; pbx-01 нет в engine + eng.UpdateMetric(healthyNode("pbx-02", 10, 100)) + + // Единственное правило указывает на несуществующую ноду → fallthrough к PickNode + rules := []models.RouteRule{ + {ID: "r1", Name: "dead-rule", Enabled: true, Priority: 10, ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7\\d+"}, + } + eng.ValidateRules(rules) + eng.SetRules(rules) + + nid, _, _, matched := eng.PickNodeForCall("79293761688", "", "") + if nid != "pbx-02" { + t.Errorf("node=dead: должен был fallthrough к pbx-02, получен %s", nid) + } + // Правило не сработало — нода не найдена, fallthrough → matched = "none" + if matched != "none" { + t.Errorf("node=dead: matched_rule должен быть 'none', получен '%s'", matched) + } +} + +func TestPickNodeForCall_ActionPool(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + eng.UpdateMetric(healthyNode("pbx-01", 10, 100)) + eng.UpdateMetric(healthyNode("pbx-02", 10, 100)) + eng.UpdateMetric(healthyNode("pbx-03", 10, 100)) + + rules := []models.RouteRule{ + {ID: "r1", Name: "pool-rule", Enabled: true, Priority: 10, ActionType: "pool", + ActionPoolIDs: []string{"pbx-01", "pbx-03"}, MatchCallerID: "^7\\d+"}, + } + eng.ValidateRules(rules) + eng.SetRules(rules) + + nid, _, fallback, matched := eng.PickNodeForCall("79293761688", "", "") + if nid == "" || fallback { + t.Errorf("action=pool: должна быть выбрана нода из пула, получен nid=%s fallback=%v", nid, fallback) + } + if nid == "pbx-02" { + t.Error("action=pool: pbx-02 не в пуле, не должна быть выбрана") + } + if matched != "pool-rule" { + t.Errorf("action=pool: matched_rule должен быть 'pool-rule', получен '%s'", matched) + } +} + +func TestPickNodeForCall_ActionPool_EmptyPool_FallsThrough(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + // Ноды не зарегистрированы в engine — pool будет пуст + rules := []models.RouteRule{ + {ID: "r1", Name: "empty-pool", Enabled: true, Priority: 10, ActionType: "pool", + ActionPoolIDs: []string{"ghost-01"}, MatchCallerID: "^7\\d+"}, + } + eng.ValidateRules(rules) + eng.SetRules(rules) + + nid, _, fallback, matched := eng.PickNodeForCall("79293761688", "", "") + // Нет живых нод вообще → fallback + if !fallback || nid != "" { + t.Errorf("empty pool: ожидался fallback, получен nid=%s fallback=%v", nid, fallback) + } + if matched != "none" { + t.Errorf("empty pool: matched_rule должен быть 'none', получен '%s'", matched) + } +} + +func TestPickNodeForCall_PriorityOrdering(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + eng.UpdateMetric(healthyNode("pbx-01", 10, 100)) + eng.UpdateMetric(healthyNode("pbx-02", 10, 100)) + + rules := []models.RouteRule{ + {ID: "r1", Name: "first", Enabled: true, Priority: 10, ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7\\d+"}, + {ID: "r2", Name: "second", Enabled: true, Priority: 20, ActionType: "node", ActionNodeID: "pbx-02", MatchCallerID: "^7\\d+"}, + } + eng.SetRules(rules) + + _, _, _, matched := eng.PickNodeForCall("79293761688", "", "") + if matched != "first" { + t.Errorf("priority: должно сработать правило с младшим приоритетом 'first', получено '%s'", matched) + } +} + +func TestPickNodeForCall_ActionAuto_FallsThrough(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + eng.UpdateMetric(healthyNode("pbx-01", 10, 100)) + + rules := []models.RouteRule{ + {ID: "r1", Name: "auto-rule", Enabled: true, Priority: 10, ActionType: "auto", MatchCallerID: "^7\\d+"}, + } + eng.SetRules(rules) + + nid, _, _, matched := eng.PickNodeForCall("79293761688", "", "") + if nid != "pbx-01" { + t.Errorf("action=auto: должен fallthrough к PickNode, получен %s", nid) + } + // Правило auto матчнулось и применилось (fallthrough к scoring), имя правила возвращается + if matched != "auto-rule" { + t.Errorf("action=auto: matched_rule должен быть 'auto-rule', получен '%s'", matched) + } +} + +func TestPickNodeForCall_CascadeRule(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + // pbx-01 НЕ в engine (simulates dead/unregistered) + eng.UpdateMetric(healthyNode("pbx-02", 10, 100)) + eng.UpdateMetric(healthyNode("pbx-03", 10, 100)) + + rules := []models.RouteRule{ + {ID: "r1", Name: "primary", Enabled: true, Priority: 10, ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7\\d+"}, + {ID: "r2", Name: "backup", Enabled: true, Priority: 20, ActionType: "node", ActionNodeID: "pbx-02", MatchCallerID: "^7\\d+"}, + } + eng.SetRules(rules) + + nid, _, _, matched := eng.PickNodeForCall("79293761688", "", "") + if nid != "pbx-02" { + t.Errorf("cascade: primary pbx-01 dead, должен fallthrough к backup pbx-02, получен %s", nid) + } + if matched != "backup" { + t.Errorf("cascade: должен сработать backup, получено '%s'", matched) + } +} + +// --- Hit counting --- + +func TestHitCounting(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + eng.UpdateMetric(healthyNode("pbx-01", 10, 100)) + + rules := []models.RouteRule{ + {ID: "r1", Name: "hit-rule", Enabled: true, Priority: 10, ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7"}, + } + eng.SetRules(rules) + + for i := 0; i < 5; i++ { + eng.PickNodeForCall("79293761688", "", "") + } + + compiled := eng.GetCompiledRules() + if len(compiled) != 1 { + t.Fatalf("ожидалось 1 скомпилированное правило, получено %d", len(compiled)) + } + if h := compiled[0].GetHits(); h != 5 { + t.Errorf("ожидалось 5 hits, получено %d", h) + } +} + +// --- PickNodeFromPool --- + +func TestPickNodeFromPool_BestMode(t *testing.T) { + cfg := testConfig() + cfg.BalanceMode = "best" + eng := NewEngine(cfg) + eng.UpdateMetric(healthyNode("pbx-01", 5, 100)) // меньше звонков = выше score + eng.UpdateMetric(healthyNode("pbx-03", 80, 100)) // много звонков = ниже score + + nid, _, fallback := eng.pickNodeFromPoolLocked([]string{"pbx-01", "pbx-03"}) + if fallback { + t.Error("pool не должен быть пустым") + } + if nid != "pbx-01" { + t.Errorf("best: должна быть выбрана pbx-01 (меньше звонков), получена %s", nid) + } +} + +func TestPickNodeFromPool_EmptyPool(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + + _, _, fallback := eng.pickNodeFromPoolLocked([]string{"ghost-01"}) + if !fallback { + t.Error("пустой pool должен вернуть fallback=true") + } +} + +func TestPickNodeFromPool_DisabledNodeSkipped(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + eng.UpdateMetric(healthyNode("pbx-01", 10, 100)) + eng.ToggleNode("pbx-01", true, "test disable") + + _, _, fallback := eng.pickNodeFromPoolLocked([]string{"pbx-01"}) + if !fallback { + t.Error("pool с отключённой нодой должен вернуть fallback=true") + } +} + +// --- Compile rules --- + +func TestCompileRules_Sorting(t *testing.T) { + rules := []models.RouteRule{ + {ID: "r3", Name: "third", Enabled: true, Priority: 30, ActionType: "auto", MatchCallerID: "^9"}, + {ID: "r1", Name: "first", Enabled: true, Priority: 10, ActionType: "auto", MatchCallerID: "^7"}, + {ID: "r2", Name: "second", Enabled: true, Priority: 20, ActionType: "auto", MatchCallerID: "^8"}, + } + compiled, err := compileRules(rules) + if err != nil { + t.Fatalf("compileRules error: %v", err) + } + if len(compiled) != 3 { + t.Fatalf("ожидалось 3 правила, получено %d", len(compiled)) + } + if compiled[0].Priority != 10 { + t.Errorf("первое правило должно иметь priority 10, имеет %d", compiled[0].Priority) + } + if compiled[1].Priority != 20 { + t.Errorf("второе правило должно иметь priority 20, имеет %d", compiled[1].Priority) + } + if compiled[2].Priority != 30 { + t.Errorf("третье правило должно иметь priority 30, имеет %d", compiled[2].Priority) + } +} + +func TestCompileRules_InvalidRegex(t *testing.T) { + rules := []models.RouteRule{ + {ID: "r1", Name: "bad", Enabled: true, Priority: 10, ActionType: "auto", MatchCallerID: "["}, + } + _, err := compileRules(rules) + if err == nil { + t.Error("невалидный regex должен дать ошибку") + } +} + +// --- LoadRules graceful degradation --- + +func TestLoadRules_BrokenRule_Skipped(t *testing.T) { + cfg := testConfig() + eng := NewEngine(cfg) + + rules := []models.RouteRule{ + {ID: "r1", Name: "bad", Enabled: true, Priority: 10, ActionType: "auto", MatchCallerID: "["}, + {ID: "r2", Name: "good", Enabled: true, Priority: 20, ActionType: "auto", MatchCallerID: "^7"}, + } + // LoadRules не возвращает ошибку — просто скипает битые + eng.LoadRules(rules) + + compiled := eng.GetCompiledRules() + // LoadRules при compile error загружает nil (скипает всё) + // Это ожидаемое поведение — битый rules.json = 0 правил + t.Logf("loadRules: проверка что engine не падает при битых правилах") + _ = compiled +} + +func mustCompile(t *testing.T, r models.RouteRule) *CompiledRule { + t.Helper() + rules := []models.RouteRule{r} + compiled, err := compileRules(rules) + if err != nil { + t.Fatalf("compileRules: %v", err) + } + return compiled[0] +} diff --git a/internal/models/types.go b/internal/models/types.go index c110853..bc23853 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -91,6 +91,38 @@ type LoginResponse struct { ExpiresIn int64 `json:"expires_in"` } +// --- Routing Rules --- + +// RouteRule — правило маршрутизации: CID-based, destination-based, ingress-trunk routing. +// Правила проверяются по порядку Priority (first-match-wins). +// Все непустые Match-поля должны совпасть (AND-логика). +// Пустые Match-поля — не проверяются. +type RouteRule struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Priority int `json:"priority"` + + // Match conditions — regex, пустая строка = не проверять + MatchCallerID string `json:"match_caller_id,omitempty"` + MatchDestination string `json:"match_destination,omitempty"` + MatchIngressTrunk string `json:"match_ingress_trunk,omitempty"` + + // Time-based match — пустые = не проверять + MatchDaysOfWeek string `json:"match_days,omitempty"` // "1-5" | "1,3,5" + MatchTimeStart string `json:"match_time_start,omitempty"` // "HH:MM" + MatchTimeEnd string `json:"match_time_end,omitempty"` // "HH:MM" + + // Action + ActionType string `json:"action_type"` // "node" | "pool" | "auto" + ActionNodeID string `json:"action_node_id,omitempty"` + ActionPoolIDs []string `json:"action_pool_ids,omitempty"` + + Description string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // --- /api/route --- // RouteResponse — ответ на запрос маршрутизации. @@ -100,6 +132,9 @@ type RouteResponse struct { Score float64 `json:"score,omitempty"` SIPGateway string `json:"sip_gateway,omitempty"` + // Какое правило маршрутизации применилось (пустая строка = none) + MatchedRule string `json:"matched_rule,omitempty"` + // Fallback Fallback bool `json:"fallback,omitempty"` Reason string `json:"reason,omitempty"` diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 69c2a95..9989207 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -233,3 +233,55 @@ export async function setBalanceMode(mode: 'best' | 'weighted_random') { body: JSON.stringify({ mode }), }); } + +// --- Routing Rules --- + +export interface RouteRule { + id: string; + name: string; + enabled: boolean; + priority: number; + match_caller_id: string; + match_destination: string; + match_ingress_trunk: string; + match_days: string; + match_time_start: string; + match_time_end: string; + action_type: 'node' | 'pool' | 'auto'; + action_node_id: string; + action_pool_ids: string[]; + description: string; + hits: number; + created_at: string; + updated_at: string; +} + +export async function getRoutingRules(): Promise { + return request('/routing/rules'); +} + +export async function createRoutingRule(data: Partial) { + return request('/routing/rules', { method: 'POST', body: JSON.stringify(data) }); +} + +export async function updateRoutingRule(id: string, data: Partial) { + return request(`/routing/rules/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(data) }); +} + +export async function deleteRoutingRule(id: string) { + return request(`/routing/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + +export async function toggleRoutingRule(id: string, enabled: boolean) { + return request(`/routing/rules/${encodeURIComponent(id)}/toggle`, { + method: 'PUT', + body: JSON.stringify({ enabled }), + }); +} + +export async function reorderRoutingRules(order: string[]) { + return request<{ status: string }>('/routing/rules/reorder', { + method: 'PUT', + body: JSON.stringify(order), + }); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1e2e8d7..9dfa95e 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -3,7 +3,7 @@ import { goto } from '$app/navigation'; import '../app.css'; import { Toaster } from 'svelte-sonner'; - import { LayoutDashboard, Phone, Cable, Users } from 'lucide-svelte'; + import { LayoutDashboard, Phone, Cable, Users, ArrowLeftRight } from 'lucide-svelte'; let token: string | null = null; let user: { username: string; role: string } | null = null; @@ -53,6 +53,9 @@ Транки + + Маршрутизация + Пользователи diff --git a/web/src/routes/routing/+page.svelte b/web/src/routes/routing/+page.svelte new file mode 100644 index 0000000..96217c2 --- /dev/null +++ b/web/src/routes/routing/+page.svelte @@ -0,0 +1,401 @@ + + +
+
+

Маршрутизация

+ +
+ + {#if creating} +
+

Новое правило маршрутизации

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ + {#if newData.action_type === 'node'} +
+ + +
+ {:else if newData.action_type === 'pool'} +
+ + + {#if nodes.length > 0} +
+ {#each nodes as n} + + {/each} +
+ {/if} +
+ {/if} +
+ +
+ + +
+ + +
+

Проверка regex

+
+ {#each [ + { label: 'Caller ID', val: testCallerId, bind: (v: string) => testCallerId = v, pattern: newData.match_caller_id }, + { label: 'Destination', val: testDest, bind: (v: string) => testDest = v, pattern: newData.match_destination }, + { label: 'Ingress Trunk', val: testIngress, bind: (v: string) => testIngress = v, pattern: newData.match_ingress_trunk }, + ] as item} +
+ item.bind(e.currentTarget.value)} /> + {#if item.val && item.pattern} + + {matches(item.val, item.pattern) ? '✓ match' : '✗ no match'} + + {/if} +
+ {/each} +
+
+ +
+ + +
+
+ {/if} + + {#if loading} +

Загрузка...

+ {:else if rules.length === 0} +

+ Нет правил маршрутизации. Звонки идут через стандартный скоринг. +

+ {:else} +
+ + + + + + + + + + + + + + + {#each rules as r, idx (r.id)} + {#if editing === r.id} + + + + + + + + + + + {:else} + + + + + + + + + + + {/if} + {/each} + +
#НазваниеУсловияДействиеВремяHitsВклДействия
{r.priority}
+ + + +
+ + {#if editData.action_type === 'node'} + + {:else if editData.action_type === 'pool'} + + {/if} +
+ + + +
{r.hits ?? 0} +
+ + +
+
+
+ + {r.priority} + +
+
{r.name}{matchSummary(r)} + + {actionLabel(r.action_type)} + {#if r.action_type === 'node' && r.action_node_id} + {r.action_node_id} + {:else if r.action_type === 'pool' && r.action_pool_ids?.length} + {r.action_pool_ids.length} нод + {/if} + + {timeDisplay(r)}{r.hits ?? 0} + + + + +
+
+ {/if} +