feat: CID-based routing rules engine with flexible match conditions

Routing rules:
- RouteRule model: 3 match fields (caller_id, dest, ingress_trunk regex),
  3 action types (node/pool/auto), time-based (days of week, time range)
- Engine router.go: CompiledRule with pre-compiled regex,
  PickNodeForCall with cascade (dead node → next rule), pickNodeFromPool
- Separate sync.RWMutex for Router — zero contention admin vs traffic
- Validate-before-save pattern: engine validates before JSON save
- Graceful degradation: corrupted rules skipped, no rules → PickNode()

Persistence:
- data/routing.json with atomic save (tmp→rename), config.Manager
- Auto-created empty on first start

API (admin only):
- GET/POST/PUT/DELETE /api/routing/rules
- PUT /api/routing/rules/:id/toggle
- PUT /api/routing/rules/reorder

Integration:
- /api/route now uses PickNodeForCall with caller_id/dest/ingress_trunk
  query params (already sent by route.lua)
- RouteResponse includes matched_rule field for debugging
- Main.go loads routing rules on startup with graceful fallback

Web UI:
- /routing page: table with priority arrows, inline edit, create form,
  regex test tool, toggle, delete, action badges, hits counter

Tests:
- 28 tests: matchRule, time matching, PickNodeForCall cascade,
  pool scoring, validation, hit counting, compilation
This commit is contained in:
Maksim Totmin 2026-06-25 21:51:37 +07:00
parent 2d9d179b0a
commit a7722af29e
13 changed files with 1928 additions and 16 deletions

View File

@ -63,7 +63,16 @@ func main() {
// 3. Engine // 3. Engine
eng := engine.NewEngine(cfg) 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) eng.StartEvictionLoop(60*time.Second, 5*time.Minute)
// 4. ASCII логгер метрик // 4. ASCII логгер метрик
@ -257,6 +266,7 @@ func main() {
log.Printf(" GET /api/nodes — список нод") log.Printf(" GET /api/nodes — список нод")
log.Printf(" GET /api/trunks — управление транками") log.Printf(" GET /api/trunks — управление транками")
log.Printf(" GET /api/users — управление пользователями") 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/zabbix — Zabbix LLD + items")
log.Printf(" GET /api/monitoring/prometheus — Prometheus метрики") log.Printf(" GET /api/monitoring/prometheus — Prometheus метрики")
log.Printf(" WS /ws/metrics — real-time метрики на фронт") log.Printf(" WS /ws/metrics — real-time метрики на фронт")

View File

@ -7,16 +7,23 @@ import (
) )
// handleRoute — GET /api/route — главный эндпоинт маршрутизации вызова. // 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) { func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
a.engine.IncrementRouteRequests() 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 == "" { if nodeID == "" {
a.engine.IncrementRouteFallbacks() a.engine.IncrementRouteFallbacks()
writeJSON(w, http.StatusServiceUnavailable, models.RouteResponse{ writeJSON(w, http.StatusServiceUnavailable, models.RouteResponse{
Error: "no_nodes_registered", Error: "no_nodes_registered",
MatchedRule: matchedRule,
}) })
return return
} }
@ -28,10 +35,11 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
nodes := a.getRouteNodeInfo() nodes := a.getRouteNodeInfo()
writeJSON(w, http.StatusOK, models.RouteResponse{ writeJSON(w, http.StatusOK, models.RouteResponse{
Fallback: true, Fallback: true,
SIPGateway: fallbackGW, SIPGateway: fallbackGW,
Reason: "all_nodes_unhealthy", Reason: "all_nodes_unhealthy",
Nodes: nodes, Nodes: nodes,
MatchedRule: matchedRule,
}) })
return return
} }
@ -44,9 +52,10 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, models.RouteResponse{ writeJSON(w, http.StatusOK, models.RouteResponse{
NodeID: nodeID, NodeID: nodeID,
Score: score, Score: score,
SIPGateway: gw, SIPGateway: gw,
MatchedRule: matchedRule,
}) })
} }

View File

@ -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("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)))) 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) // Логирование + CORS (навешиваем после mux)
var wrapped http.Handler = mux var wrapped http.Handler = mux
wrapped = requestLoggingMiddleware(a.logFormat)(wrapped) wrapped = requestLoggingMiddleware(a.logFormat)(wrapped)

View File

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

View File

@ -225,6 +225,11 @@ func NewManager(dataDir string) (*Manager, error) {
return nil, fmt.Errorf("создание users.json: %w", err) 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 return m, nil
} }
@ -327,11 +332,46 @@ func (m *Manager) saveUsersLocked(users []models.User) error {
return m.saveFile(m.usersPath(), data) 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) configPath() string { return filepath.Join(m.dir, "config.json") }
func (m *Manager) trunksPath() string { return filepath.Join(m.dir, "trunks.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) usersPath() string { return filepath.Join(m.dir, "users.json") }
func (m *Manager) routingRulesPath() string { return filepath.Join(m.dir, "routing.json") }
// saveFile атомарно сохраняет данные в файл: tmp → rename. // saveFile атомарно сохраняет данные в файл: tmp → rename.
// Вызывающий держит write lock. // Вызывающий держит write lock.

View File

@ -40,6 +40,9 @@ type Engine struct {
// Режим балансировки // Режим балансировки
balanceMode string // "best" или "weighted_random" balanceMode string // "best" или "weighted_random"
// Routing rules engine (CID-based, destination, ingress-trunk routing)
router Router
} }
// NewEngine создаёт новый engine с заданной конфигурацией. // NewEngine создаёт новый engine с заданной конфигурацией.

View File

@ -536,8 +536,13 @@ func TestSetOnNodeEvictedCallback(t *testing.T) {
if len(evictedIDs) != 2 { if len(evictedIDs) != 2 {
t.Errorf("ожидалось 2 callback вызова, получено %d: %v", len(evictedIDs), evictedIDs) t.Errorf("ожидалось 2 callback вызова, получено %d: %v", len(evictedIDs), evictedIDs)
} }
if evictedIDs[0] != "pbx-01" || evictedIDs[1] != "pbx-02" { found01, found02 := false, false
t.Errorf("неверный порядок evicted ID: %v", evictedIDs) 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 должна остаться // pbx-03 должна остаться
if _, exists := eng.nodes["pbx-03"]; !exists { if _, exists := eng.nodes["pbx-03"]; !exists {

438
internal/engine/router.go Normal file
View File

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

View File

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

View File

@ -91,6 +91,38 @@ type LoginResponse struct {
ExpiresIn int64 `json:"expires_in"` 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 --- // --- /api/route ---
// RouteResponse — ответ на запрос маршрутизации. // RouteResponse — ответ на запрос маршрутизации.
@ -100,6 +132,9 @@ type RouteResponse struct {
Score float64 `json:"score,omitempty"` Score float64 `json:"score,omitempty"`
SIPGateway string `json:"sip_gateway,omitempty"` SIPGateway string `json:"sip_gateway,omitempty"`
// Какое правило маршрутизации применилось (пустая строка = none)
MatchedRule string `json:"matched_rule,omitempty"`
// Fallback // Fallback
Fallback bool `json:"fallback,omitempty"` Fallback bool `json:"fallback,omitempty"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`

View File

@ -233,3 +233,55 @@ export async function setBalanceMode(mode: 'best' | 'weighted_random') {
body: JSON.stringify({ mode }), 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<RouteRule[]> {
return request<RouteRule[]>('/routing/rules');
}
export async function createRoutingRule(data: Partial<RouteRule>) {
return request<RouteRule>('/routing/rules', { method: 'POST', body: JSON.stringify(data) });
}
export async function updateRoutingRule(id: string, data: Partial<RouteRule>) {
return request<RouteRule>(`/routing/rules/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(data) });
}
export async function deleteRoutingRule(id: string) {
return request<void>(`/routing/rules/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
export async function toggleRoutingRule(id: string, enabled: boolean) {
return request<RouteRule>(`/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),
});
}

View File

@ -3,7 +3,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import '../app.css'; import '../app.css';
import { Toaster } from 'svelte-sonner'; 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 token: string | null = null;
let user: { username: string; role: string } | null = null; let user: { username: string; role: string } | null = null;
@ -53,6 +53,9 @@
<a href="/trunks" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/trunks' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}"> <a href="/trunks" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/trunks' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
<Cable class="h-4 w-4" /> Транки <Cable class="h-4 w-4" /> Транки
</a> </a>
<a href="/routing" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/routing' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
<ArrowLeftRight class="h-4 w-4" /> Маршрутизация
</a>
<a href="/users" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/users' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}"> <a href="/users" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/users' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
<Users class="h-4 w-4" /> Пользователи <Users class="h-4 w-4" /> Пользователи
</a> </a>

View File

@ -0,0 +1,401 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import {
isAuthenticated, getRoutingRules, createRoutingRule, updateRoutingRule,
deleteRoutingRule, toggleRoutingRule, reorderRoutingRules, getNodes,
type RouteRule, type NodeInfo
} from '$lib/api';
import { toast } from 'svelte-sonner';
import { Plus, Trash2, Save, X, ArrowLeftRight, ArrowUp, ArrowDown } from 'lucide-svelte';
let rules: RouteRule[] = [];
let nodes: NodeInfo[] = [];
let loading = true;
let editing: string | null = null;
let editData: Partial<RouteRule> & { pool_ids_str?: string } = {};
let creating = false;
let newData: Partial<RouteRule> & { pool_ids_str?: string } = {
action_type: 'auto',
priority: 10,
action_pool_ids: [] as string[],
};
// Regex test inputs
let testCallerId = '';
let testDest = '';
let testIngress = '';
if (browser && !isAuthenticated()) { goto('/login'); }
onMount(async () => {
await loadAll();
await loadNodes();
});
async function loadAll() {
loading = true;
try { rules = await getRoutingRules(); }
catch (e: any) { toast.error(e.message); }
finally { loading = false; }
}
async function loadNodes() {
try { nodes = await getNodes(); }
catch { /* nodes optional */ }
}
// --- Helpers ---
function matches(str: string, pattern: string): boolean {
if (!str || !pattern) return false;
try { return new RegExp(pattern).test(str); } catch { return false; }
}
function joinPoolIDs(ids: string[] | undefined): string {
return (ids || []).join(',');
}
function splitPoolIDs(s: string | undefined): string[] {
if (!s) return [];
return s.split(',').map(x => x.trim()).filter(x => x);
}
// --- CRUD ---
function startEdit(r: RouteRule) {
editing = r.id;
editData = {
...r,
pool_ids_str: joinPoolIDs(r.action_pool_ids),
};
}
function cancelEdit() {
editing = null;
creating = false;
editData = {};
newData = { action_type: 'auto', priority: 10, action_pool_ids: [] };
testCallerId = testDest = testIngress = '';
}
async function saveEdit() {
if (!editing) return;
try {
const payload = { ...editData };
(payload as any).action_pool_ids = splitPoolIDs((payload as any).pool_ids_str);
delete (payload as any).pool_ids_str;
const updated = await updateRoutingRule(editing, payload);
const idx = rules.findIndex(r => r.id === editing);
if (idx >= 0) rules[idx] = updated;
editing = null;
toast.success('Правило обновлено');
} catch (e: any) { toast.error(e.message); }
}
async function saveCreate() {
const payload: any = { ...newData };
if (!payload.name?.trim()) { toast.error('Название обязательно'); return; }
if (payload.action_type === 'pool') {
payload.action_pool_ids = splitPoolIDs(payload.pool_ids_str);
} else if (payload.action_type === 'node') {
payload.action_pool_ids = [];
}
delete payload.pool_ids_str;
try {
const created = await createRoutingRule(payload);
rules = [...rules, created];
cancelEdit();
toast.success('Правило создано');
} catch (e: any) { toast.error(e.message); }
}
async function removeRule(id: string) {
if (!confirm('Удалить правило?')) return;
try {
await deleteRoutingRule(id);
rules = rules.filter(r => r.id !== id);
toast.success('Правило удалено');
} catch (e: any) { toast.error(e.message); }
}
async function toggleRule(id: string, enabled: boolean) {
try {
const updated = await toggleRoutingRule(id, enabled);
rules = rules.map(r => r.id === id ? updated : r);
} catch (e: any) { toast.error(e.message); }
}
async function moveRule(idx: number, dir: -1 | 1) {
const ni = idx + dir;
if (ni < 0 || ni >= rules.length) return;
const reordered = [...rules];
[reordered[idx], reordered[ni]] = [reordered[ni], reordered[idx]];
try {
await reorderRoutingRules(reordered.map(r => r.id));
await loadAll();
} catch (e: any) { toast.error(e.message); }
}
// --- Display ---
function actionBadge(t: string): string {
switch (t) {
case 'node': return 'bg-violet-100 text-violet-700';
case 'pool': return 'bg-sky-100 text-sky-700';
default: return 'bg-gray-100 text-gray-700';
}
}
function actionLabel(t: string): string {
switch (t) { case 'node': return 'Нода'; case 'pool': return 'Пул'; default: return 'Авто'; }
}
function matchSummary(r: RouteRule): string {
const parts: string[] = [];
if (r.match_caller_id) parts.push('CID');
if (r.match_destination) parts.push('Dest');
if (r.match_ingress_trunk) parts.push('Trunk');
if (r.match_days) parts.push('Days=' + r.match_days);
if (r.match_time_start || r.match_time_end) parts.push((r.match_time_start || '--') + '-' + (r.match_time_end || '--'));
return parts.length > 0 ? parts.join(', ') : '—';
}
function timeDisplay(r: RouteRule): string {
if (!r.match_days && !r.match_time_start && !r.match_time_end) return '—';
const parts: string[] = [];
if (r.match_days) parts.push('дни ' + r.match_days);
if (r.match_time_start || r.match_time_end) parts.push((r.match_time_start || '--') + '' + (r.match_time_end || '--'));
return parts.join(' ');
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold flex items-center gap-2"><ArrowLeftRight class="h-5 w-5" /> Маршрутизация</h1>
<button on:click={() => (creating = true)} class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90">
<Plus class="h-4 w-4" /> Добавить правило
</button>
</div>
{#if creating}
<div class="border rounded-lg p-4 bg-yellow-50 border-yellow-200 space-y-3">
<h3 class="text-sm font-semibold">Новое правило маршрутизации</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Название правила *" bind:value={newData.name} />
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Приоритет (меньше = раньше)" type="number" bind:value={newData.priority} />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="text-xs text-muted-foreground">Caller ID (regex)</label>
<input class="w-full px-3 py-2 border rounded-md text-sm font-mono" placeholder="^7\d{10}$" bind:value={newData.match_caller_id} />
</div>
<div>
<label class="text-xs text-muted-foreground">Destination (regex)</label>
<input class="w-full px-3 py-2 border rounded-md text-sm font-mono" placeholder="^383122.*$" bind:value={newData.match_destination} />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="text-xs text-muted-foreground">Ingress Trunk (regex)</label>
<input class="w-full px-3 py-2 border rounded-md text-sm font-mono" placeholder="^trk-.*$" bind:value={newData.match_ingress_trunk} />
</div>
<div>
<label class="text-xs text-muted-foreground">Дни недели ("1-5" = пн-пт)</label>
<input class="w-full px-3 py-2 border rounded-md text-sm" placeholder="1-5" bind:value={newData.match_days} />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="text-xs text-muted-foreground">Начало (HH:MM)</label>
<input class="w-full px-3 py-2 border rounded-md text-sm" placeholder="09:00" bind:value={newData.match_time_start} />
</div>
<div>
<label class="text-xs text-muted-foreground">Конец (HH:MM)</label>
<input class="w-full px-3 py-2 border rounded-md text-sm" placeholder="18:00" bind:value={newData.match_time_end} />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="text-xs text-muted-foreground">Действие</label>
<select class="w-full px-3 py-2 border rounded-md text-sm bg-background" bind:value={newData.action_type}>
<option value="auto">Авто (scoring)</option>
<option value="node">Нода</option>
<option value="pool">Пул нод</option>
</select>
</div>
{#if newData.action_type === 'node'}
<div>
<label class="text-xs text-muted-foreground">Node ID</label>
<select class="w-full px-3 py-2 border rounded-md text-sm bg-background font-mono" bind:value={newData.action_node_id}>
<option value="">— выбрать —</option>
{#each nodes as n}
<option value={n.node_id}>{n.node_id}</option>
{/each}
</select>
</div>
{:else if newData.action_type === 'pool'}
<div class="col-span-2">
<label class="text-xs text-muted-foreground">Node IDs (через запятую)</label>
<input class="w-full px-3 py-2 border rounded-md text-sm font-mono" placeholder="asterisk01,asterisk02" bind:value={newData.pool_ids_str} />
{#if nodes.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each nodes as n}
<label class="flex items-center gap-1 text-xs px-2 py-1 border rounded cursor-pointer hover:bg-secondary">
<input type="checkbox" checked={splitPoolIDs(newData.pool_ids_str).includes(n.node_id)}
on:change={(e) => {
const current = splitPoolIDs(newData.pool_ids_str);
const target = e.target as HTMLInputElement;
newData.pool_ids_str = target.checked
? [...current, n.node_id].join(',')
: current.filter(id => id !== n.node_id).join(',');
}} />
{n.node_id}
</label>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<div>
<label class="text-xs text-muted-foreground">Описание</label>
<input class="w-full px-3 py-2 border rounded-md text-sm" placeholder="Для чего это правило" bind:value={newData.description} />
</div>
<!-- Regex test -->
<div class="border-t pt-3">
<p class="text-xs font-medium text-muted-foreground mb-2">Проверка regex</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
{#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}
<div>
<input class="w-full px-3 py-2 border rounded-md text-sm" placeholder={`Тест ${item.label}`} value={item.val} on:input={(e) => item.bind(e.currentTarget.value)} />
{#if item.val && item.pattern}
<span class="text-xs {matches(item.val, item.pattern) ? 'text-green-600' : 'text-red-600'}">
{matches(item.val, item.pattern) ? '✓ match' : '✗ no match'}
</span>
{/if}
</div>
{/each}
</div>
</div>
<div class="flex gap-2 pt-2">
<button on:click={saveCreate} class="flex items-center gap-1 px-3 py-2 bg-primary text-primary-foreground rounded-md text-sm"><Save class="h-3 w-3" /> Создать</button>
<button on:click={cancelEdit} class="flex items-center gap-1 px-3 py-2 border rounded-md text-sm"><X class="h-3 w-3" /> Отмена</button>
</div>
</div>
{/if}
{#if loading}
<p class="text-muted-foreground text-sm">Загрузка...</p>
{:else if rules.length === 0}
<p class="text-muted-foreground text-sm py-8 text-center">
Нет правил маршрутизации. Звонки идут через стандартный скоринг.
</p>
{:else}
<div class="border rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-secondary">
<tr>
<th class="px-3 py-3 text-left font-medium w-16">#</th>
<th class="px-3 py-3 text-left font-medium">Название</th>
<th class="px-3 py-3 text-left font-medium">Условия</th>
<th class="px-3 py-3 text-left font-medium">Действие</th>
<th class="px-3 py-3 text-left font-medium">Время</th>
<th class="px-3 py-3 text-center font-medium w-12">Hits</th>
<th class="px-3 py-3 text-center font-medium w-14">Вкл</th>
<th class="px-3 py-3 text-right font-medium w-32">Действия</th>
</tr>
</thead>
<tbody>
{#each rules as r, idx (r.id)}
{#if editing === r.id}
<tr class="border-t bg-yellow-50">
<td class="px-3 py-2 text-center font-mono text-xs">{r.priority}</td>
<td class="px-3 py-2"><input class="w-full px-2 py-1 border rounded text-sm" bind:value={editData.name} /></td>
<td class="px-3 py-2"><div class="space-y-1">
<input class="w-full px-2 py-1 border rounded text-xs font-mono" placeholder="CID" bind:value={editData.match_caller_id} />
<input class="w-full px-2 py-1 border rounded text-xs font-mono" placeholder="Dest" bind:value={editData.match_destination} />
<input class="w-full px-2 py-1 border rounded text-xs font-mono" placeholder="Trunk" bind:value={editData.match_ingress_trunk} />
</div></td>
<td class="px-3 py-2">
<select class="w-full px-2 py-1 border rounded text-xs mb-1" bind:value={editData.action_type}>
<option value="auto">Авто</option>
<option value="node">Нода</option>
<option value="pool">Пул</option>
</select>
{#if editData.action_type === 'node'}
<input class="w-full px-2 py-1 border rounded text-xs font-mono" placeholder="node_id" bind:value={editData.action_node_id} />
{:else if editData.action_type === 'pool'}
<input class="w-full px-2 py-1 border rounded text-xs font-mono" placeholder="id1,id2" bind:value={editData.pool_ids_str} />
{/if}
</td>
<td class="px-3 py-2"><div class="space-y-1">
<input class="w-full px-2 py-1 border rounded text-xs" placeholder="дни" bind:value={editData.match_days} />
<input class="w-full px-2 py-1 border rounded text-xs" placeholder="с" bind:value={editData.match_time_start} />
<input class="w-full px-2 py-1 border rounded text-xs" placeholder="по" bind:value={editData.match_time_end} />
</div></td>
<td class="px-3 py-2 text-center text-xs">{r.hits ?? 0}</td>
<td class="px-3 py-2 text-center"><label class="flex justify-center"><input type="checkbox" checked={editData.enabled} /></label></td>
<td class="px-3 py-2 text-right">
<div class="flex gap-1 justify-end">
<button on:click={saveEdit} class="px-2 py-1 bg-primary text-primary-foreground rounded text-xs"><Save class="h-3 w-3 inline" /></button>
<button on:click={cancelEdit} class="px-2 py-1 border rounded text-xs"><X class="h-3 w-3 inline" /></button>
</div>
</td>
</tr>
{:else}
<tr class="border-t hover:bg-secondary/50">
<td class="px-3 py-3 text-center">
<div class="flex items-center justify-center gap-0.5">
<button on:click={() => moveRule(idx, -1)} disabled={idx === 0} class="text-muted-foreground hover:text-primary disabled:opacity-20"><ArrowUp class="h-3 w-3" /></button>
<span class="w-5 font-mono text-xs">{r.priority}</span>
<button on:click={() => moveRule(idx, 1)} disabled={idx === rules.length - 1} class="text-muted-foreground hover:text-primary disabled:opacity-20"><ArrowDown class="h-3 w-3" /></button>
</div>
</td>
<td class="px-3 py-3 font-medium">{r.name}</td>
<td class="px-3 py-3 font-mono text-xs text-muted-foreground max-w-56 break-all">{matchSummary(r)}</td>
<td class="px-3 py-3">
<span class="px-2 py-1 rounded text-xs font-medium {actionBadge(r.action_type)}">
{actionLabel(r.action_type)}
{#if r.action_type === 'node' && r.action_node_id}
<span class="ml-1 font-mono opacity-70">{r.action_node_id}</span>
{:else if r.action_type === 'pool' && r.action_pool_ids?.length}
<span class="ml-1 font-mono opacity-70">{r.action_pool_ids.length} нод</span>
{/if}
</span>
</td>
<td class="px-3 py-3 text-xs text-muted-foreground">{timeDisplay(r)}</td>
<td class="px-3 py-3 text-center font-mono text-xs">{r.hits ?? 0}</td>
<td class="px-3 py-3 text-center">
<button on:click={() => toggleRule(r.id, !r.enabled)} class="text-xs">{r.enabled ? '🟢' : '🔴'}</button>
</td>
<td class="px-3 py-3 text-right">
<button on:click={() => startEdit(r)} class="px-2 py-1 text-xs text-primary hover:underline mr-1">Изменить</button>
<button on:click={() => removeRule(r.id)} class="px-2 py-1 text-xs text-destructive hover:underline"><Trash2 class="h-3 w-3 inline" /></button>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>