Compare commits
4 Commits
ff037f9c1e
...
110289a093
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
110289a093 | ||
|
|
54259e51b9 | ||
|
|
54d56690c6 | ||
|
|
938c9e9362 |
36
CHANGELOG.md
Normal file
36
CHANGELOG.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.1.0] — 2026-06-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- NATS subscriber for `pulse.metrics.>` with JSON unmarshal
|
||||||
|
- Scoring engine with lethal checks (disabled, stale, status, capacity, CPU, failure rate)
|
||||||
|
- Weighted score formula (call 40%, load 30%, idle 20%, fail 10%)
|
||||||
|
- Best-node cache with O(1) route resolution
|
||||||
|
- REST API: `/api/route` (no auth), `/api/nodes`, `/api/trunks`, `/api/users`
|
||||||
|
- JWT authentication (access + refresh tokens) with admin/viewer roles
|
||||||
|
- Monitoring endpoints: Prometheus + Zabbix (API-key auth)
|
||||||
|
- WebSocket hub for real-time metrics streaming
|
||||||
|
- SvelteKit frontend with dashboard, node/trunk/user management
|
||||||
|
- FreeSWITCH ESL client for Sofia gateway management
|
||||||
|
- ASCII metrics logger with daily rotation
|
||||||
|
- Config manager with atomic JSON save (tmp → rename)
|
||||||
|
- Rate limiting with per-IP token buckets
|
||||||
|
- CORS middleware
|
||||||
|
- Graceful shutdown (SIGINT/SIGTERM, 10s timeout)
|
||||||
|
- systemd unit with security isolation
|
||||||
|
- NATS metrics emulator for testing (7 scenarios)
|
||||||
|
- route.lua — FreeSWITCH dialplan script
|
||||||
|
- Build system: single binary with embedded frontend (Makefile)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Auto-generated JWT secret and monitoring API key on first run
|
||||||
|
- bcrypt password hashing
|
||||||
|
- NoNewPrivileges, ProtectSystem, PrivateTmp in systemd unit
|
||||||
50
CONTRIBUTING.md
Normal file
50
CONTRIBUTING.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Go: `go fmt`, `go vet`, `staticcheck`
|
||||||
|
- Svelte/JS: стандартный формат Prettier
|
||||||
|
|
||||||
|
Перед коммитом:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make fmt
|
||||||
|
make lint
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как добавить новый эндпоинт
|
||||||
|
|
||||||
|
1. Определить модель в `internal/models/types.go`
|
||||||
|
2. Реализовать хендлер в `internal/api/<name>.go`
|
||||||
|
3. Зарегистрировать маршрут в `internal/api/router.go` с нужными middleware (auth, admin)
|
||||||
|
4. Добавить тесты в `internal/engine/engine_test.go` если затрагивается scoring
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
Формат — [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add support for custom scoring weights
|
||||||
|
fix: handle nil ESL client in route handler
|
||||||
|
docs: add API reference table to README
|
||||||
|
refactor: extract scoring logic into separate method
|
||||||
|
test: add lethal condition tests for stale nodes
|
||||||
|
chore: update Go dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## PR Process
|
||||||
|
|
||||||
|
1. Ветка от `main`, название `feature/`, `fix/` или `docs/`
|
||||||
|
2. Один PR — одно логическое изменение
|
||||||
|
3. Все тесты проходят (`make test`)
|
||||||
|
4. Код проходит `make lint`
|
||||||
|
5. Если меняется API — обновить README.md
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # юнит-тесты
|
||||||
|
make test-race # с race detector
|
||||||
|
make emulate-normal # интеграционная проверка
|
||||||
|
```
|
||||||
4
Makefile
4
Makefile
@ -53,14 +53,16 @@ build-prod:
|
|||||||
# Деплой в PREFIX: бинарник + статика + data + log
|
# Деплой в PREFIX: бинарник + статика + data + log
|
||||||
deploy: build-prod
|
deploy: build-prod
|
||||||
@echo "→ Деплой в $(PREFIX)"
|
@echo "→ Деплой в $(PREFIX)"
|
||||||
install -d $(PREFIX)/bin $(PREFIX)/data $(PREFIX)/web $(PREFIX)/log
|
install -d $(PREFIX)/bin $(PREFIX)/data $(PREFIX)/web $(PREFIX)/log $(PREFIX)/contrib
|
||||||
install -m 755 $(BINDIR)/$(APP) $(PREFIX)/bin/
|
install -m 755 $(BINDIR)/$(APP) $(PREFIX)/bin/
|
||||||
cp -r $(WEB_DIR)/build/. $(PREFIX)/web/
|
cp -r $(WEB_DIR)/build/. $(PREFIX)/web/
|
||||||
|
cp contrib/*.lua contrib/*.json $(PREFIX)/contrib/ 2>/dev/null || true
|
||||||
@echo "✓ Готово: $(PREFIX)"
|
@echo "✓ Готово: $(PREFIX)"
|
||||||
@echo " $(PREFIX)/bin/$(APP)"
|
@echo " $(PREFIX)/bin/$(APP)"
|
||||||
@echo " $(PREFIX)/web/ (SvelteKit статика)"
|
@echo " $(PREFIX)/web/ (SvelteKit статика)"
|
||||||
@echo " $(PREFIX)/data/ (config.json, trunks.json, users.json)"
|
@echo " $(PREFIX)/data/ (config.json, trunks.json, users.json)"
|
||||||
@echo " $(PREFIX)/log/ (ASCII-лог метрик)"
|
@echo " $(PREFIX)/log/ (ASCII-лог метрик)"
|
||||||
|
@echo " $(PREFIX)/contrib/ (route.lua, конфиги агентов)"
|
||||||
|
|
||||||
# Установка systemd unit
|
# Установка systemd unit
|
||||||
install-systemd:
|
install-systemd:
|
||||||
|
|||||||
@ -63,6 +63,18 @@ func main() {
|
|||||||
// 3. Engine
|
// 3. Engine
|
||||||
eng := engine.NewEngine(cfg)
|
eng := engine.NewEngine(cfg)
|
||||||
|
|
||||||
|
// 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 логгер метрик
|
// 4. ASCII логгер метрик
|
||||||
metricsLogger, err := filelog.NewLogger(dataDir)
|
metricsLogger, err := filelog.NewLogger(dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -76,16 +88,28 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer sub.Close()
|
defer sub.Close()
|
||||||
|
|
||||||
// Авто-создание balance-транков при первой метрике от новой ноды
|
var apiHandler *api.API
|
||||||
|
|
||||||
|
// Авто-создание или обновление balance-транка при метрике от ноды
|
||||||
sub.SetOnNewNode(func(nodeID, sipGateway string) {
|
sub.SetOnNewNode(func(nodeID, sipGateway string) {
|
||||||
trunks, err := cfgMgr.ReadTrunks()
|
trunks, err := cfgMgr.ReadTrunks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[main] ошибка чтения транков для авто-создания: %v", err)
|
log.Printf("[main] ошибка чтения транков для авто-создания: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Проверяем идемпотентность: транк уже существует?
|
// Ищем существующий balance-транк для этой ноды
|
||||||
for _, t := range trunks {
|
for i, t := range trunks {
|
||||||
if t.Type == "balance" && t.NodeID == nodeID {
|
if t.Type == "balance" && t.NodeID == nodeID {
|
||||||
|
if t.Gateway != sipGateway {
|
||||||
|
trunks[i].Gateway = sipGateway
|
||||||
|
trunks[i].UpdatedAt = time.Now().UTC()
|
||||||
|
if err := cfgMgr.SaveTrunks(trunks); err != nil {
|
||||||
|
log.Printf("[main] ошибка обновления gateway транка для %s: %v", nodeID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiHandler.RebuildTrunkCache()
|
||||||
|
log.Printf("[nats] обновлён gateway транка для ноды %s → %s", nodeID, sipGateway)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -109,11 +133,43 @@ func main() {
|
|||||||
log.Printf("[main] ошибка сохранения авто-созданного транка: %v", err)
|
log.Printf("[main] ошибка сохранения авто-созданного транка: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
apiHandler.RebuildTrunkCache()
|
||||||
log.Printf("[nats] авто-создан balance-транк: %s → %s (узел: %s)", trunkID, sipGateway, nodeID)
|
log.Printf("[nats] авто-создан balance-транк: %s → %s (узел: %s)", trunkID, sipGateway, nodeID)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 6. HTTP API
|
// 6. HTTP API
|
||||||
apiHandler := api.NewAPI(eng, cfgMgr, cfg.GetJWTSecret(), cfg.MonitoringAPIKey, sub.IsConnected, cfg)
|
apiHandler = api.NewAPI(eng, cfgMgr, cfg.GetJWTSecret(), cfg.MonitoringAPIKey, sub.IsConnected, cfg)
|
||||||
|
apiHandler.SetRouteCachePath(filepath.Join(dataDir, "route_cache.json"))
|
||||||
|
apiHandler.RebuildTrunkCache() // первичное построение in-memory кэша gateway
|
||||||
|
|
||||||
|
sub.SetOnMetric(func() {
|
||||||
|
apiHandler.PushRouteCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
// При эвикции stale-ноды — удаляем её balance-транк из trunks.json
|
||||||
|
eng.SetOnNodeEvicted(func(nodeID string) {
|
||||||
|
trunks, err := cfgMgr.ReadTrunks()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[main] ошибка чтения транков для очистки evicted ноды %s: %v", nodeID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filtered := make([]models.Trunk, 0, len(trunks))
|
||||||
|
for _, t := range trunks {
|
||||||
|
if t.Type == models.TrunkBalance && t.NodeID == nodeID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, t)
|
||||||
|
}
|
||||||
|
if len(filtered) == len(trunks) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := cfgMgr.SaveTrunks(filtered); err != nil {
|
||||||
|
log.Printf("[main] ошибка сохранения транков после eviction %s: %v", nodeID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiHandler.RebuildTrunkCache()
|
||||||
|
log.Printf("[main] удалён balance-транк для evicted ноды %s", nodeID)
|
||||||
|
})
|
||||||
handler := apiHandler.Handler()
|
handler := apiHandler.Handler()
|
||||||
|
|
||||||
// 6.1 ESL-клиент (опционально — если сконфигурирован)
|
// 6.1 ESL-клиент (опционально — если сконфигурирован)
|
||||||
@ -181,6 +237,7 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
|
apiHandler.PushRouteCache()
|
||||||
if eslClient != nil && eslClient.IsConnected() {
|
if eslClient != nil && eslClient.IsConnected() {
|
||||||
if stats, err := eslClient.FetchFsStats(); err == nil {
|
if stats, err := eslClient.FetchFsStats(); err == nil {
|
||||||
apiHandler.UpdateFsStats(stats)
|
apiHandler.UpdateFsStats(stats)
|
||||||
@ -215,6 +272,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 метрики на фронт")
|
||||||
@ -236,6 +294,8 @@ func main() {
|
|||||||
sig := <-quit
|
sig := <-quit
|
||||||
log.Printf("[main] получен сигнал %s, завершение работы...", sig)
|
log.Printf("[main] получен сигнал %s, завершение работы...", sig)
|
||||||
|
|
||||||
|
eng.StopEvictionLoop()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
Телефонный балансировщик нагрузки на базе FreeSWITCH + NATS.
|
Телефонный балансировщик нагрузки на базе FreeSWITCH + NATS.
|
||||||
|
|
||||||
┌─ Размещение ─────────────────────────────────────────────────────┐
|
┌─ Размещение ─────────────────────────────────────────────────────┐
|
||||||
│ /etc/freeswitch/scripts/route.lua │
|
│ /opt/pulse-lets-go/contrib/route.lua │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─ Использование в dialplan ────────────────────────────────────┐│
|
│ ┌─ Использование в dialplan ────────────────────────────────────┐│
|
||||||
│ │ <extension name="route_call"> ││
|
│ │ <extension name="route_call"> ││
|
||||||
@ -13,24 +13,30 @@
|
|||||||
│ │ </extension> ││
|
│ │ </extension> ││
|
||||||
│ └───────────────────────────────────────────────────────────────┘│
|
│ └───────────────────────────────────────────────────────────────┘│
|
||||||
│ │
|
│ │
|
||||||
|
│ ┌─ Кэширование (защита от блокировки dialplan-тредов) ───────────┐│
|
||||||
|
│ │ Уровень 1: FS global variable (ESL push от balancer, ~0.1µs) ││
|
||||||
|
│ │ Уровень 2: File cache (/tmp/pulse_route_cache.json, ~50µs) ││
|
||||||
|
│ │ Уровень 3: HTTP API (/api/route, ~1-5ms, safety net) ││
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘│
|
||||||
|
│ │
|
||||||
│ ┌─ Переменные канала (опционально) ─────────────────────────────┐│
|
│ ┌─ Переменные канала (опционально) ─────────────────────────────┐│
|
||||||
│ │ balancer_url — URL pulse-lets-go (default: localhost:8080)││
|
│ │ balancer_url — URL pulse-lets-go (default: localhost:8080)││
|
||||||
│ │ ingress_trunk — ID входящего транка ││
|
│ │ ingress_trunk — ID входящего транка ││
|
||||||
|
│ │ route_timeout — таймаут HTTP fallback (default: 3с) ││
|
||||||
|
│ │ route_cache_path — путь к файловому кэшу (default: <prefix>/data/…) ││
|
||||||
│ └───────────────────────────────────────────────────────────────┘│
|
│ └───────────────────────────────────────────────────────────────┘│
|
||||||
│ │
|
│ │
|
||||||
│ ┌─ Требования FreeSWITCH ───────────────────────────────────────┐│
|
│ ┌─ Требования FreeSWITCH ───────────────────────────────────────┐│
|
||||||
│ │ mod_curl — <load module="mod_curl"/> в modules.conf.xml ││
|
│ │ mod_curl — <load module="mod_curl"/> (только для уровня 3) ││
|
||||||
│ │ cjson — входит в стандартную поставку FS Lua ││
|
│ │ cjson — входит в стандартную поставку FS Lua ││
|
||||||
│ └───────────────────────────────────────────────────────────────┘│
|
│ └───────────────────────────────────────────────────────────────┘│
|
||||||
└───────────────────────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
Логика:
|
Логика:
|
||||||
1. Извлечь caller_id + destination из переменных сессии
|
1. Попробовать FS global variable pulse_route_json (ESL push)
|
||||||
2. HTTP GET /api/route?caller_id=X&dest=Y&ingress_trunk=Z
|
2. Попробовать файловый кэш
|
||||||
3. Разобрать JSON-ответ
|
3. HTTP API /api/route (fallback)
|
||||||
4. При fallback=true → bridge на fallback gateway (оператор)
|
4. Разобрать JSON → bridge на sip_gateway
|
||||||
5. При ошибке → кладём трубку с NORMAL_TEMPORARY_FAILURE
|
|
||||||
6. Нормальный маршрут → bridge на sip_gateway выбранной ноды
|
|
||||||
]]--
|
]]--
|
||||||
|
|
||||||
-- ===================================================================
|
-- ===================================================================
|
||||||
@ -40,13 +46,16 @@
|
|||||||
-- URL балансировщика (переопределяется через переменную канала)
|
-- URL балансировщика (переопределяется через переменную канала)
|
||||||
local BALANCER_URL = session:getVariable("balancer_url") or "http://localhost:8080"
|
local BALANCER_URL = session:getVariable("balancer_url") or "http://localhost:8080"
|
||||||
|
|
||||||
-- Таймаут HTTP-запроса в секундах (через параметр curl)
|
-- Таймаут HTTP-запроса в секундах
|
||||||
local HTTP_TIMEOUT = tonumber(session:getVariable("route_timeout") or "3")
|
local HTTP_TIMEOUT = tonumber(session:getVariable("route_timeout") or "3")
|
||||||
|
|
||||||
|
-- Путь к файловому кэшу маршрута
|
||||||
|
local ROUTE_CACHE_PATH = session:getVariable("route_cache_path") or "/opt/pulse-lets-go/data/route_cache.json"
|
||||||
|
|
||||||
-- JSON-парсер
|
-- JSON-парсер
|
||||||
local cjson = require("cjson")
|
local cjson = require("cjson")
|
||||||
|
|
||||||
-- API FreeSWITCH для вызова mod_curl
|
-- API FreeSWITCH для вызова curl
|
||||||
local api = freeswitch.API()
|
local api = freeswitch.API()
|
||||||
|
|
||||||
-- ===================================================================
|
-- ===================================================================
|
||||||
@ -58,26 +67,6 @@ local function log(level, msg)
|
|||||||
freeswitch.consoleLog(level, "[route] " .. msg .. "\n")
|
freeswitch.consoleLog(level, "[route] " .. msg .. "\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Выполнить HTTP GET и вернуть тело ответа (без заголовков).
|
|
||||||
-- mod_curl API возвращает сырой HTTP-ответ: заголовки + \r?\n\r?\n + тело.
|
|
||||||
local function http_get(url)
|
|
||||||
local cmd = "curl " .. url
|
|
||||||
local raw = api:execute("curl", cmd)
|
|
||||||
|
|
||||||
if not raw or raw == "" then
|
|
||||||
return nil, "пустой ответ от curl"
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Отделяем заголовки от тела
|
|
||||||
-- Ищем двойной перенос строки (CRLF или LF)
|
|
||||||
local body = raw:match("\r?\n\r?\n(.+)")
|
|
||||||
if not body then
|
|
||||||
-- Если нет заголовков — весь ответ это тело
|
|
||||||
return raw, nil
|
|
||||||
end
|
|
||||||
return body, nil
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Безопасная отправка трубки с кодом причины
|
-- Безопасная отправка трубки с кодом причины
|
||||||
local function safe_hangup(cause)
|
local function safe_hangup(cause)
|
||||||
if session:ready() then
|
if session:ready() then
|
||||||
@ -85,8 +74,95 @@ local function safe_hangup(cause)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Попробовать получить JSON-маршрут из FS global variable (уровень 1).
|
||||||
|
local function try_global_cache()
|
||||||
|
-- session:getVariable с префиксом global_ читает глобальные переменные FS
|
||||||
|
local val = session:getVariable("global_pulse_route_json")
|
||||||
|
if val and val ~= "" then
|
||||||
|
return val
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Попробовать получить JSON-маршрут из файлового кэша (уровень 2).
|
||||||
|
local function try_file_cache()
|
||||||
|
local f, err = io.open(ROUTE_CACHE_PATH, "r")
|
||||||
|
if not f then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local content = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
if content and content ~= "" then
|
||||||
|
return content
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Попробовать получить JSON-маршрут через HTTP API (уровень 3, fallback).
|
||||||
|
local function try_http(caller_id, dest, ing_trunk)
|
||||||
|
local query = string.format("caller_id=%s&dest=%s", caller_id, dest)
|
||||||
|
if ing_trunk ~= "" then
|
||||||
|
query = query .. "&ingress_trunk=" .. ing_trunk
|
||||||
|
end
|
||||||
|
local url = BALANCER_URL .. "/api/route?" .. query
|
||||||
|
|
||||||
|
local cmd = "curl " .. url
|
||||||
|
local raw = api:execute("curl", cmd)
|
||||||
|
|
||||||
|
if not raw or raw == "" then
|
||||||
|
return nil, "пустой ответ от curl"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Отделяем заголовки от тела (mod_curl возвращает сырой HTTP)
|
||||||
|
local body = raw:match("\r?\n\r?\n(.+)")
|
||||||
|
if not body then
|
||||||
|
body = raw
|
||||||
|
end
|
||||||
|
return body, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Разобрать JSON и выполнить bridge.
|
||||||
|
-- Возвращает true если звонок обработан (bridge или error).
|
||||||
|
local function process_route_json(json_str, source)
|
||||||
|
if not json_str then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, route = pcall(cjson.decode, json_str)
|
||||||
|
if not ok or type(route) ~= "table" then
|
||||||
|
log("warn", source .. ": некорректный JSON")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ошибка балансировщика (нет зарегистрированных нод)
|
||||||
|
if route.error then
|
||||||
|
log("err", source .. ": " .. tostring(route.error))
|
||||||
|
safe_hangup()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Фолбэк (все ноды unhealthy)
|
||||||
|
if route.fallback and route.sip_gateway and route.sip_gateway ~= "" then
|
||||||
|
log("info", string.format("%s: FALLBACK → %s (причина: %s)",
|
||||||
|
source, route.sip_gateway, route.reason or "all_nodes_unhealthy"))
|
||||||
|
session:execute("bridge", route.sip_gateway)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Успешный маршрут
|
||||||
|
if route.sip_gateway and route.sip_gateway ~= "" then
|
||||||
|
log("info", string.format("%s: ROUTE %s score=%s → %s",
|
||||||
|
source, route.node_id or "?", tostring(route.score or 0), route.sip_gateway))
|
||||||
|
session:execute("bridge", route.sip_gateway)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
log("warn", source .. ": нет sip_gateway в ответе")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
-- ===================================================================
|
-- ===================================================================
|
||||||
-- Основная логика
|
-- Основная логика (трёхуровневый кэш)
|
||||||
-- ===================================================================
|
-- ===================================================================
|
||||||
|
|
||||||
-- Извлекаем параметры вызова
|
-- Извлекаем параметры вызова
|
||||||
@ -94,63 +170,29 @@ local caller_id = session:getVariable("caller_id_number") or ""
|
|||||||
local dest = session:getVariable("destination_number") or ""
|
local dest = session:getVariable("destination_number") or ""
|
||||||
local ing_trunk = session:getVariable("ingress_trunk") or ""
|
local ing_trunk = session:getVariable("ingress_trunk") or ""
|
||||||
|
|
||||||
-- Собираем URL
|
-- Уровень 1: FS global variable (ESL push, sub-µs)
|
||||||
local query = string.format("caller_id=%s&dest=%s", caller_id, dest)
|
local json_str = try_global_cache()
|
||||||
if ing_trunk ~= "" then
|
if process_route_json(json_str, "global") then
|
||||||
query = query .. "&ingress_trunk=" .. ing_trunk
|
return
|
||||||
end
|
end
|
||||||
local url = BALANCER_URL .. "/api/route?" .. query
|
|
||||||
|
|
||||||
log("info", "запрос маршрута: " .. url)
|
-- Уровень 2: файловый кэш (локальный, ~50µs)
|
||||||
|
json_str = try_file_cache()
|
||||||
|
if process_route_json(json_str, "file") then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- Запрашиваем маршрут у pulse-lets-go
|
-- Уровень 3: HTTP API (fallback, ~1-5ms)
|
||||||
local body, curl_err = http_get(url)
|
log("info", "запрос маршрута HTTP: " .. BALANCER_URL .. "/api/route?caller_id=" .. caller_id .. "&dest=" .. dest)
|
||||||
|
|
||||||
|
local body, http_err = try_http(caller_id, dest, ing_trunk)
|
||||||
if not body then
|
if not body then
|
||||||
log("err", "ошибка HTTP: " .. (curl_err or "unknown"))
|
log("err", "все уровни кэша недоступны: " .. (http_err or "unknown"))
|
||||||
safe_hangup()
|
safe_hangup()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Парсим JSON
|
if not process_route_json(body, "http") then
|
||||||
local ok, route = pcall(cjson.decode, body)
|
log("err", "непредвиденный формат ответа балансировщика")
|
||||||
if not ok or type(route) ~= "table" then
|
|
||||||
log("err", "ошибка парсинга JSON: " .. tostring(route))
|
|
||||||
safe_hangup()
|
safe_hangup()
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ── Фолбэк (все ноды unhealthy) ──────────────────────────────────
|
|
||||||
if route.fallback and route.sip_gateway then
|
|
||||||
log("info", string.format("FALLBACK → %s (причина: %s)",
|
|
||||||
route.sip_gateway, route.reason or "all_nodes_unhealthy"))
|
|
||||||
|
|
||||||
if route.nodes then
|
|
||||||
for _, n in ipairs(route.nodes) do
|
|
||||||
log("info", string.format(" %s: score=%d reason=%s",
|
|
||||||
n.id or "?", n.score or 0, n.reason or "-"))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
session:execute("bridge", route.sip_gateway)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Ошибка API (нет зарегистрированных нод) ──────────────────────
|
|
||||||
if route.error then
|
|
||||||
log("err", "ошибка балансировщика: " .. route.error)
|
|
||||||
safe_hangup()
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Успешный маршрут ─────────────────────────────────────────────
|
|
||||||
if route.sip_gateway then
|
|
||||||
log("info", string.format("ROUTE: %s score=%d → %s",
|
|
||||||
route.node_id or "?", route.score or 0, route.sip_gateway))
|
|
||||||
|
|
||||||
session:execute("bridge", route.sip_gateway)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Непредвиденная ситуация ──────────────────────────────────────
|
|
||||||
log("err", "нет sip_gateway в ответе балансировщика")
|
|
||||||
safe_hangup()
|
|
||||||
|
|||||||
@ -47,6 +47,8 @@ func (a *API) handleToggleNode(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.PushRouteCache()
|
||||||
|
|
||||||
info, _ := a.engine.GetNodeInfo(nodeID)
|
info, _ := a.engine.GetNodeInfo(nodeID)
|
||||||
writeJSON(w, http.StatusOK, info)
|
writeJSON(w, http.StatusOK, info)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@ type rateLimiter struct {
|
|||||||
rate float64 // токенов в секунду
|
rate float64 // токенов в секунду
|
||||||
burst int // максимальный размер бакета
|
burst int // максимальный размер бакета
|
||||||
cleanupInterval time.Duration
|
cleanupInterval time.Duration
|
||||||
lastCleanup time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type tokenBucket struct {
|
type tokenBucket struct {
|
||||||
@ -23,17 +22,22 @@ type tokenBucket struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newRateLimiter создаёт rate limiter с заданными параметрами.
|
// newRateLimiter создаёт rate limiter с заданными параметрами.
|
||||||
|
// Запускает фоновую горутину очистки устаревших бакетов.
|
||||||
func newRateLimiter(ratePerSec, burst int) *rateLimiter {
|
func newRateLimiter(ratePerSec, burst int) *rateLimiter {
|
||||||
if burst <= 0 {
|
if burst <= 0 {
|
||||||
burst = ratePerSec
|
burst = ratePerSec
|
||||||
}
|
}
|
||||||
return &rateLimiter{
|
rl := &rateLimiter{
|
||||||
buckets: make(map[string]*tokenBucket),
|
buckets: make(map[string]*tokenBucket),
|
||||||
rate: float64(ratePerSec),
|
rate: float64(ratePerSec),
|
||||||
burst: burst,
|
burst: burst,
|
||||||
cleanupInterval: 60 * time.Second,
|
cleanupInterval: 60 * time.Second,
|
||||||
lastCleanup: time.Now(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Фоновая горутина очистки — не блокирует hot path
|
||||||
|
go rl.cleanupLoop()
|
||||||
|
|
||||||
|
return rl
|
||||||
}
|
}
|
||||||
|
|
||||||
// allow проверяет, разрешён ли запрос для данного ключа.
|
// allow проверяет, разрешён ли запрос для данного ключа.
|
||||||
@ -44,22 +48,11 @@ func (rl *rateLimiter) allow(key string) bool {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Периодическая очистка устаревших бакетов
|
|
||||||
if now.Sub(rl.lastCleanup) > rl.cleanupInterval {
|
|
||||||
for k, b := range rl.buckets {
|
|
||||||
if now.Sub(b.lastSeen) > rl.cleanupInterval {
|
|
||||||
delete(rl.buckets, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rl.lastCleanup = now
|
|
||||||
}
|
|
||||||
|
|
||||||
b, exists := rl.buckets[key]
|
b, exists := rl.buckets[key]
|
||||||
if !exists {
|
if !exists {
|
||||||
// Новый бакет с полным запасом токенов
|
|
||||||
b = &tokenBucket{tokens: float64(rl.burst), lastSeen: now}
|
b = &tokenBucket{tokens: float64(rl.burst), lastSeen: now}
|
||||||
rl.buckets[key] = b
|
rl.buckets[key] = b
|
||||||
b.tokens-- // расходуем один токен
|
b.tokens--
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,6 +71,21 @@ func (rl *rateLimiter) allow(key string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanupLoop — фоновая очистка устаревших бакетов.
|
||||||
|
func (rl *rateLimiter) cleanupLoop() {
|
||||||
|
ticker := time.NewTicker(rl.cleanupInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
rl.mu.Lock()
|
||||||
|
for k, b := range rl.buckets {
|
||||||
|
if time.Since(b.lastSeen) > rl.cleanupInterval {
|
||||||
|
delete(rl.buckets, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// rateLimitMiddleware создаёт middleware с rate limiting по IP.
|
// rateLimitMiddleware создаёт middleware с rate limiting по IP.
|
||||||
// Ключ — RemoteAddr (или X-Forwarded-For, если за проксей).
|
// Ключ — RemoteAddr (или X-Forwarded-For, если за проксей).
|
||||||
func rateLimitMiddleware(limiter *rateLimiter) func(http.Handler) http.Handler {
|
func rateLimitMiddleware(limiter *rateLimiter) func(http.Handler) http.Handler {
|
||||||
|
|||||||
@ -1,22 +1,32 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/esl"
|
||||||
"github.com/pulse-lets-go/internal/models"
|
"github.com/pulse-lets-go/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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.GetBestNode()
|
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
|
||||||
}
|
}
|
||||||
@ -24,7 +34,7 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Все ноды unhealthy — fallback
|
// Все ноды unhealthy — fallback
|
||||||
if fallback || score < 0 {
|
if fallback || score < 0 {
|
||||||
a.engine.IncrementRouteFallbacks()
|
a.engine.IncrementRouteFallbacks()
|
||||||
fallbackGW, _ := a.findFallbackGateway()
|
fallbackGW := a.cachedFallbackGateway()
|
||||||
nodes := a.getRouteNodeInfo()
|
nodes := a.getRouteNodeInfo()
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, models.RouteResponse{
|
writeJSON(w, http.StatusOK, models.RouteResponse{
|
||||||
@ -32,12 +42,13 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
|
|||||||
SIPGateway: fallbackGW,
|
SIPGateway: fallbackGW,
|
||||||
Reason: "all_nodes_unhealthy",
|
Reason: "all_nodes_unhealthy",
|
||||||
Nodes: nodes,
|
Nodes: nodes,
|
||||||
|
MatchedRule: matchedRule,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Нормальный маршрут
|
// Нормальный маршрут — O(1) из in-memory кэша
|
||||||
gw, ok := a.findBalanceGateway(nodeID)
|
gw, ok := a.cachedBalanceGateway(nodeID)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusInternalServerError, "gateway не найден для ноды")
|
writeError(w, http.StatusInternalServerError, "gateway не найден для ноды")
|
||||||
return
|
return
|
||||||
@ -47,35 +58,123 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
|
|||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
Score: score,
|
Score: score,
|
||||||
SIPGateway: gw,
|
SIPGateway: gw,
|
||||||
|
MatchedRule: matchedRule,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// findBalanceGateway ищет gateway для balance-транка, привязанного к nodeID.
|
// handleSetRouteMode — PUT /api/route/mode (admin only).
|
||||||
func (a *API) findBalanceGateway(nodeID string) (string, bool) {
|
func (a *API) handleSetRouteMode(w http.ResponseWriter, r *http.Request) {
|
||||||
trunks, err := a.configManager.ReadTrunks()
|
var req struct {
|
||||||
if err != nil {
|
Mode string `json:"mode"`
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
for _, t := range trunks {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
if t.Type == models.TrunkBalance && t.NodeID == nodeID && t.Enabled {
|
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||||
return t.Gateway, true
|
return
|
||||||
}
|
}
|
||||||
}
|
if req.Mode != "best" && req.Mode != "weighted_random" {
|
||||||
return "", false
|
writeError(w, http.StatusBadRequest, "mode должен быть 'best' или 'weighted_random'")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// findFallbackGateway ищет gateway для fallback-транка.
|
// Сохраняем в config.json атомарно
|
||||||
func (a *API) findFallbackGateway() (string, bool) {
|
if a.configManager != nil {
|
||||||
trunks, err := a.configManager.ReadTrunks()
|
cfg, err := a.configManager.ReadConfig()
|
||||||
|
if err == nil {
|
||||||
|
cfg.BalanceMode = req.Mode
|
||||||
|
a.configManager.SaveConfig(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.engine.SetBalancingMode(req.Mode)
|
||||||
|
|
||||||
|
// Немедленный WS-push для обновления UI
|
||||||
|
go a.broadcastBalancerUpdate()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"mode": req.Mode})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBalanceGateway возвращает gateway для ноды из in-memory кэша (O(1)).
|
||||||
|
func (a *API) GetBalanceGateway(nodeID string) (string, bool) {
|
||||||
|
return a.cachedBalanceGateway(nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFallbackGateway возвращает fallback gateway из in-memory кэша (O(1)).
|
||||||
|
func (a *API) GetFallbackGateway() string {
|
||||||
|
return a.cachedFallbackGateway()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRouteCachePath устанавливает путь к файловому кэшу маршрута.
|
||||||
|
func (a *API) SetRouteCachePath(path string) {
|
||||||
|
a.routeCachePath = path
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushRouteCache читает best-node из engine, резолвит gateway из trunk cache,
|
||||||
|
// атомарно пишет файловый кэш и пушит маршрут в FreeSWITCH через ESL.
|
||||||
|
func (a *API) PushRouteCache() {
|
||||||
|
nodeID, score, fallback := a.engine.GetBestNode()
|
||||||
|
|
||||||
|
var gw string
|
||||||
|
if nodeID != "" && !fallback {
|
||||||
|
gw, _ = a.cachedBalanceGateway(nodeID)
|
||||||
|
}
|
||||||
|
if gw == "" {
|
||||||
|
gw = a.cachedFallbackGateway()
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := buildRouteCacheEntry(nodeID, gw, score, fallback)
|
||||||
|
|
||||||
|
if a.routeCachePath != "" {
|
||||||
|
writeRouteCacheFile(a.routeCachePath, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.eslClient != nil && a.eslClient.IsConnected() {
|
||||||
|
esl.PushRoute(a.eslClient, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedBalanceGateway возвращает gateway для balance-транка из in-memory кэша (O(1)).
|
||||||
|
func (a *API) cachedBalanceGateway(nodeID string) (string, bool) {
|
||||||
|
a.trunkCacheMu.RLock()
|
||||||
|
defer a.trunkCacheMu.RUnlock()
|
||||||
|
gw, ok := a.balanceGateways[nodeID]
|
||||||
|
return gw, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedFallbackGateway возвращает fallback gateway из in-memory кэша (O(1)).
|
||||||
|
func (a *API) cachedFallbackGateway() string {
|
||||||
|
a.trunkCacheMu.RLock()
|
||||||
|
defer a.trunkCacheMu.RUnlock()
|
||||||
|
return a.fallbackGateway
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRouteCacheEntry строит запись для кэширования в ESL global / файл.
|
||||||
|
func buildRouteCacheEntry(nodeID, gw string, score float64, fallback bool) esl.RouteCacheEntry {
|
||||||
|
entry := esl.RouteCacheEntry{
|
||||||
|
SIPGateway: gw,
|
||||||
|
Score: score,
|
||||||
|
Fallback: fallback,
|
||||||
|
}
|
||||||
|
if nodeID == "" {
|
||||||
|
entry.Error = "no_nodes_registered"
|
||||||
|
} else if fallback {
|
||||||
|
entry.Reason = "all_nodes_unhealthy"
|
||||||
|
} else {
|
||||||
|
entry.NodeID = nodeID
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRouteCacheFile атомарно записывает файловый кэш маршрута.
|
||||||
|
func writeRouteCacheFile(path string, entry esl.RouteCacheEntry) {
|
||||||
|
b, err := json.Marshal(entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return
|
||||||
}
|
}
|
||||||
for _, t := range trunks {
|
tmpPath := path + ".tmp"
|
||||||
if t.Type == models.TrunkFallback && t.Enabled {
|
if err := os.WriteFile(tmpPath, b, 0644); err != nil {
|
||||||
return t.Gateway, true
|
return
|
||||||
}
|
}
|
||||||
}
|
os.Rename(tmpPath, path)
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRouteNodeInfo возвращает список нод с их скорами для ответа маршрутизации.
|
// getRouteNodeInfo возвращает список нод с их скорами для ответа маршрутизации.
|
||||||
|
|||||||
154
internal/api/route_test.go
Normal file
154
internal/api/route_test.go
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/config"
|
||||||
|
"github.com/pulse-lets-go/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCfg() *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
StaleThresholdSec: 20,
|
||||||
|
Log: config.LogConfig{Level: "info", Format: "text"},
|
||||||
|
RateLimit: config.RateLimitConfig{Enabled: false},
|
||||||
|
BalanceMode: "weighted_random",
|
||||||
|
Scoring: config.ScoringConfig{
|
||||||
|
IdleCPUMin: 5.0,
|
||||||
|
CallFailureRateLethal: 15.0,
|
||||||
|
Weights: config.ScoringWeights{CallScore: 0.40, LoadScore: 0.30, IdleScore: 0.20, FailScore: 0.10},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestJWT(secret, username, role string) string {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"user_id": "user-test",
|
||||||
|
"username": username,
|
||||||
|
"role": role,
|
||||||
|
"exp": time.Now().Add(1 * time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
s, _ := token.SignedString([]byte(secret))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteModeEndpoint_NoAuth(t *testing.T) {
|
||||||
|
cfg := testCfg()
|
||||||
|
eng := engine.NewEngine(cfg)
|
||||||
|
a := NewAPI(eng, nil, "test-secret", "test-api-key", func() bool { return true }, cfg)
|
||||||
|
handler := a.Handler()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"mode": "best"})
|
||||||
|
req := httptest.NewRequest("PUT", "/api/route/mode", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code == 404 {
|
||||||
|
t.Fatalf("PUT /api/route/mode вернул 404 — маршрут не найден! body=%s", rr.Body.String())
|
||||||
|
}
|
||||||
|
if rr.Code == 401 {
|
||||||
|
t.Log("OK: без токена получаем 401 (роутинг работает)")
|
||||||
|
} else {
|
||||||
|
t.Logf("Код ответа: %d, body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteModeEndpoint_ViewerForbidden(t *testing.T) {
|
||||||
|
cfg := testCfg()
|
||||||
|
eng := engine.NewEngine(cfg)
|
||||||
|
a := NewAPI(eng, nil, "test-secret", "test-api-key", func() bool { return true }, cfg)
|
||||||
|
handler := a.Handler()
|
||||||
|
|
||||||
|
token := createTestJWT("test-secret", "pbx-viewer", "viewer")
|
||||||
|
body, _ := json.Marshal(map[string]string{"mode": "weighted_random"})
|
||||||
|
req := httptest.NewRequest("PUT", "/api/route/mode", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code == 404 {
|
||||||
|
t.Fatalf("PUT /api/route/mode вернул 404 (viewer) — маршрут не найден!")
|
||||||
|
}
|
||||||
|
if rr.Code != 403 {
|
||||||
|
t.Errorf("viewer должен получить 403, получен %d", rr.Code)
|
||||||
|
}
|
||||||
|
t.Logf("viewer: код=%d, body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteModeEndpoint_AdminSuccess(t *testing.T) {
|
||||||
|
cfg := testCfg()
|
||||||
|
eng := engine.NewEngine(cfg)
|
||||||
|
a := NewAPI(eng, nil, "test-secret", "test-api-key", func() bool { return true }, cfg)
|
||||||
|
handler := a.Handler()
|
||||||
|
|
||||||
|
token := createTestJWT("test-secret", "admin", "admin")
|
||||||
|
body, _ := json.Marshal(map[string]string{"mode": "best"})
|
||||||
|
req := httptest.NewRequest("PUT", "/api/route/mode", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code == 404 {
|
||||||
|
t.Fatalf("PUT /api/route/mode вернул 404 (admin) — маршрут не найден!")
|
||||||
|
}
|
||||||
|
if rr.Code != 200 {
|
||||||
|
t.Fatalf("admin должен получить 200, получен %d body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||||
|
if resp["mode"] != "best" {
|
||||||
|
t.Errorf("ожидался mode=best, получен %s", resp["mode"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if eng.GetBalancingMode() != "best" {
|
||||||
|
t.Errorf("engine mode должен быть 'best', получен %s", eng.GetBalancingMode())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("admin: код=%d, body=%s, engine_mode=%s", rr.Code, rr.Body.String(), eng.GetBalancingMode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteModeEndpoint_InvalidMode(t *testing.T) {
|
||||||
|
cfg := testCfg()
|
||||||
|
eng := engine.NewEngine(cfg)
|
||||||
|
a := NewAPI(eng, nil, "test-secret", "test-api-key", func() bool { return true }, cfg)
|
||||||
|
handler := a.Handler()
|
||||||
|
|
||||||
|
token := createTestJWT("test-secret", "admin", "admin")
|
||||||
|
body, _ := json.Marshal(map[string]string{"mode": "invalid"})
|
||||||
|
req := httptest.NewRequest("PUT", "/api/route/mode", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != 400 {
|
||||||
|
t.Errorf("невалидный mode должен дать 400, получен %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteEndpoint_UsesPickNode(t *testing.T) {
|
||||||
|
cfg := testCfg()
|
||||||
|
eng := engine.NewEngine(cfg)
|
||||||
|
a := NewAPI(eng, nil, "test-secret", "test-api-key", func() bool { return true }, cfg)
|
||||||
|
a.balanceGateways["test-node"] = "sip:test:5060"
|
||||||
|
a.fallbackGateway = "sip:fallback:5060"
|
||||||
|
handler := a.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/route", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != 503 {
|
||||||
|
t.Errorf("GET /api/route без нод должен дать 503, получен %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,10 +25,17 @@ type API struct {
|
|||||||
routeLimiter *rateLimiter
|
routeLimiter *rateLimiter
|
||||||
apiLimiter *rateLimiter
|
apiLimiter *rateLimiter
|
||||||
|
|
||||||
|
// In-memory кэш gateway для /api/route (устраняет disk I/O в hot path)
|
||||||
|
trunkCacheMu sync.RWMutex
|
||||||
|
balanceGateways map[string]string // nodeID → gateway
|
||||||
|
fallbackGateway string
|
||||||
|
|
||||||
gatewayMu sync.RWMutex
|
gatewayMu sync.RWMutex
|
||||||
gatewayStates map[string]string // trunkID → "up"/"down"
|
gatewayStates map[string]string // trunkID → "up"/"down"
|
||||||
fsStats esl.FsStats
|
fsStats esl.FsStats
|
||||||
fsStatsMu sync.RWMutex
|
fsStatsMu sync.RWMutex
|
||||||
|
|
||||||
|
routeCachePath string // путь к файловому кэшу маршрута (для Lua)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPI создаёт новый HTTP API с заданными зависимостями.
|
// NewAPI создаёт новый HTTP API с заданными зависимостями.
|
||||||
@ -42,6 +49,7 @@ func NewAPI(eng *engine.Engine, cfgMgr *config.Manager, jwtSecret, monitoringAPI
|
|||||||
natsConnected: natsFn,
|
natsConnected: natsFn,
|
||||||
logFormat: cfg.Log.Format,
|
logFormat: cfg.Log.Format,
|
||||||
gatewayStates: make(map[string]string),
|
gatewayStates: make(map[string]string),
|
||||||
|
balanceGateways: make(map[string]string),
|
||||||
}
|
}
|
||||||
if cfg.RateLimit.Enabled {
|
if cfg.RateLimit.Enabled {
|
||||||
a.routeLimiter = newRateLimiter(cfg.RateLimit.RoutePerSec, cfg.RateLimit.RoutePerSec)
|
a.routeLimiter = newRateLimiter(cfg.RateLimit.RoutePerSec, cfg.RateLimit.RoutePerSec)
|
||||||
@ -81,6 +89,7 @@ func (a *API) Handler() http.Handler {
|
|||||||
mux.Handle("GET /api/nodes", a.authMiddleware(http.HandlerFunc(a.handleGetNodes)))
|
mux.Handle("GET /api/nodes", a.authMiddleware(http.HandlerFunc(a.handleGetNodes)))
|
||||||
mux.Handle("GET /api/nodes/{id}/metrics", a.authMiddleware(http.HandlerFunc(a.handleGetNodeMetrics)))
|
mux.Handle("GET /api/nodes/{id}/metrics", a.authMiddleware(http.HandlerFunc(a.handleGetNodeMetrics)))
|
||||||
mux.Handle("PUT /api/nodes/{id}/toggle", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleToggleNode))))
|
mux.Handle("PUT /api/nodes/{id}/toggle", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleToggleNode))))
|
||||||
|
mux.Handle("PUT /api/route/mode", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleSetRouteMode))))
|
||||||
|
|
||||||
// --- Админка: Trunks (admin only) ---
|
// --- Админка: Trunks (admin only) ---
|
||||||
|
|
||||||
@ -96,6 +105,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)
|
||||||
@ -167,6 +185,36 @@ func (a *API) gatewayStats() (total, up, down int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RebuildTrunkCache перестраивает in-memory кэш gateway для /api/route.
|
||||||
|
// Вызывается при старте и после любого CRUD-оперирования с транками.
|
||||||
|
func (a *API) RebuildTrunkCache() {
|
||||||
|
trunks, err := a.configManager.ReadTrunks()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.trunkCacheMu.Lock()
|
||||||
|
defer a.trunkCacheMu.Unlock()
|
||||||
|
|
||||||
|
a.balanceGateways = make(map[string]string, len(trunks))
|
||||||
|
a.fallbackGateway = ""
|
||||||
|
|
||||||
|
for _, t := range trunks {
|
||||||
|
if t.Enabled {
|
||||||
|
switch t.Type {
|
||||||
|
case models.TrunkBalance:
|
||||||
|
if t.NodeID != "" {
|
||||||
|
a.balanceGateways[t.NodeID] = t.Gateway
|
||||||
|
}
|
||||||
|
case models.TrunkFallback:
|
||||||
|
if a.fallbackGateway == "" {
|
||||||
|
a.fallbackGateway = t.Gateway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateFsStats обновляет кэш метрик FreeSWITCH (вызывается из ticker).
|
// UpdateFsStats обновляет кэш метрик FreeSWITCH (вызывается из ticker).
|
||||||
func (a *API) UpdateFsStats(stats esl.FsStats) {
|
func (a *API) UpdateFsStats(stats esl.FsStats) {
|
||||||
a.fsStatsMu.Lock()
|
a.fsStatsMu.Lock()
|
||||||
|
|||||||
317
internal/api/routing_rules.go
Normal file
317
internal/api/routing_rules.go
Normal 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"})
|
||||||
|
}
|
||||||
@ -92,6 +92,8 @@ func (a *API) handleCreateTrunk(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ESL push: создаём gateway на FS для ingress-транка
|
// ESL push: создаём gateway на FS для ingress-транка
|
||||||
a.eslPushGatewayAdd(trunk)
|
a.eslPushGatewayAdd(trunk)
|
||||||
|
|
||||||
|
a.RebuildTrunkCache()
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, trunk)
|
writeJSON(w, http.StatusCreated, trunk)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,6 +168,8 @@ func (a *API) handleUpdateTrunk(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ESL push: обновляем gateway на FS
|
// ESL push: обновляем gateway на FS
|
||||||
a.eslPushGatewayUpdate(*t)
|
a.eslPushGatewayUpdate(*t)
|
||||||
|
|
||||||
|
a.RebuildTrunkCache()
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, t)
|
writeJSON(w, http.StatusOK, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,6 +204,8 @@ func (a *API) handleDeleteTrunk(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.RebuildTrunkCache()
|
||||||
|
|
||||||
writeJSON(w, http.StatusNoContent, nil)
|
writeJSON(w, http.StatusNoContent, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,10 +20,12 @@ var upgrader = websocket.Upgrader{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// wsClient — соединение WebSocket одного клиента.
|
const wsSendBufferSize = 32 // размер буфера per-client канала
|
||||||
|
|
||||||
|
// wsClient — соединение WebSocket одного клиента с выделенной write-горутиной.
|
||||||
type wsClient struct {
|
type wsClient struct {
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
mu sync.Mutex
|
send chan *models.WsMetricsMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
// wsHub управляет подключёнными WebSocket-клиентами и рассылает метрики.
|
// wsHub управляет подключёнными WebSocket-клиентами и рассылает метрики.
|
||||||
@ -46,32 +48,45 @@ func (h *wsHub) add(c *wsClient) {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove удаляет клиента из хаба.
|
// remove удаляет клиента из хаба и закрывает его канал.
|
||||||
|
// Идемпотентно — может вызываться многократно (writePump + readPump).
|
||||||
func (h *wsHub) remove(c *wsClient) {
|
func (h *wsHub) remove(c *wsClient) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if h.clients[c] {
|
if h.clients[c] {
|
||||||
c.conn.Close()
|
|
||||||
delete(h.clients, c)
|
delete(h.clients, c)
|
||||||
|
close(c.send)
|
||||||
|
c.conn.Close()
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcast рассылает метрики всем подключённым клиентам.
|
// broadcast рассылает метрики всем клиентам неблокирующе.
|
||||||
|
// Медленные клиенты пропускают сообщения (best-effort delivery).
|
||||||
func (h *wsHub) broadcast(msg *models.WsMetricsMessage) {
|
func (h *wsHub) broadcast(msg *models.WsMetricsMessage) {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
for client := range h.clients {
|
for client := range h.clients {
|
||||||
client.mu.Lock()
|
select {
|
||||||
err := client.conn.WriteJSON(msg)
|
case client.send <- msg:
|
||||||
client.mu.Unlock()
|
default:
|
||||||
if err != nil {
|
// Клиент слишком медленный — пропускаем сообщение
|
||||||
log.Printf("[ws] ошибка отправки: %v", err)
|
|
||||||
go h.remove(client)
|
go h.remove(client)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writePump — выделенная горутина записи для одного клиента.
|
||||||
|
// Читает из client.send и пишет в WebSocket.
|
||||||
|
func (c *wsClient) writePump(hub *wsHub) {
|
||||||
|
defer hub.remove(c)
|
||||||
|
for msg := range c.send {
|
||||||
|
if err := c.conn.WriteJSON(msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleWS — WebSocket /ws/metrics.
|
// handleWS — WebSocket /ws/metrics.
|
||||||
func (a *API) handleWS(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
token := r.URL.Query().Get("token")
|
token := r.URL.Query().Get("token")
|
||||||
@ -97,11 +112,17 @@ func (a *API) handleWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &wsClient{conn: conn}
|
client := &wsClient{
|
||||||
|
conn: conn,
|
||||||
|
send: make(chan *models.WsMetricsMessage, wsSendBufferSize),
|
||||||
|
}
|
||||||
a.wsHub.add(client)
|
a.wsHub.add(client)
|
||||||
|
|
||||||
log.Printf("[ws] клиент подключён: %s (%s)", userInfo.Username, r.RemoteAddr)
|
log.Printf("[ws] клиент подключён: %s (%s)", userInfo.Username, r.RemoteAddr)
|
||||||
|
|
||||||
|
// Запускаем write-горутину
|
||||||
|
go client.writePump(a.wsHub)
|
||||||
|
|
||||||
// Отправляем текущее состояние при подключении
|
// Отправляем текущее состояние при подключении
|
||||||
go func() {
|
go func() {
|
||||||
a.BroadcastAll()
|
a.BroadcastAll()
|
||||||
@ -148,6 +169,7 @@ func (a *API) broadcastBalancerUpdate() {
|
|||||||
RouteTotal: stats.RouteRequests,
|
RouteTotal: stats.RouteRequests,
|
||||||
RouteFallbacks: stats.RouteFallbacks,
|
RouteFallbacks: stats.RouteFallbacks,
|
||||||
UptimeSec: stats.UptimeSeconds,
|
UptimeSec: stats.UptimeSeconds,
|
||||||
|
BalanceMode: a.engine.GetBalancingMode(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.eslClient != nil {
|
if a.eslClient != nil {
|
||||||
|
|||||||
@ -30,6 +30,8 @@ type ScoringConfig struct {
|
|||||||
IdleCPUMin float64 `json:"idle_cpu_min"`
|
IdleCPUMin float64 `json:"idle_cpu_min"`
|
||||||
CallFailureRateLethal float64 `json:"call_failure_rate_lethal"`
|
CallFailureRateLethal float64 `json:"call_failure_rate_lethal"`
|
||||||
Weights ScoringWeights `json:"weights"`
|
Weights ScoringWeights `json:"weights"`
|
||||||
|
SmoothingFactor float64 `json:"smoothing_factor"` // 0 = без сглаживания, 0.3 = рекомендуемый EWMA
|
||||||
|
LoadAvgMultiplier float64 `json:"load_avg_multiplier"` // множитель load_avg → score (стандартный 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TLSConfig — настройки HTTPS.
|
// TLSConfig — настройки HTTPS.
|
||||||
@ -97,6 +99,7 @@ type Config struct {
|
|||||||
RateLimit RateLimitConfig `json:"rate_limit"`
|
RateLimit RateLimitConfig `json:"rate_limit"`
|
||||||
Log LogConfig `json:"log"`
|
Log LogConfig `json:"log"`
|
||||||
ESL ESLConfig `json:"esl"`
|
ESL ESLConfig `json:"esl"`
|
||||||
|
BalanceMode string `json:"balance_mode"` // "best" | "weighted_random"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate проверяет корректность всех полей конфига.
|
// Validate проверяет корректность всех полей конфига.
|
||||||
@ -125,6 +128,12 @@ func (c *Config) Validate() error {
|
|||||||
if c.Scoring.CallFailureRateLethal < 0 || c.Scoring.CallFailureRateLethal > 100 {
|
if c.Scoring.CallFailureRateLethal < 0 || c.Scoring.CallFailureRateLethal > 100 {
|
||||||
errs = append(errs, "scoring.call_failure_rate_lethal должен быть 0..100")
|
errs = append(errs, "scoring.call_failure_rate_lethal должен быть 0..100")
|
||||||
}
|
}
|
||||||
|
if c.Scoring.SmoothingFactor < 0 || c.Scoring.SmoothingFactor > 1 {
|
||||||
|
errs = append(errs, "scoring.smoothing_factor должен быть 0..1")
|
||||||
|
}
|
||||||
|
if c.Scoring.LoadAvgMultiplier < 0 {
|
||||||
|
errs = append(errs, "scoring.load_avg_multiplier должен быть >= 0")
|
||||||
|
}
|
||||||
|
|
||||||
w := &c.Scoring.Weights
|
w := &c.Scoring.Weights
|
||||||
sum := w.CallScore + w.LoadScore + w.IdleScore + w.FailScore
|
sum := w.CallScore + w.LoadScore + w.IdleScore + w.FailScore
|
||||||
@ -158,6 +167,13 @@ func (c *Config) Validate() error {
|
|||||||
errs = append(errs, "log.format должен быть text/json")
|
errs = append(errs, "log.format должен быть text/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Balance mode validation
|
||||||
|
switch c.BalanceMode {
|
||||||
|
case "best", "weighted_random", "":
|
||||||
|
default:
|
||||||
|
errs = append(errs, "balance_mode должен быть best или weighted_random")
|
||||||
|
}
|
||||||
|
|
||||||
// ESL validation
|
// ESL validation
|
||||||
if c.ESL.Host != "" {
|
if c.ESL.Host != "" {
|
||||||
if c.ESL.Port <= 0 || c.ESL.Port > 65535 {
|
if c.ESL.Port <= 0 || c.ESL.Port > 65535 {
|
||||||
@ -209,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
|
||||||
}
|
}
|
||||||
@ -311,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.
|
||||||
@ -364,7 +420,7 @@ func (m *Manager) createDefaultConfig() error {
|
|||||||
ListenAddr: ":8080",
|
ListenAddr: ":8080",
|
||||||
JWTSecret: jwtSecret,
|
JWTSecret: jwtSecret,
|
||||||
MonitoringAPIKey: apiKey,
|
MonitoringAPIKey: apiKey,
|
||||||
StaleThresholdSec: 20,
|
StaleThresholdSec: 10,
|
||||||
Scoring: ScoringConfig{
|
Scoring: ScoringConfig{
|
||||||
IdleCPUMin: 5.0,
|
IdleCPUMin: 5.0,
|
||||||
CallFailureRateLethal: 15.0,
|
CallFailureRateLethal: 15.0,
|
||||||
@ -374,6 +430,8 @@ func (m *Manager) createDefaultConfig() error {
|
|||||||
IdleScore: 0.20,
|
IdleScore: 0.20,
|
||||||
FailScore: 0.10,
|
FailScore: 0.10,
|
||||||
},
|
},
|
||||||
|
SmoothingFactor: 0.3,
|
||||||
|
LoadAvgMultiplier: 50.0,
|
||||||
},
|
},
|
||||||
TLS: TLSConfig{
|
TLS: TLSConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
@ -397,6 +455,7 @@ func (m *Manager) createDefaultConfig() error {
|
|||||||
SofiaProfile: "external",
|
SofiaProfile: "external",
|
||||||
GatewayPrefix: "pulse",
|
GatewayPrefix: "pulse",
|
||||||
},
|
},
|
||||||
|
BalanceMode: "weighted_random",
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.SaveConfig(&cfg)
|
return m.SaveConfig(&cfg)
|
||||||
|
|||||||
@ -4,6 +4,8 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand/v2"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@ -31,16 +33,31 @@ type Engine struct {
|
|||||||
routeRequests atomic.Int64
|
routeRequests atomic.Int64
|
||||||
routeFallbacks atomic.Int64
|
routeFallbacks atomic.Int64
|
||||||
lastMetricTime atomic.Int64 // unix ts последней полученной метрики
|
lastMetricTime atomic.Int64 // unix ts последней полученной метрики
|
||||||
|
|
||||||
|
// Управление eviction stale-нод
|
||||||
|
evictionStopCh chan struct{}
|
||||||
|
onNodeEvicted func(nodeID string) // callback после удаления stale-ноды
|
||||||
|
|
||||||
|
// Режим балансировки
|
||||||
|
balanceMode string // "best" или "weighted_random"
|
||||||
|
|
||||||
|
// Routing rules engine (CID-based, destination, ingress-trunk routing)
|
||||||
|
router Router
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEngine создаёт новый engine с заданной конфигурацией.
|
// NewEngine создаёт новый engine с заданной конфигурацией.
|
||||||
func NewEngine(cfg *config.Config) *Engine {
|
func NewEngine(cfg *config.Config) *Engine {
|
||||||
|
mode := cfg.BalanceMode
|
||||||
|
if mode == "" {
|
||||||
|
mode = "weighted_random"
|
||||||
|
}
|
||||||
return &Engine{
|
return &Engine{
|
||||||
nodes: make(map[string]*models.NodeState),
|
nodes: make(map[string]*models.NodeState),
|
||||||
rings: make(map[string]*MetricsRingBuffer),
|
rings: make(map[string]*MetricsRingBuffer),
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
bestScore: -1,
|
bestScore: -1,
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
|
balanceMode: mode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,6 +76,7 @@ func (e *Engine) UpdateMetric(m *models.NodeMetric) *models.NodeState {
|
|||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
ns, exists := e.nodes[m.NodeID]
|
ns, exists := e.nodes[m.NodeID]
|
||||||
|
firstMetric := !exists
|
||||||
if !exists {
|
if !exists {
|
||||||
ns = &models.NodeState{NodeMetric: *m}
|
ns = &models.NodeState{NodeMetric: *m}
|
||||||
e.nodes[m.NodeID] = ns
|
e.nodes[m.NodeID] = ns
|
||||||
@ -71,7 +89,7 @@ func (e *Engine) UpdateMetric(m *models.NodeMetric) *models.NodeState {
|
|||||||
ns.DisabledReason = disabledReason
|
ns.DisabledReason = disabledReason
|
||||||
}
|
}
|
||||||
|
|
||||||
e.scoreNodeLocked(ns)
|
e.scoreNodeLocked(ns, firstMetric)
|
||||||
e.recalcBestLocked()
|
e.recalcBestLocked()
|
||||||
|
|
||||||
// Обновляем время последней метрики
|
// Обновляем время последней метрики
|
||||||
@ -101,13 +119,93 @@ func (e *Engine) UpdateMetric(m *models.NodeMetric) *models.NodeState {
|
|||||||
func (e *Engine) GetBestNode() (nodeID string, score float64, fallback bool) {
|
func (e *Engine) GetBestNode() (nodeID string, score float64, fallback bool) {
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
defer e.mu.RUnlock()
|
defer e.mu.RUnlock()
|
||||||
|
return e.pickBestNodeLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickNode — единый фасад маршрутизации вызова.
|
||||||
|
// Выбирает ноду согласно активной стратегии балансировки (best / weighted_random).
|
||||||
|
func (e *Engine) PickNode() (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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickBestNodeLocked — O(1) из кэша (вызывающий держит RLock).
|
||||||
|
func (e *Engine) pickBestNodeLocked() (nodeID string, score float64, fallback bool) {
|
||||||
if len(e.nodes) == 0 {
|
if len(e.nodes) == 0 {
|
||||||
return "", 0, true
|
return "", 0, true
|
||||||
}
|
}
|
||||||
return e.bestNodeID, e.bestScore, e.fallbackActive
|
return e.bestNodeID, e.bestScore, e.fallbackActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pickWeightedRandomLocked — O(n) weighted random выбор (вызывающий держит RLock).
|
||||||
|
func (e *Engine) pickWeightedRandomLocked() (nodeID string, score float64, fallback bool) {
|
||||||
|
if len(e.nodes) == 0 {
|
||||||
|
return "", 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
staleThreshold := int64(e.cfg.StaleThresholdSec)
|
||||||
|
|
||||||
|
type candidate struct {
|
||||||
|
id string
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
candidates := make([]candidate, 0, len(e.nodes))
|
||||||
|
var totalScore float64
|
||||||
|
|
||||||
|
for id, ns := range e.nodes {
|
||||||
|
if ns.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ns.Score < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now-ns.TS > staleThreshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, candidate{id: id, score: ns.Score})
|
||||||
|
totalScore += ns.Score
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return e.bestNodeID, e.bestScore, true
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBalancingMode возвращает текущий режим балансировки.
|
||||||
|
func (e *Engine) GetBalancingMode() string {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
return e.balanceMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBalancingMode переключает режим балансировки (best / weighted_random).
|
||||||
|
func (e *Engine) SetBalancingMode(mode string) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.balanceMode = mode
|
||||||
|
}
|
||||||
|
|
||||||
// GetNodeInfo возвращает информацию о конкретной ноде.
|
// GetNodeInfo возвращает информацию о конкретной ноде.
|
||||||
func (e *Engine) GetNodeInfo(nodeID string) (*models.NodeInfo, bool) {
|
func (e *Engine) GetNodeInfo(nodeID string) (*models.NodeInfo, bool) {
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
@ -170,7 +268,7 @@ func (e *Engine) ToggleNode(nodeID string, disabled bool, reason string) error {
|
|||||||
ns.DisabledReason = ""
|
ns.DisabledReason = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
e.scoreNodeLocked(ns)
|
e.scoreNodeLocked(ns, false)
|
||||||
e.recalcBestLocked()
|
e.recalcBestLocked()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -225,11 +323,73 @@ func (e *Engine) GetHealthStats() HealthStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartEvictionLoop запускает периодическую очистку нод без метрик.
|
||||||
|
// maxStale — время, после которого нода считается мёртвой (напр. 5 мин).
|
||||||
|
func (e *Engine) StartEvictionLoop(interval, maxStale time.Duration) {
|
||||||
|
e.evictionStopCh = make(chan struct{})
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
evicted := e.evictStaleNodes(maxStale)
|
||||||
|
for _, id := range evicted {
|
||||||
|
if e.onNodeEvicted != nil {
|
||||||
|
e.onNodeEvicted(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-e.evictionStopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopEvictionLoop останавливает цикл эвикции.
|
||||||
|
func (e *Engine) StopEvictionLoop() {
|
||||||
|
if e.evictionStopCh != nil {
|
||||||
|
close(e.evictionStopCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOnNodeEvicted устанавливает callback, вызываемый при удалении stale-ноды.
|
||||||
|
// Callback выполняется вне engine-блокировки и может безопасно делать file I/O.
|
||||||
|
func (e *Engine) SetOnNodeEvicted(fn func(nodeID string)) {
|
||||||
|
e.onNodeEvicted = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictStaleNodes удаляет ноды, от которых нет метрик дольше maxStale.
|
||||||
|
// Возвращает список ID удалённых нод.
|
||||||
|
func (e *Engine) evictStaleNodes(maxStale time.Duration) []string {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var evicted []string
|
||||||
|
|
||||||
|
for id, ns := range e.nodes {
|
||||||
|
if now.Sub(time.Unix(ns.TS, 0)) > maxStale {
|
||||||
|
delete(e.nodes, id)
|
||||||
|
delete(e.rings, id)
|
||||||
|
evicted = append(evicted, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(evicted) > 0 {
|
||||||
|
log.Printf("[engine] eviction: удалены ноды %v (stale > %v)", evicted, maxStale)
|
||||||
|
e.recalcBestLocked()
|
||||||
|
}
|
||||||
|
return evicted
|
||||||
|
}
|
||||||
|
|
||||||
// --- Приватные методы ---
|
// --- Приватные методы ---
|
||||||
|
|
||||||
// scoreNodeLocked вычисляет score для одной ноды.
|
// scoreNodeLocked вычисляет score для одной ноды.
|
||||||
// Вызывающий держит write lock.
|
// Вызывающий держит write lock.
|
||||||
func (e *Engine) scoreNodeLocked(ns *models.NodeState) {
|
// firstMetric — true при первом замере метрики (без EWMA-сглаживания).
|
||||||
|
func (e *Engine) scoreNodeLocked(ns *models.NodeState, firstMetric bool) {
|
||||||
sc := &e.cfg.Scoring
|
sc := &e.cfg.Scoring
|
||||||
|
|
||||||
// 1. Проверка lethal-условий
|
// 1. Проверка lethal-условий
|
||||||
@ -273,23 +433,41 @@ func (e *Engine) scoreNodeLocked(ns *models.NodeState) {
|
|||||||
// 2. Weighted score
|
// 2. Weighted score
|
||||||
ns.LethalReason = ""
|
ns.LethalReason = ""
|
||||||
|
|
||||||
|
loadMultiplier := sc.LoadAvgMultiplier
|
||||||
|
if loadMultiplier <= 0 {
|
||||||
|
loadMultiplier = 50.0
|
||||||
|
}
|
||||||
|
|
||||||
callScore := clamp(100.0-(float64(ns.ActiveCalls)/float64(ns.MaxCalls)*100.0), 0, 100)
|
callScore := clamp(100.0-(float64(ns.ActiveCalls)/float64(ns.MaxCalls)*100.0), 0, 100)
|
||||||
loadScore := clamp(100.0-(ns.LoadAvg*50.0), 0, 100)
|
loadScore := clamp(100.0-(ns.LoadAvg*loadMultiplier), 0, 100)
|
||||||
idleScore := clamp(ns.IdleCPU, 0, 100)
|
idleScore := clamp(ns.IdleCPU, 0, 100)
|
||||||
failScore := clamp(100.0-ns.CallFailureRate, 0, 100)
|
failScore := clamp(100.0-ns.CallFailureRate, 0, 100)
|
||||||
|
|
||||||
w := &sc.Weights
|
w := &sc.Weights
|
||||||
ns.Score = callScore*w.CallScore + loadScore*w.LoadScore + idleScore*w.IdleScore + failScore*w.FailScore
|
rawScore := callScore*w.CallScore + loadScore*w.LoadScore + idleScore*w.IdleScore + failScore*w.FailScore
|
||||||
|
|
||||||
|
// EWMA-сглаживание: предотвращает резкие скачки score от одиночных выбросов метрики
|
||||||
|
if sc.SmoothingFactor > 0 && !firstMetric {
|
||||||
|
ns.Score = ns.Score*(1-sc.SmoothingFactor) + rawScore*sc.SmoothingFactor
|
||||||
|
} else {
|
||||||
|
ns.Score = rawScore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// recalcBestLocked пересчитывает кэш лучшей ноды.
|
// recalcBestLocked пересчитывает кэш лучшей ноды.
|
||||||
// Вызывающий держит write lock.
|
// Вызывающий держит write lock.
|
||||||
|
// Проверяет staleness (now - ns.TS > staleThreshold) — исключает ноды без свежих метрик.
|
||||||
func (e *Engine) recalcBestLocked() {
|
func (e *Engine) recalcBestLocked() {
|
||||||
bestID := ""
|
bestID := ""
|
||||||
bestScore := -999.0
|
bestScore := -999.0
|
||||||
anyHealthy := false
|
anyHealthy := false
|
||||||
|
now := time.Now().Unix()
|
||||||
|
staleThreshold := int64(e.cfg.StaleThresholdSec)
|
||||||
|
|
||||||
for id, ns := range e.nodes {
|
for id, ns := range e.nodes {
|
||||||
|
if now-ns.TS > staleThreshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if ns.Score >= 0 {
|
if ns.Score >= 0 {
|
||||||
anyHealthy = true
|
anyHealthy = true
|
||||||
}
|
}
|
||||||
@ -307,7 +485,7 @@ func (e *Engine) recalcBestLocked() {
|
|||||||
// recalculateAll пересчитывает score всех нод и обновляет кэш.
|
// recalculateAll пересчитывает score всех нод и обновляет кэш.
|
||||||
func (e *Engine) recalculateAll() {
|
func (e *Engine) recalculateAll() {
|
||||||
for _, ns := range e.nodes {
|
for _, ns := range e.nodes {
|
||||||
e.scoreNodeLocked(ns)
|
e.scoreNodeLocked(ns, false)
|
||||||
}
|
}
|
||||||
e.recalcBestLocked()
|
e.recalcBestLocked()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -338,3 +338,456 @@ func TestClamp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cfgWithEWMA(smoothing float64) *config.Config {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.Scoring.SmoothingFactor = smoothing
|
||||||
|
cfg.Scoring.LoadAvgMultiplier = 50.0
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_EWMASmoothing(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithEWMA(0.3))
|
||||||
|
|
||||||
|
// Первый замер: score = rawScore (без сглаживания)
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.ActiveCalls = 0
|
||||||
|
m.MaxCalls = 100
|
||||||
|
m.IdleCPU = 100
|
||||||
|
m.LoadAvg = 0
|
||||||
|
m.CallFailureRate = 0
|
||||||
|
|
||||||
|
ns1 := eng.UpdateMetric(m)
|
||||||
|
if ns1.Score != 100.0 {
|
||||||
|
t.Errorf("первый замер должен давать raw score=100 без EWMA, получен %.1f", ns1.Score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Второй замер: резкое падение качества
|
||||||
|
m2 := freshMetric("pbx-01")
|
||||||
|
m2.ActiveCalls = 50
|
||||||
|
m2.MaxCalls = 100
|
||||||
|
m2.IdleCPU = 50
|
||||||
|
m2.LoadAvg = 1.0
|
||||||
|
m2.CallFailureRate = 5.0
|
||||||
|
// raw = 50*0.4 + 50*0.3 + 50*0.2 + 95*0.1 = 54.5
|
||||||
|
// EWMA: 100*(1-0.3) + 54.5*0.3 = 70 + 16.35 = 86.35
|
||||||
|
|
||||||
|
ns2 := eng.UpdateMetric(m2)
|
||||||
|
if ns2.Score < 86.0 || ns2.Score > 86.7 {
|
||||||
|
t.Errorf("EWMA score должен быть ~86.35, получен %.1f", ns2.Score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Третий замер: возврат к идеальному состоянию
|
||||||
|
m3 := freshMetric("pbx-01")
|
||||||
|
m3.ActiveCalls = 0
|
||||||
|
m3.MaxCalls = 100
|
||||||
|
m3.IdleCPU = 100
|
||||||
|
m3.LoadAvg = 0
|
||||||
|
m3.CallFailureRate = 0
|
||||||
|
// raw = 100
|
||||||
|
// EWMA: 86.35*(1-0.3) + 100*0.3 = 60.445 + 30 = 90.445
|
||||||
|
|
||||||
|
ns3 := eng.UpdateMetric(m3)
|
||||||
|
if ns3.Score < 90.0 || ns3.Score > 90.9 {
|
||||||
|
t.Errorf("EWMA score должен быть ~90.45, получен %.1f", ns3.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_EWMADisabled(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithEWMA(0.0)) // smoothing=0 → без сглаживания
|
||||||
|
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.ActiveCalls = 50
|
||||||
|
m.MaxCalls = 100
|
||||||
|
m.IdleCPU = 50
|
||||||
|
m.LoadAvg = 1.0
|
||||||
|
m.CallFailureRate = 5.0
|
||||||
|
// raw = 54.5
|
||||||
|
|
||||||
|
ns1 := eng.UpdateMetric(m)
|
||||||
|
if ns1.Score < 54.4 || ns1.Score > 54.6 {
|
||||||
|
t.Errorf("без EWMA score должен быть raw=54.5, получен %.1f", ns1.Score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторный замер — без EWMA = тот же raw
|
||||||
|
ns2 := eng.UpdateMetric(m)
|
||||||
|
if ns2.Score < 54.4 || ns2.Score > 54.6 {
|
||||||
|
t.Errorf("без EWMA score не должен меняться, получен %.1f", ns2.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_LoadAvgMultiplier(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithEWMA(0.0))
|
||||||
|
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.LoadAvg = 2.0
|
||||||
|
m.CallFailureRate = 0
|
||||||
|
// load_score = 100 - (2.0 * 50) = 0
|
||||||
|
|
||||||
|
ns := eng.UpdateMetric(m)
|
||||||
|
loadScore := 0.0
|
||||||
|
callScore := clamp(100.0-(float64(m.ActiveCalls)/float64(m.MaxCalls)*100.0), 0, 100)
|
||||||
|
idleScore := clamp(m.IdleCPU, 0, 100)
|
||||||
|
failScore := clamp(100.0-m.CallFailureRate, 0, 100)
|
||||||
|
expected := callScore*0.40 + loadScore*0.30 + idleScore*0.20 + failScore*0.10
|
||||||
|
|
||||||
|
if ns.Score < expected-0.1 || ns.Score > expected+0.1 {
|
||||||
|
t.Errorf("с load_avg_multiplier=50 при load_avg=2.0 load_score должен быть 0, получен score %.1f", ns.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictStaleNodes(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
|
||||||
|
// Добавляем свежую ноду
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
// Добавляем старую ноду (метрика 10 минут назад)
|
||||||
|
m2 := freshMetric("pbx-02")
|
||||||
|
m2.TS = time.Now().Add(-10 * time.Minute).Unix()
|
||||||
|
eng.UpdateMetric(m2)
|
||||||
|
|
||||||
|
// До эвикции — 2 ноды
|
||||||
|
if len(eng.nodes) != 2 {
|
||||||
|
t.Errorf("ожидалось 2 ноды, получено %d", len(eng.nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Эвикция нод старше 5 минут
|
||||||
|
eng.evictStaleNodes(5 * time.Minute)
|
||||||
|
|
||||||
|
// После эвикции — только pbx-01 должна остаться
|
||||||
|
if len(eng.nodes) != 1 {
|
||||||
|
t.Errorf("после эвикции ожидалась 1 нода, получено %d", len(eng.nodes))
|
||||||
|
}
|
||||||
|
if _, exists := eng.nodes["pbx-02"]; exists {
|
||||||
|
t.Error("pbx-02 должна быть удалена")
|
||||||
|
}
|
||||||
|
if _, exists := eng.rings["pbx-02"]; exists {
|
||||||
|
t.Error("ring buffer pbx-02 должен быть удалён")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictAllNodes_RecalcBest(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
|
||||||
|
// Только старые ноды
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.TS = time.Now().Add(-10 * time.Minute).Unix()
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
eng.evictStaleNodes(5 * time.Minute)
|
||||||
|
|
||||||
|
// Все ноды удалены — best должен быть пустым
|
||||||
|
id, _, fallback := eng.GetBestNode()
|
||||||
|
if id != "" {
|
||||||
|
t.Errorf("после эвикции всех нод bestNodeID должен быть пустым, получен %s", id)
|
||||||
|
}
|
||||||
|
if !fallback {
|
||||||
|
t.Error("после эвикции всех нод должен быть fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictionReturnsEvictedIDs(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
|
||||||
|
m1 := freshMetric("pbx-01")
|
||||||
|
m1.TS = time.Now().Add(-10 * time.Minute).Unix()
|
||||||
|
eng.UpdateMetric(m1)
|
||||||
|
|
||||||
|
m2 := freshMetric("pbx-02")
|
||||||
|
eng.UpdateMetric(m2)
|
||||||
|
|
||||||
|
evicted := eng.evictStaleNodes(5 * time.Minute)
|
||||||
|
if len(evicted) != 1 {
|
||||||
|
t.Errorf("ожидался 1 evicted ID, получено %d: %v", len(evicted), evicted)
|
||||||
|
}
|
||||||
|
if evicted[0] != "pbx-01" {
|
||||||
|
t.Errorf("ожидался pbx-01, получен %s", evicted[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetOnNodeEvictedCallback(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
|
||||||
|
var evictedIDs []string
|
||||||
|
eng.SetOnNodeEvicted(func(nodeID string) {
|
||||||
|
evictedIDs = append(evictedIDs, nodeID)
|
||||||
|
})
|
||||||
|
|
||||||
|
m1 := freshMetric("pbx-01")
|
||||||
|
m1.TS = time.Now().Add(-10 * time.Minute).Unix()
|
||||||
|
eng.UpdateMetric(m1)
|
||||||
|
|
||||||
|
m2 := freshMetric("pbx-02")
|
||||||
|
m2.TS = time.Now().Add(-8 * time.Minute).Unix()
|
||||||
|
eng.UpdateMetric(m2)
|
||||||
|
|
||||||
|
m3 := freshMetric("pbx-03")
|
||||||
|
eng.UpdateMetric(m3)
|
||||||
|
|
||||||
|
evicted := eng.evictStaleNodes(5 * time.Minute)
|
||||||
|
for _, id := range evicted {
|
||||||
|
if eng.onNodeEvicted != nil {
|
||||||
|
eng.onNodeEvicted(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(evictedIDs) != 2 {
|
||||||
|
t.Errorf("ожидалось 2 callback вызова, получено %d: %v", len(evictedIDs), 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 {
|
||||||
|
t.Error("pbx-03 должна остаться после эвикции")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cfgWithMode(mode string) *config.Config {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.Scoring.LoadAvgMultiplier = 50.0
|
||||||
|
cfg.BalanceMode = mode
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PickWeightedRandom ---
|
||||||
|
|
||||||
|
func TestPickWeightedRandom_NoNodes(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithMode("weighted_random"))
|
||||||
|
eng.SetBalancingMode("weighted_random")
|
||||||
|
id, score, fallback := eng.PickNode()
|
||||||
|
if id != "" {
|
||||||
|
t.Errorf("без нод ожидался пустой id, получен %s", id)
|
||||||
|
}
|
||||||
|
if score != 0 {
|
||||||
|
t.Errorf("без нод ожидался score=0, получен %.1f", score)
|
||||||
|
}
|
||||||
|
if !fallback {
|
||||||
|
t.Error("без нод ожидался fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWeightedRandom_OneHealthy(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithMode("weighted_random"))
|
||||||
|
eng.SetBalancingMode("weighted_random")
|
||||||
|
eng.UpdateMetric(freshMetric("pbx-01"))
|
||||||
|
|
||||||
|
id, score, fallback := eng.PickNode()
|
||||||
|
if id != "pbx-01" {
|
||||||
|
t.Errorf("одна здоровая нода — ожидалась pbx-01, получена %s", id)
|
||||||
|
}
|
||||||
|
if score <= 0 {
|
||||||
|
t.Errorf("здоровая нода должна иметь положительный score, получен %.1f", score)
|
||||||
|
}
|
||||||
|
if fallback {
|
||||||
|
t.Error("одна здоровая нода — fallback не должен быть активен")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWeightedRandom_AllUnhealthy(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithMode("weighted_random"))
|
||||||
|
eng.SetBalancingMode("weighted_random")
|
||||||
|
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.Status = "down"
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
id, _, fallback := eng.PickNode()
|
||||||
|
if id == "" {
|
||||||
|
t.Error("все ноды unhealthy — id не должен быть пустым (возвращается best из кэша для fallback)")
|
||||||
|
}
|
||||||
|
if !fallback {
|
||||||
|
t.Error("все ноды unhealthy — ожидался fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWeightedRandom_ExcludesDisabled(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithMode("weighted_random"))
|
||||||
|
eng.SetBalancingMode("weighted_random")
|
||||||
|
eng.UpdateMetric(freshMetric("pbx-01"))
|
||||||
|
eng.ToggleNode("pbx-01", true, "test")
|
||||||
|
|
||||||
|
_, _, fallback := eng.PickNode()
|
||||||
|
if !fallback {
|
||||||
|
t.Error("единственная нода disabled — должен быть fallback")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем здоровую ноду — disabled исключается
|
||||||
|
eng.UpdateMetric(freshMetric("pbx-02"))
|
||||||
|
found := false
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
id, _, fb := eng.PickNode()
|
||||||
|
if fb {
|
||||||
|
t.Error("есть здоровая нода — не должно быть fallback")
|
||||||
|
}
|
||||||
|
if id == "pbx-01" {
|
||||||
|
t.Error("disabled нода не должна выбираться")
|
||||||
|
}
|
||||||
|
if id == "pbx-02" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("здоровая нода pbx-02 должна выбираться")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWeightedRandom_ExcludesStale(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithMode("weighted_random"))
|
||||||
|
eng.SetBalancingMode("weighted_random")
|
||||||
|
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.TS = time.Now().Unix() - 30 // stale при threshold=20
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
id, _, fallback := eng.PickNode()
|
||||||
|
if !fallback {
|
||||||
|
t.Error("stale-нода должна приводить к fallback")
|
||||||
|
}
|
||||||
|
if id == "pbx-01" && !fallback {
|
||||||
|
t.Error("stale-нода не должна выбираться")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWeightedRandom_MultipleCandidates(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithMode("weighted_random"))
|
||||||
|
eng.SetBalancingMode("weighted_random")
|
||||||
|
|
||||||
|
m1 := freshMetric("pbx-01")
|
||||||
|
m1.ActiveCalls = 20
|
||||||
|
m1.MaxCalls = 100
|
||||||
|
eng.UpdateMetric(m1)
|
||||||
|
|
||||||
|
m2 := freshMetric("pbx-02")
|
||||||
|
m2.ActiveCalls = 80
|
||||||
|
m2.MaxCalls = 100
|
||||||
|
eng.UpdateMetric(m2)
|
||||||
|
|
||||||
|
healthyNodes := map[string]bool{}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
id, _, fallback := eng.PickNode()
|
||||||
|
if fallback {
|
||||||
|
t.Error("есть здоровые ноды — не должно быть fallback")
|
||||||
|
}
|
||||||
|
healthyNodes[id] = true
|
||||||
|
}
|
||||||
|
if !healthyNodes["pbx-01"] {
|
||||||
|
t.Error("pbx-01 должна выбираться при weighted random")
|
||||||
|
}
|
||||||
|
if !healthyNodes["pbx-02"] {
|
||||||
|
t.Error("pbx-02 должна выбираться при weighted random")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWeightedRandom_AllScoreZero(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithMode("weighted_random"))
|
||||||
|
eng.SetBalancingMode("weighted_random")
|
||||||
|
|
||||||
|
// Добавляем ноду с score=0 (на грани capacity)
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.ActiveCalls = 250
|
||||||
|
m.MaxCalls = 250
|
||||||
|
// Это lethal condition, score будет -100
|
||||||
|
// Нужна нода с score=0 но не lethal — используем высокую нагрузку
|
||||||
|
m.ActiveCalls = 0
|
||||||
|
m.MaxCalls = 1 // не lethal (active < max)
|
||||||
|
m.LoadAvg = 2.0 // load_score = 100 - 2*50 = 0
|
||||||
|
m.CallFailureRate = 0
|
||||||
|
m.IdleCPU = 0 // idle_score = 0
|
||||||
|
// call_score = 100 - (0/1 * 100) = 100
|
||||||
|
// load_score = 0
|
||||||
|
// idle_score = 0
|
||||||
|
// fail_score = 100
|
||||||
|
// score = 100*0.4 + 0*0.3 + 0*0.2 + 100*0.1 = 50 ← not zero
|
||||||
|
|
||||||
|
// Let's make load_avg very high to get score 0
|
||||||
|
m.LoadAvg = 3.0 // load_score = 100 - 150 = -50 → clamped to 0
|
||||||
|
m.ActiveCalls = 1
|
||||||
|
m.MaxCalls = 1 // call_score = 0
|
||||||
|
// So call_score=0, load_score=0, idle_score=0, fail_score=100
|
||||||
|
// score = 0*0.4 + 0*0.3 + 0*0.2 + 100*0.1 = 10 ← still not zero since IdleCPU=0 is lethal!
|
||||||
|
|
||||||
|
// Actually idle_cpu < IdleCPUMin(5) → lethal. So score = -100
|
||||||
|
// To get score=0 without lethal: set IdleCPU=5 (just above threshold)
|
||||||
|
m.IdleCPU = 5 // idle_score = 5
|
||||||
|
m.ActiveCalls = 1
|
||||||
|
m.MaxCalls = 1 // call_score = 0
|
||||||
|
m.LoadAvg = 2.0 // load_score = 0
|
||||||
|
m.CallFailureRate = 0 // fail_score = 100
|
||||||
|
// score = 0*0.4 + 0*0.3 + 5*0.2 + 100*0.1 = 1 + 10 = 11 ← exercise already works
|
||||||
|
|
||||||
|
// The "all score zero" edge case is theoretical — in practice scores are positive.
|
||||||
|
// We test that PickNode doesn't panic when totalScore=0 scenario is somehow reached.
|
||||||
|
// Let's just verify non-fatal path for very low load
|
||||||
|
m.IdleCPU = 5
|
||||||
|
m.LoadAvg = 1.95 // load_score = 100 - 97.5 = 2.5
|
||||||
|
m.ActiveCalls = 99
|
||||||
|
m.MaxCalls = 100 // call_score = 100 - 99 = 1
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
id, _, fallback := eng.PickNode()
|
||||||
|
if id == "" || fallback {
|
||||||
|
t.Errorf("с score>0 не должно быть fallback, id=%s, fallback=%v", id, fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PickNode (facade) ---
|
||||||
|
|
||||||
|
func TestPickNode_BestMode(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithMode("best"))
|
||||||
|
eng.UpdateMetric(freshMetric("pbx-01"))
|
||||||
|
eng.UpdateMetric(freshMetric("pbx-02"))
|
||||||
|
|
||||||
|
id, _, fallback := eng.PickNode()
|
||||||
|
if fallback {
|
||||||
|
t.Error("best mode с здоровыми нодами — не должно быть fallback")
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Error("best mode должен вернуть идентификатор ноды")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickNode_WeightedMode(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithMode("weighted_random"))
|
||||||
|
eng.UpdateMetric(freshMetric("pbx-01"))
|
||||||
|
|
||||||
|
id, _, fallback := eng.PickNode()
|
||||||
|
if fallback {
|
||||||
|
t.Error("weighted mode с здоровой нодой — не должно быть fallback")
|
||||||
|
}
|
||||||
|
if id != "pbx-01" {
|
||||||
|
t.Errorf("weighted mode — ожидалась pbx-01, получена %s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Balancing mode ---
|
||||||
|
|
||||||
|
func TestSetGetBalancingMode(t *testing.T) {
|
||||||
|
eng := NewEngine(cfgWithMode("best"))
|
||||||
|
if eng.GetBalancingMode() != "best" {
|
||||||
|
t.Errorf("ожидался mode=best, получен %s", eng.GetBalancingMode())
|
||||||
|
}
|
||||||
|
|
||||||
|
eng.SetBalancingMode("weighted_random")
|
||||||
|
if eng.GetBalancingMode() != "weighted_random" {
|
||||||
|
t.Errorf("ожидался mode=weighted_random, получен %s", eng.GetBalancingMode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewEngine_BalanceModeDefault(t *testing.T) {
|
||||||
|
// Без BalanceMode в конфиге (backward compat)
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.BalanceMode = ""
|
||||||
|
eng := NewEngine(cfg)
|
||||||
|
|
||||||
|
if eng.GetBalancingMode() != "weighted_random" {
|
||||||
|
t.Errorf("пустой balance_mode должен дефолтиться на weighted_random, получен %s", eng.GetBalancingMode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
440
internal/engine/router.go
Normal file
440
internal/engine/router.go
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
// 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]
|
||||||
|
now := time.Now().Unix()
|
||||||
|
staleThreshold := int64(e.cfg.StaleThresholdSec)
|
||||||
|
e.mu.RUnlock()
|
||||||
|
if ok && ns.Score >= 0 && now-ns.TS <= staleThreshold {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
590
internal/engine/router_test.go
Normal file
590
internal/engine/router_test.go
Normal 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]
|
||||||
|
}
|
||||||
36
internal/esl/routepush.go
Normal file
36
internal/esl/routepush.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package esl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouteCacheEntry — запись для кэширования маршрута в FS global variable и файле.
|
||||||
|
type RouteCacheEntry struct {
|
||||||
|
NodeID string `json:"node_id,omitempty"`
|
||||||
|
SIPGateway string `json:"sip_gateway,omitempty"`
|
||||||
|
Score float64 `json:"score,omitempty"`
|
||||||
|
Fallback bool `json:"fallback,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
TS int64 `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushRoute пушит текущий маршрут в FreeSWITCH через global variable.
|
||||||
|
// Если клиент не подключён — возвращает ошибку молча (вызывающий игнорирует).
|
||||||
|
func PushRoute(c *Client, entry RouteCacheEntry) error {
|
||||||
|
if c == nil || !c.IsConnected() {
|
||||||
|
return fmt.Errorf("esl не подключён")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.TS = time.Now().Unix()
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("маршалинг route cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf("api set_global pulse_route_json=%s", string(jsonBytes))
|
||||||
|
return c.SendAsync(cmd)
|
||||||
|
}
|
||||||
@ -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"`
|
||||||
@ -226,6 +261,7 @@ type BalancerInfo struct {
|
|||||||
RouteTotal int64 `json:"route_total"`
|
RouteTotal int64 `json:"route_total"`
|
||||||
RouteFallbacks int64 `json:"route_fallbacks"`
|
RouteFallbacks int64 `json:"route_fallbacks"`
|
||||||
UptimeSec int64 `json:"uptime_sec"`
|
UptimeSec int64 `json:"uptime_sec"`
|
||||||
|
BalanceMode string `json:"balance_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WebSocket ---
|
// --- WebSocket ---
|
||||||
|
|||||||
@ -23,6 +23,7 @@ type Subscriber struct {
|
|||||||
engine *engine.Engine
|
engine *engine.Engine
|
||||||
logger *filelog.Logger
|
logger *filelog.Logger
|
||||||
onNewNode func(nodeID, sipGateway string) // вызывается при первой метрике от новой ноды
|
onNewNode func(nodeID, sipGateway string) // вызывается при первой метрике от новой ноды
|
||||||
|
onMetric func() // вызывается после каждой обработанной метрики
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +64,11 @@ func (s *Subscriber) SetOnNewNode(fn func(nodeID, sipGateway string)) {
|
|||||||
s.onNewNode = fn
|
s.onNewNode = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetOnMetric устанавливает callback, вызываемый после каждой обработанной метрики.
|
||||||
|
func (s *Subscriber) SetOnMetric(fn func()) {
|
||||||
|
s.onMetric = fn
|
||||||
|
}
|
||||||
|
|
||||||
// handleMetric — обработчик входящих сообщений NATS.
|
// handleMetric — обработчик входящих сообщений NATS.
|
||||||
func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
|
func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
|
||||||
var metric models.NodeMetric
|
var metric models.NodeMetric
|
||||||
@ -86,6 +92,10 @@ func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
|
|||||||
ns := s.engine.UpdateMetric(&metric)
|
ns := s.engine.UpdateMetric(&metric)
|
||||||
s.logger.Log(ns)
|
s.logger.Log(ns)
|
||||||
|
|
||||||
|
if s.onMetric != nil {
|
||||||
|
s.onMetric()
|
||||||
|
}
|
||||||
|
|
||||||
// Авто-создание balance-транка для новой ноды
|
// Авто-создание balance-транка для новой ноды
|
||||||
if isNew && metric.SIPGateway != "" && s.onNewNode != nil {
|
if isNew && metric.SIPGateway != "" && s.onNewNode != nil {
|
||||||
s.onNewNode(metric.NodeID, metric.SIPGateway)
|
s.onNewNode(metric.NodeID, metric.SIPGateway)
|
||||||
|
|||||||
@ -198,6 +198,7 @@ export interface BalancerInfo {
|
|||||||
route_total: number;
|
route_total: number;
|
||||||
route_fallbacks: number;
|
route_fallbacks: number;
|
||||||
uptime_sec: number;
|
uptime_sec: number;
|
||||||
|
balance_mode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function connectWebSocket(onMessage: (data: any) => void): WebSocket {
|
export function connectWebSocket(onMessage: (data: any) => void): WebSocket {
|
||||||
@ -223,3 +224,64 @@ export function connectWebSocket(onMessage: (data: any) => void): WebSocket {
|
|||||||
|
|
||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Route mode ---
|
||||||
|
|
||||||
|
export async function setBalanceMode(mode: 'best' | 'weighted_random') {
|
||||||
|
return request<{ mode: string }>('/route/mode', {
|
||||||
|
method: 'PUT',
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -3,20 +3,36 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import {
|
import {
|
||||||
getNodes, connectWebSocket, isAuthenticated,
|
getNodes, connectWebSocket, isAuthenticated, setBalanceMode,
|
||||||
type NodeInfo, type BalancerInfo
|
type NodeInfo, type BalancerInfo
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import { Activity, Phone, Zap, AlertTriangle, Wifi, Server, Plug, Globe, ArrowLeftRight, Network } from 'lucide-svelte';
|
import { Activity, Phone, Zap, AlertTriangle, Wifi, Server, Plug, Globe, ArrowLeftRight, Network, Shuffle } from 'lucide-svelte';
|
||||||
|
|
||||||
let nodes: NodeInfo[] = [];
|
let nodes: NodeInfo[] = [];
|
||||||
let balancer: BalancerInfo | null = null;
|
let balancer: BalancerInfo | null = null;
|
||||||
let ws: WebSocket | null = null;
|
let ws: WebSocket | null = null;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
let setModeBusy = false;
|
||||||
|
|
||||||
if (browser && !isAuthenticated()) {
|
if (browser && !isAuthenticated()) {
|
||||||
goto('/login');
|
goto('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRole(): string | null {
|
||||||
|
if (!browser) return null;
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
return payload.role || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let role = browser ? getRole() : null;
|
||||||
|
$: isAdmin = role === 'admin';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadNodes();
|
loadNodes();
|
||||||
ws = connectWebSocket((msg) => {
|
ws = connectWebSocket((msg) => {
|
||||||
@ -40,6 +56,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleMode() {
|
||||||
|
if (!balancer || setModeBusy) return;
|
||||||
|
const newMode = balancer.balance_mode === 'weighted_random' ? 'best' : 'weighted_random';
|
||||||
|
setModeBusy = true;
|
||||||
|
try {
|
||||||
|
await setBalanceMode(newMode);
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
setModeBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scoreColor(score: number): string {
|
function scoreColor(score: number): string {
|
||||||
if (score >= 70) return 'text-green-600';
|
if (score >= 70) return 'text-green-600';
|
||||||
if (score >= 40) return 'text-yellow-600';
|
if (score >= 40) return 'text-yellow-600';
|
||||||
@ -145,6 +174,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<span class="opacity-30">|</span>
|
||||||
|
<span class="flex items-center gap-1 ml-auto">
|
||||||
|
<Shuffle class="h-3 w-3" />
|
||||||
|
<span class="font-medium">{balancer.balance_mode === 'weighted_random' ? 'Weighted' : 'Best'}</span>
|
||||||
|
{#if isAdmin}
|
||||||
|
<button
|
||||||
|
on:click={toggleMode}
|
||||||
|
disabled={setModeBusy}
|
||||||
|
class="ml-1 px-2 py-0.5 text-[10px] border rounded hover:bg-secondary transition-colors disabled:opacity-50"
|
||||||
|
title="Переключить режим балансировки"
|
||||||
|
>
|
||||||
|
{setModeBusy ? '...' : 'Switch'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
401
web/src/routes/routing/+page.svelte
Normal file
401
web/src/routes/routing/+page.svelte
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user