Compare commits

..
4 Commits
Author SHA1 Message Date
Maksim Totmin e66ac27dd9 docs: fix 12 documentation inaccuracies in README.md and AGENTS.md
README.md fixes:
- Architecture diagram now shows pulse-lets-go-agent layer
- Features list: added agent, auto-trunk, WS gateway, user-01 protection
- route.lua example: replaced fake curl.fetch() with real api:execute()
- systemd unit: Wants=nats.service removed (NATS on separate host)
- Gateway mapping: removed grep 'pulse-' filter (misses existing gateways)
- Production deployment: added agent deployment step (between NATS and route.lua)
- Makefile commands: added build-agent, build-siptest, siptest-*, emulate-*

AGENTS.md fixes:
- Structure: added missing cmd/emulator, cmd/siptest, cmd/pulse-lets-go-agent, esl/, ami/, contrib/
- NATS model: added sip_gateway field
- config.json: added nats_user, nats_password, tls, rate_limit, log, esl blocks
- Internal packages: added esl/ and ami/
- route.lua name: route_call → pulse_route (matches production)
2026-06-25 17:16:03 +07:00
Maksim Totmin 13c88c1586 fix: agent systemd unit remove nats dependency
Agent connects to NATS remotely, not locally. After=network only.
2026-06-25 16:59:39 +07:00
Maksim Totmin 95ac4164dd chore: emulator sip_gateway, Makefile targets, full README documentation
- Emulator: add sip_gateway field to all scenarios (auto-trunk creation)
- Makefile: build-agent, build-siptest, emulate-*, siptest-* targets
- README: full documentation with agent section, production deployment
- Project structure update with all components
2026-06-25 16:59:17 +07:00
Maksim Totmin 1e6f432230 feat: metrics agent for FreeSWITCH and Asterisk nodes
- pulse-lets-go-agent: collects metrics from PBX nodes via ESL (FreeSWITCH) or AMI (Asterisk)
- AMI client (raw TCP) for Asterisk Manager Interface
- Collector interface: collect, connect, subscribe hangup events, close
- FreeSWITCH collector: show channels, max_sessions, idle_cpu
- Asterisk collector: CoreShowChannels, CoreSettings
- System collector: /proc/loadavg, /proc/stat (CPU idle)
- Failure tracker: sliding window for call_failure_rate
- NATS publisher: pulse.metrics.<node_id> every 5 seconds
- Graceful shutdown, retry logic (5 failures → status=down)
- Template configs for UC nodes (Asterisk) and ses-sip (FreeSWITCH)
2026-06-25 16:59:10 +07:00
56 changed files with 1465 additions and 5182 deletions
+282
View File
@@ -0,0 +1,282 @@
# pulse-lets-go — Telephone Load Balancer
Балансировщик телефонной нагрузки на базе FreeSWITCH + NATS.
Принимает метрики от нижестоящих АТС, вычисляет score готовности,
возвращает лучшую ноду для следующего вызова через `/api/route`.
## Стек
| Компонент | Технология |
|-----------|-----------|
| Backend | Go (stdlib net/http или Chi, nats.go, golang-jwt, bcrypt) |
| Frontend | SvelteKit + Tailwind + shadcn-svelte |
| Auth | JWT access/refresh, bcrypt, роли: admin / viewer |
| Шина | NATS — `pulse.metrics.<node_id>` |
| Хранилище | JSON (`data/config.json`, `trunks.json`, `users.json`) + ASCII-лог метрик |
| Деплой | systemd + единый бинарник, без Docker |
## Модели данных
### NATS message (node → balancer, каждые 5 сек)
```json
{
"node_id": "pbx-01",
"ts": 1719000000,
"status": "ok",
"active_calls": 42,
"max_calls": 250,
"idle_cpu": 35,
"load_avg": 0.85,
"call_failure_rate": 0.5,
"sip_gateway": "sip:pbx-01.lan:5060"
}
```
`call_failure_rate`: 0.0..100.0 (проценты).
### In-memory node state (engine)
Все поля метрики + дополнительные:
- `disabled` (bool) — ручное исключение админом
- `disabled_reason` (string)
## Scoring Engine
### Lethal (score → -100, нода исключается)
```
status != "ok"
stale > stale_threshold_sec (по умолчанию 20 сек)
active_calls >= max_calls
idle_cpu < idle_cpu_min (по умолчанию 5)
disabled == true
call_failure_rate > call_failure_rate_lethal (по умолчанию 15.0)
```
### Weighted score
```
call_score = min(100, max(0, 100 - (active_calls / max_calls * 100)))
load_score = min(100, max(0, 100 - (load_avg * 50)))
idle_score = min(100, max(0, idle_cpu))
fail_score = min(100, max(0, 100 - call_failure_rate))
score = call_score * 0.40 + load_score * 0.30 + idle_score * 0.20 + fail_score * 0.10
```
Веса и пороги конфигурируются в `config.json`.
### Оптимизация
`best_node_id` + `best_score` + `fallback_active` кэшируются, обновляются при каждой метрике.
`/api/route` читает их за RLock, O(1).
## HTTP API
### Auth
- `POST /api/auth/login` → JWT
- Все эндпоинты (кроме login и `/api/monitoring/*`) требуют `Authorization: Bearer <jwt>`
- `/api/monitoring/*` — API-key в заголовке `X-API-Key`
### Core
| Метод | Путь | Роль | Описание |
|-------|------|------|----------|
| `GET` | `/api/route?caller_id=X&dest=Y&ingress_trunk=Z` | – | Выбор ноды для звонка (без JWT) |
Response (успех):
```json
{ "node_id": "pbx-03", "score": 88, "sip_gateway": "sip:pbx03.lan:5060" }
```
Response (fallback — все ноды unhealthy):
```json
{
"fallback": true,
"sip_gateway": "sip:operator.lan:5060",
"reason": "all_nodes_unhealthy",
"nodes": [...]
}
```
Response (нет нод вообще):
```json
{ "error": "no_nodes_registered" }
```
### Nodes
| Метод | Путь | Роль | Описание |
|-------|------|------|----------|
| `GET` | `/api/nodes` | admin/viewer | Список нод со score |
| `GET` | `/api/nodes/:id/metrics` | admin/viewer | История метрик (ring buffer) |
| `PUT` | `/api/nodes/:id/toggle` | admin | `{"disabled": true/false, "reason": "..."}` |
### Trunks
| Метод | Путь | Роль | Описание |
|-------|------|------|----------|
| `GET` | `/api/trunks` | admin | Все транки |
| `GET` | `/api/trunks?type=ingress\|balance\|fallback` | admin | Фильтр по типу |
| `POST` | `/api/trunks` | admin | Создать |
| `PUT` | `/api/trunks/:id` | admin | Обновить |
| `DELETE` | `/api/trunks/:id` | admin | Удалить |
### Users (admin only)
| Метод | Путь | Описание |
|-------|------|----------|
| `GET` | `/api/users` | Список |
| `POST` | `/api/users` | Создать |
| `PUT` | `/api/users/:id` | Обновить |
| `DELETE` | `/api/users/:id` | Удалить |
### Monitoring (API-key, не JWT)
| Метод | Путь | Описание |
|-------|------|----------|
| `GET` | `/api/monitoring/zabbix` | JSON для Zabbix LLD + items |
| `GET` | `/api/monitoring/prometheus` | text/plain метрики |
### WebSocket
| Метод | Путь | Описание |
|-------|------|----------|
| `WS` | `/ws/metrics` | Real-time метрики на фронт (JWT в query param) |
## Терминология транков
| Тип | Назначение | Сколько |
|-----|-----------|---------|
| `ingress` | Входящий транк (откуда звонки приходят) | 1..N |
| `balance` | Транк назначения (куда балансятся, привязан к Node) | 1..N |
| `fallback` | Резервный транк оператора (когда все balance score < 0) | ровно 1 |
Модель транка:
```json
{
"id": "trk-001",
"name": "pbx-03 balance",
"type": "ingress|balance|fallback",
"node_id": "pbx-03",
"gateway": "sip:pbx03.lan:5060",
"codecs": ["PCMU", "PCMA"],
"context": "default",
"enabled": true,
"description": "",
"created_at": "...",
"updated_at": "..."
}
```
## FreeSWITCH интеграция
FS использует Lua скрипт в dialplan → `mod_curl` дёргает `/api/route` → получает JSON → парсит `sip_gateway` → bridge.
```
<extension name="pulse_route">
<condition field="destination_number" expression="^.*$">
<action application="lua" data="route.lua"/>
</condition>
</extension>
```
route.lua: GET `/api/route?...` → если `fallback: true` идёт на fallback gateway, иначе на `sip_gateway`.
## config.json
```json
{
"nats_url": "nats://localhost:4222",
"nats_user": "",
"nats_password": "",
"listen_addr": ":8080",
"jwt_secret": "auto-generated-on-first-run",
"monitoring_api_key": "auto-generated-on-first-run",
"stale_threshold_sec": 20,
"scoring": {
"idle_cpu_min": 5,
"call_failure_rate_lethal": 15.0,
"weights": {
"call_score": 0.40,
"load_score": 0.30,
"idle_score": 0.20,
"fail_score": 0.10
}
},
"tls": {
"enabled": false,
"cert_file": "",
"key_file": ""
},
"rate_limit": {
"enabled": true,
"route_per_sec": 5000,
"api_per_sec": 100
},
"log": {
"level": "info",
"format": "text"
},
"esl": {
"host": "",
"port": 8021,
"password": "",
"password_env": "",
"sofia_profile": "external",
"gateway_prefix": "pulse"
}
}
```
## Структура проекта
```
pulse-lets-go/
├── cmd/pulse-lets-go/main.go
├── cmd/pulse-lets-go-agent/
├── cmd/emulator/
├── cmd/siptest/
├── internal/
│ ├── api/ — handlers: auth, nodes, trunks, users, monitoring, ws
│ ├── engine/ — scorer + router (sync.RWMutex, best_node кэш)
│ ├── nats/ — subscriber, in-memory node store
│ ├── esl/ — FreeSWITCH ESL client (raw TCP)
│ ├── ami/ — Asterisk AMI client (raw TCP)
│ ├── config/ — manager: чтение/атомарная запись JSON
│ ├── models/ — все общие типы
│ └── log/ — ASCII-metrics логгер + ротация
├── contrib/ — route.lua, agent конфиги
├── data/ — JSON конфиги (gitignored)
├── web/ — SvelteKit + shadcn-svelte + Tailwind
│ ├── src/routes/
│ │ ├── +layout.svelte
│ │ ├── +page.svelte # дашборд
│ │ ├── login/+page.svelte
│ │ ├── trunks/+page.svelte
│ │ ├── nodes/+page.svelte
│ │ └── users/+page.svelte
│ ├── src/lib/
│ │ ├── components/
│ │ └── api.ts
│ ├── svelte.config.js
│ ├── tailwind.config.js
│ └── package.json
├── go.mod
├── Makefile
└── AGENTS.md
```
## Порядок реализации
1. `cmd/pulse-lets-go/main.go` + Go module + пустая структура internal
2. `internal/models/` — все типы данных
3. `internal/config/` — чтение/запись JSON, атомарное сохранение
4. `internal/nats/` — подписка, in-memory store
5. `internal/engine/` — scorer + router
6. `internal/api/` — все хендлеры
7. `internal/log/` — ASCII-логгер + ring buffer
8. Web-фронт (SvelteKit)
9. Makefile + systemd unit
10. Тесты + graceful shutdown
-36
View File
@@ -1,36 +0,0 @@
# 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
View File
@@ -1,50 +0,0 @@
# 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 # интеграционная проверка
```
+15 -24
View File
@@ -4,9 +4,9 @@
APP := pulse-lets-go APP := pulse-lets-go
BINDIR := bin BINDIR := bin
WEB_DIR := web WEB_DIR := web
PREFIX := /opt/pulse-lets-go DATA_DIR := data
.PHONY: all build build-go build-web clean run test deploy \ .PHONY: all build build-go build-web clean run test install \
emulate emulate-normal emulate-overload emulate-cpu-low emulate-fail-high \ emulate emulate-normal emulate-overload emulate-cpu-low emulate-fail-high \
emulate-node-down emulate-stale emulate-chaos build-emulator \ emulate-node-down emulate-stale emulate-chaos build-emulator \
build-siptest siptest-uas siptest-uac siptest-full build-siptest siptest-uas siptest-uac siptest-full
@@ -22,17 +22,13 @@ build-web:
@echo "→ Сборка SvelteKit..." @echo "→ Сборка SvelteKit..."
cd $(WEB_DIR) && npm run build cd $(WEB_DIR) && npm run build
# Сборка Go бинарника (с авто-копированием статики) # Сборка Go бинарника
build-go: build-go:
@mkdir -p $(BINDIR) @mkdir -p $(BINDIR)
@echo "→ Сборка Go backend..." @echo "→ Сборка Go backend..."
@if [ -d $(WEB_DIR)/build ]; then \
rm -rf $(BINDIR)/web && cp -r $(WEB_DIR)/build $(BINDIR)/web; \
echo " статика скопирована в $(BINDIR)/web/"; \
fi
go build -o $(BINDIR)/$(APP) ./cmd/$(APP) go build -o $(BINDIR)/$(APP) ./cmd/$(APP)
# Быстрая сборка только Go (без фронта, без статики) # Быстрая сборка только Go (без фронта)
build-go-only: build-go-only:
@mkdir -p $(BINDIR) @mkdir -p $(BINDIR)
go build -o $(BINDIR)/$(APP) ./cmd/$(APP) go build -o $(BINDIR)/$(APP) ./cmd/$(APP)
@@ -47,28 +43,23 @@ run:
build-prod: build-prod:
@mkdir -p $(BINDIR) @mkdir -p $(BINDIR)
cd $(WEB_DIR) && npm run build cd $(WEB_DIR) && npm run build
rm -rf $(BINDIR)/web && cp -r $(WEB_DIR)/build $(BINDIR)/web
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BINDIR)/$(APP) ./cmd/$(APP) CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BINDIR)/$(APP) ./cmd/$(APP)
# Деплой в PREFIX: бинарник + статика + data + log # Установка: копирует бинарник и статику в /usr/local
deploy: build-prod install: build
@echo "→ Деплой в $(PREFIX)" @echo "→ Установка в /usr/local"
install -d $(PREFIX)/bin $(PREFIX)/data $(PREFIX)/web $(PREFIX)/log $(PREFIX)/contrib mkdir -p /usr/local/share/$(APP)/web
install -m 755 $(BINDIR)/$(APP) $(PREFIX)/bin/ cp -r $(WEB_DIR)/build /usr/local/share/$(APP)/web/
cp -r $(WEB_DIR)/build/. $(PREFIX)/web/ cp $(BINDIR)/$(APP) /usr/local/bin/$(APP)
cp contrib/*.lua contrib/*.json $(PREFIX)/contrib/ 2>/dev/null || true chmod +x /usr/local/bin/$(APP)
@echo "Готово: $(PREFIX)" @echo "Установлен в /usr/local/bin/$(APP)"
@echo " $(PREFIX)/bin/$(APP)"
@echo " $(PREFIX)/web/ (SvelteKit статика)"
@echo " $(PREFIX)/data/ (config.json, trunks.json, users.json)"
@echo " $(PREFIX)/log/ (ASCII-лог метрик)"
@echo " $(PREFIX)/contrib/ (route.lua, конфиги агентов)"
# Установка systemd unit # Установка systemd unit
install-systemd: install-systemd:
install -m 644 deploy/$(APP).service /etc/systemd/system/ @echo "→ Установка systemd unit"
cp deploy/$(APP).service /etc/systemd/system/
systemctl daemon-reload systemctl daemon-reload
@echo "✓ systemd unit установлен. Запуск: sudo systemctl start $(APP)" @echo "✓ systemd unit установлен. Запуск: systemctl start $(APP)"
# ============================================================ # ============================================================
# Эмулятор метрик # Эмулятор метрик
+729 -55
View File
@@ -1,5 +1,10 @@
# Pulse Lets Go # Pulse Lets Go
[![Go](https://img.shields.io/badge/Go-1.26-blue?logo=go)](https://go.dev)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![NATS](https://img.shields.io/badge/NATS-latest-27ae60?logo=nats)](https://nats.io)
[![SvelteKit](https://img.shields.io/badge/SvelteKit-2-fb923c?logo=svelte)](https://kit.svelte.dev)
**pulse-lets-go** — балансировщик телефонной нагрузки на базе FreeSWITCH + NATS. **pulse-lets-go** — балансировщик телефонной нагрузки на базе FreeSWITCH + NATS.
Агенты PBX отправляют метрики в NATS каждые 5 секунд. Балансировщик вычисляет weighted score готовности каждой ноды, кэширует лучшую и отдаёт её FreeSWITCH через `/api/route` для следующего звонка. Агенты PBX отправляют метрики в NATS каждые 5 секунд. Балансировщик вычисляет weighted score готовности каждой ноды, кэширует лучшую и отдаёт её FreeSWITCH через `/api/route` для следующего звонка.
@@ -8,28 +13,86 @@
## Features ## Features
- **pulse-lets-go-agent** — агент сбора метрик для FreeSWITCH (ESL) и Asterisk (AMI) - **pulse-lets-go-agent** — агент сбора метрик для FreeSWITCH (ESL) и Asterisk (AMI). Работает на каждой PBX-ноде.
- **Scoring engine** — lethal checks + weighted score (call, load, idle, fail) с конфигурируемыми весами - **Scoring engine** — lethal checks (disabled, stale, overload, CPU, failure rate) + weighted score (call, load, idle, fail) с конфигурируемыми весами.
- **NATS шина** — асинхронный сбор метрик через `pulse.metrics.<node_id>` - **NATS шина** — асинхронный сбор метрик через `pulse.metrics.<node_id>`.
- **REST API** — JWT (access + refresh), роли admin/viewer, rate limiting, CORS - **Auto-создание транков** — при получении первой метрики от новой ноды с `sip_gateway` создаётся balance-транк автоматически.
- **WebSocket** — real-time трансляция метрик на SvelteKit дашборд - **Gateway health WS** — статус FS-gateway (registered/unregistered) транслируется на фронт через WebSocket.
- **FreeSWITCH ESL** — управление Sofia-шлюзами через raw TCP ESL - **REST API** — JWT (access + refresh), роли admin/viewer, rate limiting, CORS, health endpoints.
- **Monitoring** — Prometheus и Zabbix эндпоинты с отдельным API-key - **WebSocket** — real-time трансляция метрик на SvelteKit дашборд.
- **SvelteKit UI** — дашборд, таблицы нод, транков, пользователей - **FreeSWITCH ESL** — управление Sofia-шлюзами через raw TCP ESL.
- **Graceful shutdown** с таймаутом 10 с - **Monitoring** — Prometheus и Zabbix эндпоинты с отдельным API-key.
- **systemd ready** с изоляцией (NoNewPrivileges, ProtectSystem, PrivateTmp) - **SvelteKit UI** — дашборд, таблицы нод, транков, пользователей.
- **Защита пользователей** — первичный администратор (user-01) не может быть удалён или понижен до viewer.
- **Graceful shutdown** — корректное завершение с таймаутом 10 с.
- **systemd ready** — unit с изоляцией (NoNewPrivileges, ProtectSystem, PrivateTmp).
---
## Архитектура
```
┌─────────────────────┐ ESL/AMI ┌──────────────────────┐
│ PBX-01 │──────────▶│ pulse-lets-go-agent │
│ (FS or Asterisk) │ collect │ (on each PBX node) │
├─────────────────────┤ ├──────────────────────┤
│ PBX-02 │──────────▶│ pulse-lets-go-agent │
│ (FS or Asterisk) │ ├──────────────────────┤
├─────────────────────┤ ├──────────────────────┤
│ PBX-N │──────────▶│ pulse-lets-go-agent │
│ (FS or Asterisk) │ └─────────┬────────────┘
└─────────────────────┘ │ pulse.metrics.<id>
│ every 5s
┌──────────────┐
│ NATS Server │
│ nats://:4222 │
└──────┬───────┘
┌──────────────────────┐
│ pulse-lets-go │
│ │
│ ┌────────────────┐ │
│ │ NATS Sub │ │
│ │ (pulse.metrics)│ │
│ └────────┬───────┘ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Engine │ │
│ │ (scoring) │──│──▶ /api/route
│ └────────┬───────┘ │ ↓
│ ▼ │ FreeSWITCH bridge
│ ┌────────────────┐ │ sip:pbx-03:5060
│ │ REST API │──│──▶ /api/nodes
│ │ (JWT auth) │ │ /api/trunks
│ └────────┬───────┘ │ /api/users
│ ▼ │
│ ┌────────────────┐ │
│ │ WebSocket │──│──▶ SvelteKit UI
│ └────────────────┘ │ (ws://:8080/ws/metrics)
│ ┌────────────────┐ │
│ │ ESL Client │──│──▶ FreeSWITCH ESL
│ │ (gateways) │ │ :8021
│ └────────────────┘ │
│ ┌────────────────┐ │
│ │ Monitoring │──│──▶ /api/monitoring/prometheus
│ │ (API-key) │ │ /api/monitoring/zabbix
│ └────────────────┘ │
└──────────────────────┘
```
--- ---
## Quick Start ## Quick Start
### Requirements ### 1. Требования
- Go 1.26+ - Go 1.26+
- Node.js 22+ - Node.js 22+
- NATS Server (`make nats` скачивает автоматически) - NATS Server (автоматически качается `make nats`)
### Build & Run ### 2. Сборка
```bash ```bash
git clone git@git.totmin.ru:en2zmax/pulse-lets-go.git git clone git@git.totmin.ru:en2zmax/pulse-lets-go.git
@@ -37,21 +100,23 @@ cd pulse-lets-go
make build make build
``` ```
### 3. Запуск
```bash ```bash
# Terminal 1: NATS # Терминал 1: NATS
make nats make nats
# Terminal 2: balancer # Терминал 2: балансировщик
./bin/pulse-lets-go ./bin/pulse-lets-go
# Terminal 3: emulator (3 healthy nodes) # Терминал 3: эмулятор метрик (3 здоровые ноды)
make emulate-normal make emulate-normal
``` ```
### Check ### 4. Проверка
```bash ```bash
# Route (no auth) # Route (без авторизации)
curl 'http://localhost:8080/api/route?caller_id=123&dest=456&ingress_trunk=trk-001' curl 'http://localhost:8080/api/route?caller_id=123&dest=456&ingress_trunk=trk-001'
# Login # Login
@@ -62,26 +127,9 @@ curl -X POST http://localhost:8080/api/auth/login \
--- ---
## Documentation ## Конфигурация
| Document | Content | При первом запуске `config.json` создаётся автоматически в директории `data/` (или `$PULSE_DATA_DIR`). JWT-секрет и API-key генерируются случайно.
|----------|---------|
| [docs/architecture.md](docs/architecture.md) | Архитектура, диаграмма потоков данных, стек, структура проекта |
| [docs/api.md](docs/api.md) | Полная справка REST API + WebSocket: auth, route, nodes, trunks, users, monitoring, примеры |
| [docs/scoring.md](docs/scoring.md) | Scoring engine: lethal-условия, weighted score, пример расчёта, кэширование |
| [docs/routing.md](docs/routing.md) | Маршрутизация: режимы балансировки (best / weighted_random), routing rules (node / pool / auto) |
| [docs/agent.md](docs/agent.md) | pulse-lets-go-agent: collectors, конфигурация, деплой, troubleshooting |
| [docs/deployment.md](docs/deployment.md) | Production деплой (8 шагов), systemd unit, security, rollback |
| [docs/freeswitch.md](docs/freeswitch.md) | Интеграция с FreeSWITCH: route.lua, dialplan, ESL, предварительные проверки |
| [docs/development.md](docs/development.md) | Команды Makefile, code style, добавление эндпоинта, PR process |
| [CHANGELOG.md](CHANGELOG.md) | История изменений (Keep a Changelog) |
| [CONTRIBUTING.md](CONTRIBUTING.md) | Code style, conventional commits, процесс PR |
---
## Configuration
При первом запуске `config.json` создаётся автоматически в `data/` (или `$PULSE_DATA_DIR`). JWT-секрет и API-key генерируются случайно.
### config.json ### config.json
@@ -91,8 +139,8 @@ curl -X POST http://localhost:8080/api/auth/login \
"nats_user": "", "nats_user": "",
"nats_password": "", "nats_password": "",
"listen_addr": ":8080", "listen_addr": ":8080",
"jwt_secret": "auto-generated-32-byte-hex", "jwt_secret": "aabbccdd...32-байта-hex",
"monitoring_api_key": "auto-generated-32-byte-hex", "monitoring_api_key": "11223344...32-байта-hex",
"stale_threshold_sec": 20, "stale_threshold_sec": 20,
"scoring": { "scoring": {
"idle_cpu_min": 5, "idle_cpu_min": 5,
@@ -129,22 +177,648 @@ curl -X POST http://localhost:8080/api/auth/login \
} }
``` ```
### Key Parameters | Поле | Дефолт | Описание |
|------|--------|----------|
| Parameter | Default | Description | | `nats_url` | `nats://localhost:4222` | URL NATS сервера |
|-----------|---------|-------------| | `nats_user` | `""` | Имя пользователя NATS (если нужна аутентификация) |
| `nats_url` | `nats://localhost:4222` | NATS server URL | | `nats_password` | `""` | Пароль NATS |
| `listen_addr` | `:8080` | HTTP server address | | `listen_addr` | `:8080` | Адрес HTTP-сервера |
| `stale_threshold_sec` | `20` | Seconds without metrics before node is stale | | `jwt_secret` | auto | Секрет для подписи JWT (32 байта hex) |
| `scoring.idle_cpu_min` | `5` | Minimum idle CPU. Below — lethal | | `monitoring_api_key` | auto | API-ключ для `/api/monitoring/*` |
| `scoring.call_failure_rate_lethal` | `15.0` | Maximum failure rate. Above — lethal | | `stale_threshold_sec` | `20` | Через сколько секунд без метрик нода считается stale |
| `rate_limit.route_per_sec` | `5000` | `/api/route` per-IP limit | | `scoring.idle_cpu_min` | `5` | Минимальный idle CPU. Ниже — lethal |
| `rate_limit.api_per_sec` | `100` | `/api/*` per-IP limit | | `scoring.call_failure_rate_lethal` | `15.0` | Максимальный failure rate. Выше — lethal |
| `log.level` | `info` | `debug`, `info`, `warn`, `error` | | `scoring.weights.*` | 0.40/0.30/0.20/0.10 | Веса компонентов скора (сумма = 1.0) |
| `esl.host` | `""` | FS ESL host. Empty — ESL disabled | | `tls.enabled` | `false` | Включить HTTPS |
| `rate_limit.enabled` | `true` | Включить rate limiting |
| `rate_limit.route_per_sec` | `5000` | Лимит `/api/route` на IP |
| `rate_limit.api_per_sec` | `100` | Лимит остальных `/api/*` на IP |
| `log.level` | `info` | Уровень лога: `debug`, `info`, `warn`, `error` |
| `log.format` | `text` | Формат: `text` или `json` |
| `esl.host` | `""` | Хост FreeSWITCH ESL. Пусто — ESL отключён |
| `esl.port` | `8021` | Порт ESL |
| `esl.password` | `""` | Пароль ESL (ClueCon) |
| `esl.password_env` | `""` | Или имя переменной окружения с паролем |
--- ---
## Прочее
AGENTS.md содержит только этот файл — структура и связь рабочих элементов. ## Scoring Engine
see docs/development.md — план реализации и стек.
### Lethal-условия (score = -100)
Если хотя бы одно истинно — нода исключается из роутинга:
| Условие | Порог |
|---------|-------|
| `status != "ok"` | Любой статус кроме ok |
| Stale | > `stale_threshold_sec` (20 с) |
| `active_calls >= max_calls` | 100% заполнение |
| `idle_cpu < idle_cpu_min` | < 5% |
| `call_failure_rate > call_failure_rate_lethal` | > 15% |
| `disabled == true` | Ручное отключение админом |
### Weighted score (0..100)
```
call_score = clamp(100 - (active_calls / max_calls * 100), 0, 100)
load_score = clamp(100 - (load_avg * 50), 0, 100)
idle_score = clamp(idle_cpu, 0, 100)
fail_score = clamp(100 - call_failure_rate, 0, 100)
score = call_score × 0.40 +
load_score × 0.30 +
idle_score × 0.20 +
fail_score × 0.10
```
### Пример
Нода `pbx-01`: `active_calls=42`, `max_calls=250`, `load_avg=0.85`, `idle_cpu=35`, `fail_rate=0.5`
```
call_score = clamp(100 - (42/250)*100, 0, 100) = 83.2
load_score = clamp(100 - 0.85*50, 0, 100) = 57.5
idle_score = clamp(35, 0, 100) = 35.0
fail_score = clamp(100 - 0.5, 0, 100) = 99.5
score = 83.2×0.40 + 57.5×0.30 + 35.0×0.20 + 99.5×0.10 = 70.1
```
### Кэширование
`best_node_id` + `best_score` + `fallback_active` пересчитываются при каждой метрике.
`/api/route` читает их за RLock — O(1).
---
## API Reference
### Auth
| Метод | Путь | Роль | Описание |
|-------|------|------|----------|
| `POST` | `/api/auth/login` | | Вход: `{"username":"admin","password":"admin"}` → access_token + refresh_token |
| `POST` | `/api/auth/refresh` | – | Обновление access-токена через refresh_token |
Все остальные эндпоинты (кроме `/api/route`, `/api/health/*`, `/api/monitoring/*`) требуют `Authorization: Bearer <jwt>`.
### Core
| Метод | Путь | Роль | Описание |
|-------|------|------|----------|
| `GET` | `/api/route` | – | Выбор ноды для звонка. Query: `caller_id`, `dest`, `ingress_trunk` |
| `GET` | `/api/health` | – | Статистика: всего нод, здоровых, запросов, uptime |
| `GET` | `/api/health/live` | | Liveness probe (всегда 200) |
| `GET` | `/api/health/ready` | | Readiness probe (NATS + метрики) |
### Nodes
| Метод | Путь | Роль | Описание |
|-------|------|------|----------|
| `GET` | `/api/nodes` | admin/viewer | Список всех нод со score, состоянием |
| `GET` | `/api/nodes/{id}/metrics` | admin/viewer | История метрик (ring buffer, ~30 мин) |
| `PUT` | `/api/nodes/{id}/toggle` | admin | `{"disabled":true, "reason":"..."}` |
### Trunks
| Метод | Путь | Роль | Описание |
|-------|------|------|----------|
| `GET` | `/api/trunks` | admin | Все транки. Query: `type=ingress\|balance\|fallback` |
| `POST` | `/api/trunks` | admin | Создать транк |
| `PUT` | `/api/trunks/{id}` | admin | Обновить транк |
| `DELETE` | `/api/trunks/{id}` | admin | Удалить транк |
### Users
| Метод | Путь | Роль | Описание |
|-------|------|------|----------|
| `GET` | `/api/users` | admin | Список пользователей |
| `POST` | `/api/users` | admin | Создать пользователя |
| `PUT` | `/api/users/{id}` | admin | Обновить пользователя |
| `DELETE` | `/api/users/{id}` | admin | Удалить пользователя |
### Monitoring (API-key, не JWT)
| Метод | Путь | Описание |
|-------|------|----------|
| `GET` | `/api/monitoring/zabbix` | JSON для Zabbix LLD |
| `GET` | `/api/monitoring/prometheus` | `text/plain` метрики для Prometheus |
### WebSocket
| Метод | Путь | Описание |
|-------|------|----------|
| `WS` | `/ws/metrics` | Real-time метрики на фронт (JWT в query `?token=`) |
### Примеры запросов
```bash
# Route (no auth)
curl 'http://localhost:8080/api/route?caller_id=74951234567&dest=123&ingress_trunk=trk-001'
# → {"node_id":"pbx-03","score":88,"sip_gateway":"sip:pbx03.lan:5060"}
# Fallback (все ноды unhealthy)
# → {"fallback":true,"sip_gateway":"sip:operator.lan:5060","reason":"all_nodes_unhealthy","nodes":[...]}
# Login
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin"}' | jq -r '.access_token')
# Nodes with JWT
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/nodes
# Toggle node (admin only)
curl -X PUT http://localhost:8080/api/nodes/pbx-01/toggle \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"disabled":true,"reason":"maintenance"}'
# Prometheus metrics (API-key)
curl -H "X-API-Key: $(jq -r '.monitoring_api_key' data/config.json)" \
http://localhost:8080/api/monitoring/prometheus
```
---
## FreeSWITCH Интеграция
### Dialplan (route.lua)
FreeSWITCH использует Lua-скрипт в dialplan. Скрипт дёргает `/api/route` через `mod_curl` и бриджится на полученный gateway.
```lua
-- contrib/route.lua (сокращённо)
api = freeswitch.API()
caller_id = session:getVariable("caller_id_number") or ""
dest = session:getVariable("destination_number") or ""
url = "http://localhost:8080/api/route?caller_id=" .. caller_id .. "&dest=" .. dest
raw = api:execute("curl", url)
body = raw:match("\r?\n\r?\n(.+)") or raw
local ok, route = pcall(cjson.decode, body)
if not ok then session:hangup("NORMAL_TEMPORARY_FAILURE") return end
if route.fallback then
session:bridge(route.sip_gateway) -- оператор
elseif route.sip_gateway then
session:bridge(route.sip_gateway) -- лучшая нода
else
session:hangup("NORMAL_TEMPORARY_FAILURE")
end
```
Конфигурация в dialplan:
```xml
<extension name="route_call">
<condition field="destination_number" expression="^.*$">
<action application="set" data="balancer_url=http://balancer.lan:8080"/>
<action application="set" data="ingress_trunk=trk-001"/>
<action application="lua" data="route.lua"/>
</condition>
</extension>
```
### ESL (автоматическое управление шлюзами)
При включении `esl.host` в конфиге балансировщик:
- Подключается к FreeSWITCH ESL (`:8021`)
- Синхронизирует ingress-транки как Sofia-шлюзы
- Слушает события регистрации шлюзов
- Передаёт статус gateway на WebSocket фронта
---
## Модели данных
### NATS-сообщение (PBX → Balancer, каждые 5 с)
```json
{
"node_id": "pbx-01",
"ts": 1719000000,
"status": "ok",
"active_calls": 42,
"max_calls": 250,
"idle_cpu": 35,
"load_avg": 0.85,
"call_failure_rate": 0.5
}
```
### Транки
| Тип | Назначение |
|-----|-----------|
| `ingress` | Входящий транк (откуда звонки приходят) |
| `balance` | Транк назначения (привязан к Node) |
| `fallback` | Резервный транк (когда все balance score < 0), ровно 1 |
---
## Деплой
### systemd
```bash
make install # копирует бинарник + web-статику
make install-systemd # устанавливает systemd unit
```
```ini
# deploy/pulse-lets-go.service
[Unit]
Description=Pulse Lets Go — Telephone Load Balancer
After=network.target
[Service]
User=pulse
Group=pulse
WorkingDirectory=/usr/local/share/pulse-lets-go
Environment=PULSE_DATA_DIR=/etc/pulse-lets-go
ExecStart=/usr/local/bin/pulse-lets-go
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/etc/pulse-lets-go /var/log/pulse-lets-go
[Install]
WantedBy=multi-user.target
```
### Структура директорий
```
/usr/local/share/pulse-lets-go/
├── web/build/ # SvelteKit статика
/etc/pulse-lets-go/
├── config.json # Конфигурация
├── trunks.json # Транки
├── users.json # Пользователи
/var/log/pulse-lets-go/
└── metrics.YYYY-MM-DD.log # ASCII-лог метрик
```
### Security
- `NoNewPrivileges=true` — запрет повышения привилегий
- `ProtectSystem=strict` — read-only системные директории
- `ProtectHome=yes` — изоляция home
- `PrivateTmp=true` — изолированный /tmp
- JWT-секрет и API-key генерируются случайно при первом запуске
---
## Production Deployment
### Предусловия (перед деплоем pulse-lets-go)
| № | Проверка | Команда | Ожидание |
|:-:|----------|---------|----------|
| 1 | **ESL запущен** | `nc -z 127.0.0.1 8021 && echo OK` | `OK` |
| 2 | **ESL пароль** | `echo "auth ClueCon" \| nc 127.0.0.1 8021` | `+OK accepted` |
| 3 | **mod_curl загружен** | `fs_cli -x "show modules" \| grep mod_curl` | `<load module="mod_curl"/>` |
| 4 | **mod_lua загружен** | `fs_cli -x "show modules" \| grep mod_lua` | `<load module="mod_lua"/>` |
| 5 | **NATS установлен** | `which nats-server` | путь к бинарнику |
| 6 | **Права на ESL** | ACL в `event_socket.conf.xml` разрешает 127.0.0.1 | `localhost` или `127.0.0.1` в ACL |
### Загрузка mod_curl без рестарта FreeSWITCH
Если `mod_curl` не загружен — звонки работать не будут. Загружается **без остановки FS**:
```bash
fs_cli -x "load mod_curl"
# Добавить в modules.conf.xml для персистентности:
# <load module="mod_curl"/>
```
### Порядок деплоя (7 шагов)
**Шаг 1 — Скопировать бинарник и конфиги**
```bash
make build-prod
scp bin/pulse-lets-go devadmin@10.101.60.81:/usr/local/bin/
scp -r contrib/route.lua devadmin@10.101.60.81:/tmp/
```
**Шаг 2 — NATS сервер** (если не установлен)
```bash
ssh devadmin@10.101.60.81
sudo mkdir -p /opt/nats
# Установить nats-server из репозитория или скопировать бинарник
# Запуск: nats-server -p 4222 -D &
```
**Шаг 3 — Деплой agent на PBX-ноды** (повторить для каждой UC/Asterisk ноды)
```bash
# Скопировать бинарник и конфиг на каждую PBX:
scp bin/pulse-lets-go-agent devadmin@PBX_IP:/usr/local/bin/
scp contrib/agent-uc.json devadmin@PBX_IP:/etc/pulse-lets-go-agent/agent.json
# Запустить:
ssh devadmin@PBX_IP "systemctl start pulse-lets-go-agent"
# Проверить лог:
# journalctl -u pulse-lets-go-agent -f
# → [ami] подключён к 127.0.0.1:6154
# → [agent] опубликована метрика: calls=0/150 load=0.28 cpu=99% status=ok
```
**Шаг 4 — route.lua в FS scripts**
```bash
sudo mkdir -p /etc/freeswitch/scripts
sudo cp /tmp/route.lua /etc/freeswitch/scripts/
```
**Шаг 5 — Dialplan** (добавить `pulse_route` extension)
```xml
<!-- В /etc/freeswitch/dialplan/default.xml перед остальными extension -->
<extension name="pulse_route">
<condition field="destination_number" expression="^(.*)$">
<action application="set" data="balancer_url=http://127.0.0.1:8080"/>
<action application="lua" data="route.lua"/>
</condition>
</extension>
```
```bash
sudo fs_cli -x "reloadxml"
```
**Шаг 6 — Создание balance-транков для каждой PBX-ноды**
```bash
# Получить список gateway из FS (или список нод из конфига):
echo "api sofia profile external gwlist" | nc 127.0.0.1 8021
# Создать balance-транк для каждой UC-ноды:
curl -X POST .../api/trunks
-d '{"name":"uc06","type":"balance","node_id":"uc06","gateway":"sip:10.101.60.115:5060","enabled":true}'
```
Транки также создаются автоматически при получении первой метрики от агента (если в метрике передан `sip_gateway`).
**Шаг 7 — Запуск pulse-lets-go**
```bash
sudo mkdir -p /etc/pulse-lets-go /var/log/pulse-lets-go
# config.json создастся автоматически при первом запуске
# Добавить esl блок в config.json:
# "esl": {"host": "127.0.0.1", "port": 8021, "password": "ClueCon"}
PULSE_DATA_DIR=/etc/pulse-lets-go ./pulse-lets-go
```
**Шаг 8 — Проверка**
```bash
curl http://127.0.0.1:8080/api/health | jq '.connections'
# → {"nats":"connected","esl":"connected"}
curl 'http://127.0.0.1:8080/api/route?caller_id=123&dest=456'
# → {"node_id":"pbx-03","score":88,"sip_gateway":...}
```
### Rollback (если route.lua сломал звонки)
```bash
# 1. Убрать pulse_route из dialplan
sudo sed -i '/pulse_route/,/<\/extension>/d' /etc/freeswitch/dialplan/default.xml
sudo fs_cli -x "reloadxml"
# 2. Остановить pulse-lets-go
sudo systemctl stop pulse-lets-go
# Звонки продолжают идти по старому dialplan без балансировщика.
```
### Права доступа (production)
| Операция | Требуемые права |
|----------|----------------|
| Чтение FS конфигов | root или freeswitch |
| `fs_cli -x` команды | root или freeswitch |
| ESL (порт 8021) | ACL в `event_socket.conf.xml` |
| Установка ПО | root |
| Запись в `/etc/freeswitch/scripts/` | root |
| `reloadxml` | root или freeswitch |
### Версии (протестировано)
| Компонент | Версия |
|-----------|--------|
| FreeSWITCH | 1.10.12+ |
| NATS Server | 2.10+ |
| Go | 1.26 |
| ОС | Linux (CentOS 7+, AlmaLinux, Arch) |
---
## Metrics Agent (pulse-lets-go-agent)
### Architecture
```
┌─────────────────────┐ pulse.metrics.<id> ┌─────────────────────┐
│ pulse-lets-go-agent│──────────────────────────▶ │ NATS Server │
│ (on each PBX node) │ every 5 seconds │ (central) │
├─────────────────────┤ └────────┬────────────┘
│ FreeSWITCH (ESL) │ │
│ Asterisk (AMI) │ ▼
│ System (/proc) │ ┌─────────────────┐
└─────────────────────┘ │ pulse-lets-go │
│ (scoring engine) │
└─────────────────┘
```
The agent runs on each PBX node (FreeSWITCH or Asterisk), collects metrics, and publishes them to NATS at a regular interval (default 5 seconds). The pulse-lets-go balancer subscribes to all metrics and uses them for scoring and routing decisions.
### Installation
```bash
make build-agent
# → bin/pulse-lets-go-agent (static binary, ~9 MB)
```
### Configuration (agent.json)
```json
{
"node_id": "uc06",
"type": "asterisk",
"nats_url": "nats://balancer.host:4222",
"nats_user": "",
"nats_password": "",
"interval_sec": 5,
"max_calls": 150,
"sip_gateway": "sip:10.101.60.115:5060",
"sip_gateway_auto": false,
"failure_window": 1000,
"esl": { "host": "127.0.0.1", "port": 8021, "password": "ClueCon" },
"ami": { "host": "127.0.0.1", "port": 6154, "username": "ctt", "password": "cttpass" }
}
```
| Поле | Тип | Default | Описание |
|------|-----|---------|----------|
| `node_id` | string | — | Уникальный ID ноды (uc06, ses-sip) |
| `type` | string | — | `freeswitch` или `asterisk` |
| `nats_url` | string | — | URL NATS-сервера |
| `nats_user` | string | `""` | Пользователь NATS |
| `nats_password` | string | `""` | Пароль NATS |
| `interval_sec` | int | `5` | Интервал публикации метрик |
| `max_calls` | int | `250` | Ёмкость ноды (fallback) |
| `sip_gateway` | string | `""` | SIP-адрес для auto-создания транка |
| `sip_gateway_auto` | bool | `false` | Авто-определение SIP из PBX |
| `failure_window` | int | `1000` | Размер окна для call_failure_rate |
| `esl.*` | object | — | FreeSWITCH ESL настройки (`host`, `port`, `password`) |
| `ami.*` | object | — | Asterisk AMI настройки (`host`, `port`, `username`, `password`) |
### Collectors
| Collector | Источник | Метрики | PBX |
|-----------|----------|---------|-----|
| `freeswitch` | ESL: `show channels count`, `json status`, `eval $${idle_cpu}` | active_calls, max_calls, idle_cpu | ✅ |
| `asterisk` | AMI: `CoreShowChannels`, `CoreSettings`, Hangup events | active_calls, max_calls, call_failure_rate | ✅ |
| `system` | `/proc/loadavg`, `/proc/stat` | load_avg, idle_cpu (fallback) | ✅ |
**Call Failure Rate:** скользящее окно последних N (1000) завершённых звонков.
| Исход | FS (ESL) | Asterisk (AMI) |
|-------|----------|---------------|
| Успех | `CHANNEL_HANGUP` + `Hangup-Cause: NORMAL_CLEARING` | `Hangup` + `Cause: 16` |
| Отказ | Любой другой `Hangup-Cause` | Любой другой `Cause` |
### Collector Interface (расширяемость)
```go
type Collector interface {
Type() string // "freeswitch" | "asterisk"
Connect() error // ESL auth / AMI login
Collect() (*PBXResult, error) // Сбор метрик
ListenHangup(onHangup func(bool)) // Подписка на hangup события
Close() // Закрытие соединения
}
```
Новый тип ноды (omnichannel, другой PBX) = новый файл `collector_<type>.go`, реализующий 5 методов интерфейса. Ничего в существующем коде менять не нужно.
### Template Configs
| Файл | Назначение | Тип |
|------|-----------|-----|
| `contrib/agent-uc.json` | Asterisk UC-ноды (05, 06, 66-69) | asterisk |
| `contrib/agent-sessip.json` | FreeSWITCH ses-sip (10.3.0.44) | freeswitch |
### Deployment
```bash
# 1. Собрать
make build-agent
# 2. Скопировать на ноду
scp bin/pulse-lets-go-agent devadmin@NODE_IP:/usr/local/bin/
# 3. Создать конфиг
scp contrib/agent-uc.json devadmin@NODE_IP:/etc/pulse-lets-go-agent/agent.json
# 4. Запустить
ssh devadmin@NODE_IP "pulse-lets-go-agent -config /etc/pulse-lets-go-agent/agent.json"
```
### Systemd Unit
```ini
# /etc/systemd/system/pulse-lets-go-agent.service
[Unit]
Description=Pulse Lets Go Agent — PBX metrics collector
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/pulse-lets-go-agent -config /etc/pulse-lets-go-agent/agent.json
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
### Verification
```bash
# На PBX-ноде — проверить лог агента:
journalctl -u pulse-lets-go-agent -f
# Ожидаем:
# [publisher] NATS подключён к nats://host:4222
# [ami] подключён к 127.0.0.1:6154 (для Asterisk)
# [agent] опубликована метрика: calls=0/150 load=0.28 cpu=99% status=ok
# На балансировщике — проверить что нода появилась:
curl http://localhost:8080/api/nodes | jq '.[] | {node_id, score, active_calls}'
```
### Troubleshooting
| Проблема | Причина | Решение |
|----------|---------|---------|
| `nats: no servers available` | NATS не запущен или недоступен | `systemctl start nats-server` |
| `ami auth rejected` | Неверный пароль или доступ | Проверить `/etc/asterisk/manager.conf` |
| `esl connect timeout` | ESL не слушает на порту | `fs_cli -x "load mod_event_socket"` |
| `calls=0/0` | max_calls не удалось получить | Указать `max_calls` в `agent.json` |
---
## Разработка
### Команды
| Команда | Описание |
|---------|----------|
| `make build` | Полная сборка (Go + SvelteKit) |
| `make dev` | Go backend + SvelteKit dev server (hot-reload) |
| `make test` | Go тесты с coverage |
| `make test-race` | Тесты с race detector |
| `make lint` | `go vet ./...` |
| `make fmt` | `go fmt ./...` |
| `make build-agent` | Сборка pulse-lets-go-agent |
| `make build-siptest` | Сборка SIP-тестера |
| `make emulate-*` | Эмуляция метрик (normal, overload, chaos, stale, ...) |
| `make siptest-*` | SIP-тестирование (uas, uac, full) |
### Стек
| Компонент | Технология |
|-----------|-----------|
| Backend | Go 1.26, stdlib `net/http` + `http.ServeMux` |
| Frontend | SvelteKit 2 + Tailwind CSS 4 + Lucide |
| Auth | JWT (golang-jwt/v5, HS256) + bcrypt |
| Message bus | NATS (nats.go) |
| Storage | JSON-файлы (data/) |
| ESL | Raw TCP (без внешних библиотек) |
### Структура проекта
```
pulse-lets-go/
├── cmd/
│ ├── pulse-lets-go/ # Точка входа (main.go)
│ ├── pulse-lets-go-agent/ # Агент сбора метрик (FS / Asterisk)
│ ├── emulator/ # Эмулятор метрик для тестирования
│ └── siptest/ # SIP-тестер для e2e проверок
├── internal/
│ ├── api/ # HTTP-хендлеры (auth, nodes, trunks, users, monitoring, ws, route)
│ ├── config/ # JSON-конфиги (atomic save, thread-safe)
│ ├── engine/ # Scoring engine + router (sync.RWMutex, best-node cache)
│ ├── esl/ # FreeSWITCH ESL client (raw TCP)
│ ├── ami/ # Asterisk AMI client (raw TCP)
│ ├── log/ # ASCII-логгер метрик с ротацией
│ ├── models/ # Все типы данных (NodeMetric, NodeState, Trunk, User, ...)
│ └── nats/ # NATS subscriber
├── contrib/ # route.lua, agent-*.json (FreeSWITCH dialplan + агент)
├── deploy/ # systemd unit
├── web/ # SvelteKit фронтенд
└── data/ # JSON-файлы времени выполнения (gitignored)
```
@@ -32,15 +32,12 @@ func (fc *FSCollector) Connect() error {
fc.client.ConnectWithRetry() fc.client.ConnectWithRetry()
// Ждём подключения с таймаутом // Ждём подключения с таймаутом
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
for !fc.client.IsConnected() { for !fc.client.IsConnected() {
timer.Reset(100 * time.Millisecond)
select { select {
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("esl connect timeout") return fmt.Errorf("esl connect timeout")
case <-timer.C: case <-time.After(100 * time.Millisecond):
// Выходим из цикла после ConnectWithRetry
return nil return nil
} }
} }
+3 -3
View File
@@ -11,11 +11,11 @@
// { // {
// "node_id": "uc06", // "node_id": "uc06",
// "type": "asterisk", // "type": "asterisk",
// "nats_url": "nats://balancer.lan:4222", // "nats_url": "nats://10.101.60.81:4222",
// "sip_gateway": "sip:uc-pbx.lan:5060", // "sip_gateway": "sip:10.101.60.115:5060",
// "interval_sec": 5, // "interval_sec": 5,
// "max_calls": 150, // "max_calls": 150,
// "ami": { "host": "127.0.0.1", "port": 6154, "username": "admin", "password": "changeme" } // "ami": { "host": "127.0.0.1", "port": 6154, "username": "ctt", "password": "cttpass" }
// } // }
package agent package agent
+3 -3
View File
@@ -11,11 +11,11 @@
// { // {
// "node_id": "uc06", // "node_id": "uc06",
// "type": "asterisk", // "type": "asterisk",
// "nats_url": "nats://balancer.lan:4222", // "nats_url": "nats://10.101.60.81:4222",
// "sip_gateway": "sip:uc-pbx.lan:5060", // "sip_gateway": "sip:10.101.60.115:5060",
// "interval_sec": 5, // "interval_sec": 5,
// "max_calls": 150, // "max_calls": 150,
// "ami": { "host": "127.0.0.1", "port": 6154, "username": "admin", "password": "changeme" } // "ami": { "host": "127.0.0.1", "port": 6154, "username": "ctt", "password": "cttpass" }
// } // }
package main package main
+23 -86
View File
@@ -5,6 +5,8 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -22,17 +24,15 @@ import (
filelog "github.com/pulse-lets-go/internal/log" filelog "github.com/pulse-lets-go/internal/log"
"github.com/pulse-lets-go/internal/models" "github.com/pulse-lets-go/internal/models"
"github.com/pulse-lets-go/internal/nats" "github.com/pulse-lets-go/internal/nats"
"github.com/pulse-lets-go/internal/util"
) )
func main() { func main() {
// Определяем рабочую директорию // Определяем рабочую директорию
// По умолчанию data/ — родительская папка от бинарника.
// Пример: /opt/pulse-lets-go/bin/pulse-lets-go → /opt/pulse-lets-go/data
exe, _ := os.Executable() exe, _ := os.Executable()
exeDir := filepath.Dir(exe) exeDir := filepath.Dir(exe)
dataDir := filepath.Join(filepath.Dir(exeDir), "data") dataDir := filepath.Join(exeDir, "data")
// Разрешаем переопределение через переменную окружения
if envDir := os.Getenv("PULSE_DATA_DIR"); envDir != "" { if envDir := os.Getenv("PULSE_DATA_DIR"); envDir != "" {
dataDir = envDir dataDir = envDir
} }
@@ -63,18 +63,6 @@ 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 {
@@ -88,34 +76,22 @@ func main() {
} }
defer sub.Close() defer sub.Close()
var apiHandler *api.API // Авто-создание balance-транков при первой метрике от новой ноды
// Авто-создание или обновление 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 i, t := range trunks { for _, 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
} }
} }
// Создаём новый balance-транк // Создаём новый balance-транк
now := time.Now().UTC() now := time.Now().UTC()
trunkID := "trk-" + util.RandomHex(6) trunkID := "trk-" + randomHex(6)
trunk := models.Trunk{ trunk := models.Trunk{
ID: trunkID, ID: trunkID,
Name: fmt.Sprintf("%s баланс", nodeID), Name: fmt.Sprintf("%s баланс", nodeID),
@@ -133,43 +109,11 @@ 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.JWTSecret, 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-клиент (опционально — если сконфигурирован)
@@ -233,20 +177,6 @@ func main() {
} }
}() }()
// 6.2 Periodic WS broadcast + FS stats poll
go func() {
ticker := time.NewTicker(5 * time.Second)
for range ticker.C {
apiHandler.PushRouteCache()
if eslClient != nil && eslClient.IsConnected() {
if stats, err := eslClient.FetchFsStats(); err == nil {
apiHandler.UpdateFsStats(stats)
}
}
apiHandler.BroadcastAll()
}
}()
// Подключаем раздачу SvelteKit статики, если директория существует // Подключаем раздачу SvelteKit статики, если директория существует
webDir := findWebDir(exeDir) webDir := findWebDir(exeDir)
if webDir != "" { if webDir != "" {
@@ -272,7 +202,6 @@ 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 метрики на фронт")
@@ -294,8 +223,6 @@ 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()
@@ -303,19 +230,20 @@ func main() {
log.Printf("[main] ошибка остановки HTTP сервера: %v", err) log.Printf("[main] ошибка остановки HTTP сервера: %v", err)
} }
sub.Close()
metricsLogger.Close() metricsLogger.Close()
log.Println("[main] pulse-lets-go остановлен") log.Println("[main] pulse-lets-go остановлен")
} }
// findWebDir ищет директорию со SvelteKit сборкой (web/build/). // findWebDir ищет директорию со SvelteKit сборкой (web/build/).
// Пробует по порядку: PULSE_WEB_DIR, CWD, ../web/build, рядом с бинарником. // Пробует по порядку: переменная окружения, рядом с бинарником, в корне проекта, CWD.
func findWebDir(exeDir string) string { func findWebDir(exeDir string) string {
candidates := []string{ candidates := []string{
os.Getenv("PULSE_WEB_DIR"), os.Getenv("PULSE_WEB_DIR"),
"web/build", // ./web/build (CWD — dev, systemd working dir) filepath.Join(exeDir, "web", "build"), // bin/web/build
filepath.Join(filepath.Dir(exeDir), "web", "build"), // ../web/build (корень проекта, dev с bin/) filepath.Join(filepath.Dir(exeDir), "web", "build"), // ../web/build (корень проекта)
filepath.Join(exeDir, "web", "build"), // рядом с бинарником (production install, make deploy) "web/build", // ./web/build (CWD)
} }
for _, d := range candidates { for _, d := range candidates {
if d == "" { if d == "" {
@@ -366,6 +294,15 @@ func withSPA(webDir string, apiHandler http.Handler) http.Handler {
}) })
} }
// randomHex генерирует случайную hex-строку заданной длины.
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())[:n]
}
return hex.EncodeToString(b)[:n]
}
// extractHostFromGateway извлекает хост из SIP URI. // extractHostFromGateway извлекает хост из SIP URI.
// "sip:mts-gw.lan:5060" → "mts-gw.lan" // "sip:mts-gw.lan:5060" → "mts-gw.lan"
func extractHostFromGateway(gateway string) string { func extractHostFromGateway(gateway string) string {
+11 -7
View File
@@ -1,16 +1,16 @@
// SIP тестер для e2e проверки pulse-lets-go + FreeSWITCH. // SIP тестер для e2e проверки pulse-lets-go + FreeSWITCH.
// Два режима: // Два режима:
//
// uas — отвечает 200 OK на SIP INVITE, симулирует PBX-ноду // uas — отвечает 200 OK на SIP INVITE, симулирует PBX-ноду
// uac — шлёт SIP INVITE в FreeSWITCH, симулирует оператора // uac — шлёт SIP INVITE в FreeSWITCH, симулирует оператора
// //
// Примеры: // Примеры:
//
// ./siptest -mode uas (PBX-нода на порту 5090) // ./siptest -mode uas (PBX-нода на порту 5090)
// ./siptest -mode uac -r 10 -l 100 (оператор, 10 CPS, до 100 одновременных) // ./siptest -mode uac -r 10 -l 100 (оператор, 10 CPS, до 100 одновременных)
package main package main
import ( import (
"crypto/rand"
"encoding/hex"
"flag" "flag"
"fmt" "fmt"
"log" "log"
@@ -19,8 +19,6 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/pulse-lets-go/internal/util"
) )
const ( const (
@@ -105,7 +103,7 @@ func buildSIPResponse(invite, localAddr string) string {
// Добавляем tag к To если его нет // Добавляем tag к To если его нет
to = strings.TrimSpace(to) to = strings.TrimSpace(to)
if !strings.Contains(to, ";tag=") { if !strings.Contains(to, ";tag=") {
to += ";tag=uas-" + util.RandomHex(4) to += ";tag=uas-" + randomHex(4)
} }
return fmt.Sprintf("SIP/2.0 200 OK\r\n"+ return fmt.Sprintf("SIP/2.0 200 OK\r\n"+
@@ -215,8 +213,8 @@ func runUAC(rate, limit, maxCalls int, dest, caller, host string) {
} }
func buildSIPInvite(dest, caller string, callNum int, srcAddr, fsAddr string) string { func buildSIPInvite(dest, caller string, callNum int, srcAddr, fsAddr string) string {
callID := fmt.Sprintf("call-%d-%s@%s", callNum, util.RandomHex(4), "127.0.0.1") callID := fmt.Sprintf("call-%d-%s@%s", callNum, randomHex(4), "127.0.0.1")
branch := "z9hG4bK-" + util.RandomHex(8) branch := "z9hG4bK-" + randomHex(8)
return fmt.Sprintf("INVITE sip:%s@%s SIP/2.0\r\n"+ return fmt.Sprintf("INVITE sip:%s@%s SIP/2.0\r\n"+
"Via: SIP/2.0/UDP %s;branch=%s\r\n"+ "Via: SIP/2.0/UDP %s;branch=%s\r\n"+
@@ -229,3 +227,9 @@ func buildSIPInvite(dest, caller string, callNum int, srcAddr, fsAddr string) st
"\r\n", "\r\n",
dest, fsAddr, srcAddr, branch, caller, callNum, dest, callID, caller, srcAddr) dest, fsAddr, srcAddr, branch, caller, callNum, dest, callID, caller, srcAddr)
} }
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)[:n]
}
+3 -3
View File
@@ -1,10 +1,10 @@
{ {
"node_id": "ses-pbx", "node_id": "ses-sip",
"type": "freeswitch", "type": "freeswitch",
"nats_url": "nats://balancer.lan:4222", "nats_url": "nats://10.101.60.81:4222",
"interval_sec": 5, "interval_sec": 5,
"max_calls": 250, "max_calls": 250,
"sip_gateway": "sip:ses-pbx.lan:5060", "sip_gateway": "sip:10.3.0.44:5060",
"sip_gateway_auto": false, "sip_gateway_auto": false,
"failure_window": 1000, "failure_window": 1000,
"esl": { "esl": {
+5 -5
View File
@@ -1,16 +1,16 @@
{ {
"node_id": "uc-pbx", "node_id": "uc06",
"type": "asterisk", "type": "asterisk",
"nats_url": "nats://balancer.lan:4222", "nats_url": "nats://10.101.60.81:4222",
"interval_sec": 5, "interval_sec": 5,
"max_calls": 150, "max_calls": 150,
"sip_gateway": "sip:uc-pbx.lan:5060", "sip_gateway": "sip:10.101.60.115:5060",
"sip_gateway_auto": false, "sip_gateway_auto": false,
"failure_window": 1000, "failure_window": 1000,
"ami": { "ami": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 6154, "port": 6154,
"username": "admin", "username": "ctt",
"password": "changeme" "password": "cttpass"
} }
} }
+73 -115
View File
@@ -3,7 +3,7 @@
Телефонный балансировщик нагрузки на базе FreeSWITCH + NATS. Телефонный балансировщик нагрузки на базе FreeSWITCH + NATS.
┌─ Размещение ─────────────────────────────────────────────────────┐ ┌─ Размещение ─────────────────────────────────────────────────────┐
│ /opt/pulse-lets-go/contrib/route.lua │ │ /etc/freeswitch/scripts/route.lua
│ │ │ │
│ ┌─ Использование в dialplan ────────────────────────────────────┐│ │ ┌─ Использование в dialplan ────────────────────────────────────┐│
│ │ <extension name="route_call"> ││ │ │ <extension name="route_call"> ││
@@ -13,30 +13,24 @@
│ │ </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"/> (только для уровня 3) ││ │ │ mod_curl — <load module="mod_curl"/> в modules.conf.xml ││
│ │ cjson — входит в стандартную поставку FS Lua ││ │ │ cjson — входит в стандартную поставку FS Lua ││
│ └───────────────────────────────────────────────────────────────┘│ │ └───────────────────────────────────────────────────────────────┘│
└───────────────────────────────────────────────────────────────────┘ └───────────────────────────────────────────────────────────────────┘
Логика: Логика:
1. Попробовать FS global variable pulse_route_json (ESL push) 1. Извлечь caller_id + destination из переменных сессии
2. Попробовать файловый кэш 2. HTTP GET /api/route?caller_id=X&dest=Y&ingress_trunk=Z
3. HTTP API /api/route (fallback) 3. Разобрать JSON-ответ
4. Разобрать JSON → bridge на sip_gateway 4. При fallback=true → bridge на fallback gateway (оператор)
5. При ошибке → кладём трубку с NORMAL_TEMPORARY_FAILURE
6. Нормальный маршрут → bridge на sip_gateway выбранной ноды
]]-- ]]--
-- =================================================================== -- ===================================================================
@@ -46,16 +40,13 @@
-- 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-запроса в секундах -- Таймаут HTTP-запроса в секундах (через параметр curl)
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 для вызова curl -- API FreeSWITCH для вызова mod_curl
local api = freeswitch.API() local api = freeswitch.API()
-- =================================================================== -- ===================================================================
@@ -67,45 +58,9 @@ local function log(level, msg)
freeswitch.consoleLog(level, "[route] " .. msg .. "\n") freeswitch.consoleLog(level, "[route] " .. msg .. "\n")
end end
-- Безопасная отправка трубки с кодом причины -- Выполнить HTTP GET и вернуть тело ответа (без заголовков).
local function safe_hangup(cause) -- mod_curl API возвращает сырой HTTP-ответ: заголовки + \r?\n\r?\n + тело.
if session:ready() then local function http_get(url)
session:hangup(cause or "NORMAL_TEMPORARY_FAILURE")
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 cmd = "curl " .. url
local raw = api:execute("curl", cmd) local raw = api:execute("curl", cmd)
@@ -113,56 +68,25 @@ local function try_http(caller_id, dest, ing_trunk)
return nil, "пустой ответ от curl" return nil, "пустой ответ от curl"
end end
-- Отделяем заголовки от тела (mod_curl возвращает сырой HTTP) -- Отделяем заголовки от тела
-- Ищем двойной перенос строки (CRLF или LF)
local body = raw:match("\r?\n\r?\n(.+)") local body = raw:match("\r?\n\r?\n(.+)")
if not body then if not body then
body = raw -- Если нет заголовков — весь ответ это тело
return raw, nil
end end
return body, nil return body, nil
end end
-- Разобрать JSON и выполнить bridge. -- Безопасная отправка трубки с кодом причины
-- Возвращает true если звонок обработан (bridge или error). local function safe_hangup(cause)
local function process_route_json(json_str, source) if session:ready() then
if not json_str then session:hangup(cause or "NORMAL_TEMPORARY_FAILURE")
return false
end 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 end
-- =================================================================== -- ===================================================================
-- Основная логика (трёхуровневый кэш) -- Основная логика
-- =================================================================== -- ===================================================================
-- Извлекаем параметры вызова -- Извлекаем параметры вызова
@@ -170,29 +94,63 @@ 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 ""
-- Уровень 1: FS global variable (ESL push, sub-µs) -- Собираем URL
local json_str = try_global_cache() local query = string.format("caller_id=%s&dest=%s", caller_id, dest)
if process_route_json(json_str, "global") then if ing_trunk ~= "" then
return query = query .. "&ingress_trunk=" .. ing_trunk
end end
local url = BALANCER_URL .. "/api/route?" .. query
-- Уровень 2: файловый кэш (локальный, ~50µs) log("info", "запрос маршрута: " .. url)
json_str = try_file_cache()
if process_route_json(json_str, "file") then
return
end
-- Уровень 3: HTTP API (fallback, ~1-5ms) -- Запрашиваем маршрут у pulse-lets-go
log("info", "запрос маршрута HTTP: " .. BALANCER_URL .. "/api/route?caller_id=" .. caller_id .. "&dest=" .. dest) local body, curl_err = http_get(url)
local body, http_err = try_http(caller_id, dest, ing_trunk)
if not body then if not body then
log("err", "все уровни кэша недоступны: " .. (http_err or "unknown")) log("err", "ошибка HTTP: " .. (curl_err or "unknown"))
safe_hangup() safe_hangup()
return return
end end
if not process_route_json(body, "http") then -- Парсим JSON
log("err", "непредвиденный формат ответа балансировщика") local ok, route = pcall(cjson.decode, body)
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()
+6 -4
View File
@@ -1,14 +1,16 @@
[Unit] [Unit]
Description=pulse-lets-go — Telephone Load Balancer Description=pulse-lets-go — Telephone Load Balancer
Documentation=https://github.com/pulse-lets-go Documentation=https://github.com/pulse-lets-go
After=network.target After=network.target nats.service
Wants=nats.service
[Service] [Service]
Type=simple Type=simple
User=pulse User=pulse
Group=pulse Group=pulse
ExecStart=/opt/pulse-lets-go/bin/pulse-lets-go ExecStart=/usr/local/bin/pulse-lets-go
WorkingDirectory=/opt/pulse-lets-go WorkingDirectory=/usr/local/share/pulse-lets-go
Environment=PULSE_DATA_DIR=/etc/pulse-lets-go
Restart=always Restart=always
RestartSec=5 RestartSec=5
@@ -17,7 +19,7 @@ NoNewPrivileges=yes
PrivateTmp=yes PrivateTmp=yes
ProtectSystem=strict ProtectSystem=strict
ProtectHome=yes ProtectHome=yes
ReadWritePaths=/opt/pulse-lets-go/data /opt/pulse-lets-go/log ReadWritePaths=/etc/pulse-lets-go /var/log/pulse-lets-go
# Логирование # Логирование
StandardOutput=journal StandardOutput=journal
-178
View File
@@ -1,178 +0,0 @@
# Metrics Agent (pulse-lets-go-agent)
The agent runs on each PBX node (FreeSWITCH or Asterisk), collects metrics, and publishes them to NATS every 5 seconds.
## Architecture
```
┌─────────────────────┐ pulse.metrics.<id> ┌─────────────────────┐
│ pulse-lets-go-agent│──────────────────────────▶ │ NATS Server │
│ (on each PBX node) │ every 5 seconds │ (central) │
├─────────────────────┤ └────────┬────────────┘
│ FreeSWITCH (ESL) │ │
│ Asterisk (AMI) │ ▼
│ System (/proc) │ ┌─────────────────┐
└─────────────────────┘ │ pulse-lets-go │
│ (scoring engine) │
└─────────────────┘
```
## NATS Message Format
```json
{
"node_id": "pbx-01",
"ts": 1719000000,
"status": "ok",
"active_calls": 42,
"max_calls": 250,
"idle_cpu": 35,
"load_avg": 0.85,
"call_failure_rate": 0.5,
"sip_gateway": "sip:pbx-01.lan:5060"
}
```
- `status`: `"ok"` or any error string (triggers lethal)
- `call_failure_rate`: 0.0..100.0 (percentage)
- `sip_gateway`: optional, triggers auto-trunk creation on first metric
## Installation
```bash
make build-agent
# → bin/pulse-lets-go-agent (static binary, ~9 MB)
```
## Configuration (agent.json)
```json
{
"node_id": "pbx-01",
"type": "asterisk",
"nats_url": "nats://balancer.host:4222",
"nats_user": "",
"nats_password": "",
"interval_sec": 5,
"max_calls": 150,
"sip_gateway": "sip:pbx-01.lan:5060",
"sip_gateway_auto": false,
"failure_window": 1000,
"esl": { "host": "127.0.0.1", "port": 8021, "password": "ClueCon" },
"ami": { "host": "127.0.0.1", "port": 6154, "username": "admin", "password": "changeme" }
}
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `node_id` | string | — | Unique node ID (pbx-01, pbx-02) |
| `type` | string | — | `freeswitch` or `asterisk` |
| `nats_url` | string | — | NATS server URL |
| `nats_user` | string | `""` | NATS username |
| `nats_password` | string | `""` | NATS password |
| `interval_sec` | int | `5` | Metric publish interval |
| `max_calls` | int | `250` | Node capacity (fallback) |
| `sip_gateway` | string | `""` | SIP address for auto-trunk creation |
| `sip_gateway_auto` | bool | `false` | Auto-detect SIP from PBX |
| `failure_window` | int | `1000` | Window size for call_failure_rate |
| `esl.*` | object | — | FreeSWITCH ESL settings (`host`, `port`, `password`) |
| `ami.*` | object | — | Asterisk AMI settings (`host`, `port`, `username`, `password`) |
## Collectors
| Collector | Source | Metrics | PBX |
|-----------|--------|---------|-----|
| `freeswitch` | ESL: `show channels count`, `json status`, `eval $${idle_cpu}` | active_calls, max_calls, idle_cpu | ✅ |
| `asterisk` | AMI: `CoreShowChannels`, `CoreSettings`, Hangup events | active_calls, max_calls, call_failure_rate | ✅ |
| `system` | `/proc/loadavg`, `/proc/stat` | load_avg, idle_cpu (fallback) | ✅ |
### Call Failure Rate
Sliding window of last N (default 1000) completed calls.
| Outcome | FS (ESL) | Asterisk (AMI) |
|---------|----------|---------------|
| Success | `CHANNEL_HANGUP` + `Hangup-Cause: NORMAL_CLEARING` | `Hangup` + `Cause: 16` |
| Failure | Any other `Hangup-Cause` | Any other `Cause` |
## Collector Interface (extensibility)
```go
type Collector interface {
Type() string // "freeswitch" | "asterisk"
Connect() error // ESL auth / AMI login
Collect() (*PBXResult, error) // Collect metrics
ListenHangup(onHangup func(bool)) // Subscribe to hangup events
Close() // Close connection
}
```
New PBX type = new `collector_<type>.go` implementing 5 interface methods. No existing code changes needed.
## Template Configs
| File | Target | Type |
|------|--------|------|
| `contrib/agent-uc.json` | Asterisk UC nodes | asterisk |
| `contrib/agent-sessip.json` | FreeSWITCH ses-sip | freeswitch |
## Deployment
```bash
# 1. Build
make build-agent
# 2. Copy to node
scp bin/pulse-lets-go-agent admin@pbx-node:/usr/local/bin/
# 3. Create config
scp contrib/agent-uc.json admin@pbx-node:/etc/pulse-lets-go-agent/agent.json
# 4. Start
ssh admin@pbx-node "pulse-lets-go-agent -config /etc/pulse-lets-go-agent/agent.json"
```
## Systemd Unit
```ini
# /etc/systemd/system/pulse-lets-go-agent.service
[Unit]
Description=Pulse Lets Go Agent — PBX metrics collector
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/pulse-lets-go-agent -config /etc/pulse-lets-go-agent/agent.json
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
## Verification
```bash
# On PBX node — check agent log:
journalctl -u pulse-lets-go-agent -f
# Expected:
# [publisher] NATS connected to nats://host:4222
# [ami] connected to 127.0.0.1:6154 (for Asterisk)
# [agent] published metric: calls=0/150 load=0.28 cpu=99% status=ok
# On balancer — check node appeared:
curl http://localhost:8080/api/nodes | jq '.[] | {node_id, score, active_calls}'
```
## Troubleshooting
| Problem | Cause | Solution |
|---------|-------|----------|
| `nats: no servers available` | NATS not running or unreachable | `systemctl start nats-server` |
| `ami auth rejected` | Wrong password or access | Check `/etc/asterisk/manager.conf` |
| `esl connect timeout` | ESL not listening on port | `fs_cli -x "load mod_event_socket"` |
| `calls=0/0` | max_calls not obtained | Set `max_calls` in `agent.json` |
-315
View File
@@ -1,315 +0,0 @@
# API Reference
## Authentication
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/login` | | Login: `{"username":"admin","password":"admin"}``access_token` + `refresh_token` |
| `POST` | `/api/auth/refresh` | | Refresh access token using `refresh_token` |
All endpoints except `/api/route`, `/api/health/*`, `/api/monitoring/*` require `Authorization: Bearer <jwt>`.
Roles: `admin` (full access), `viewer` (read-only nodes/metrics).
## Core
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/route` | | Route a call. Query: `caller_id`, `dest`, `ingress_trunk` |
| `PUT` | `/api/route/mode` | admin | Set balancing mode: `{"mode":"best\|weighted_random"}` — см. [Routing](routing.md#balancing-modes) |
| `GET` | `/api/health` | | Full health status with connections and stats |
| `GET` | `/api/health/live` | | Liveness probe — always 200 `{"status":"alive"}` |
| `GET` | `/api/health/ready` | | Readiness — 200 `{"status":"ready"}` или 503 `{"reason":"nats_disconnected"}` |
### Health Response
```json
{
"status": "ok",
"version": "1.0.0",
"connections": {
"nats": "connected|disconnected",
"esl": {
"status": "disabled|connected|disconnected",
"host": "127.0.0.1",
"uptime_seconds": 3600,
"reconnects": 0,
"gateway_ops": 42,
"events_recv": 15000
}
},
"stats": {
"total_nodes": 3,
"healthy_nodes": 2,
"route_requests": 5000,
"route_fallbacks": 10,
"uptime_seconds": 7200
}
}
```
### Route Response (success)
```json
{ "node_id": "pbx-03", "score": 88, "sip_gateway": "sip:pbx03.lan:5060", "matched_rule": "none" }
```
`matched_rule` — имя сработавшего routing rule, либо `"none"` если применился глобальный scoring.
### Route Mode Response
`PUT /api/route/mode` возвращает текущий режим:
```json
{ "mode": "best" }
```
### Route Response (fallback — all nodes unhealthy)
```json
{
"fallback": true,
"sip_gateway": "sip:operator.lan:5060",
"reason": "all_nodes_unhealthy",
"matched_rule": "my-pool-rule",
"nodes": [
{ "id": "pbx-01", "score": -100, "reason": "disabled", "status": "disabled" },
{ "id": "pbx-02", "score": -1, "reason": "stale", "status": "stale" },
{ "id": "pbx-03", "score": 70, "reason": "", "status": "healthy" }
]
}
```
### Route Response (no nodes registered)
```json
{ "error": "no_nodes_registered", "matched_rule": "none" }
```
## Nodes
| Method | Path | Role | Description |
|--------|------|------|-------------|
| `GET` | `/api/nodes` | admin/viewer | List all nodes with scores and state |
| `GET` | `/api/nodes/{id}/metrics` | admin/viewer | Metrics history (ring buffer, ~30 min) |
| `PUT` | `/api/nodes/{id}/toggle` | admin | `{"disabled":true, "reason":"..."}` — returns updated `NodeInfo` |
### NodeInfo (GET /api/nodes)
```json
{
"node_id": "pbx-01",
"ts": 1719000000,
"status": "ok",
"active_calls": 42,
"max_calls": 250,
"idle_cpu": 35.0,
"load_avg": 0.85,
"call_failure_rate": 0.5,
"disabled": false,
"disabled_reason": "",
"score": 88.0,
"lethal_reason": "",
"is_stale": false,
"seconds_ago": 3.0
}
```
### Node Metrics (GET /api/nodes/{id}/metrics)
История метрик ноды из ring buffer (~30 мин, шаг 5 с):
```json
{
"node_id": "pbx-01",
"snapshots": [
{ "active_calls": 42, "load_avg": 0.85, "score": 88.0, ... }
],
"count": 360
}
```
## Trunks
| Method | Path | Role | Description |
|--------|------|------|-------------|
| `GET` | `/api/trunks` | admin | All trunks. Query: `type=ingress\|balance\|fallback` |
| `POST` | `/api/trunks` | admin | Create trunk |
| `PUT` | `/api/trunks/{id}` | admin | Update trunk |
| `DELETE` | `/api/trunks/{id}` | admin | Delete trunk |
Trunk model:
```json
{
"id": "trk-001",
"name": "pbx-03 balance",
"type": "ingress|balance|fallback",
"node_id": "pbx-03",
"gateway": "sip:pbx03.lan:5060",
"codecs": ["PCMU", "PCMA"],
"context": "default",
"enabled": true,
"description": "",
"created_at": "...",
"updated_at": "..."
}
```
Trunk types:
| Type | Purpose | Quantity |
|------|---------|----------|
| `ingress` | Incoming trunk (where calls come from) | 1..N |
| `balance` | Destination trunk (bound to a Node) | 1..N |
| `fallback` | Operator fallback (when all balance score < 0) | exactly 1 |
## Routing Rules
| Method | Path | Role | Description |
|--------|------|------|-------------|
| `GET` | `/api/routing/rules` | admin/viewer | Список правил с счётчиками hits |
| `POST` | `/api/routing/rules` | admin | Создать правило |
| `PUT` | `/api/routing/rules/{id}` | admin | Обновить правило |
| `DELETE` | `/api/routing/rules/{id}` | admin | Удалить правило |
| `PUT` | `/api/routing/rules/{id}/toggle` | admin | Вкл/выкл: `{"enabled":true}` |
| `PUT` | `/api/routing/rules/reorder` | admin | Новый порядок: `["rr-abc", "rr-def", ...]` |
Поля правила — см. [docs/routing.md](routing.md#routing-rules). Ответы содержат полную модель правила.
## Users (admin only)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/users` | List users (без password_hash) |
| `POST` | `/api/users` | `{"username":"...","password":"...","role":"admin\|viewer"}` |
| `PUT` | `/api/users/{id}` | Partial update: `{"username":"...","role":"viewer"}` |
| `DELETE` | `/api/users/{id}` | Удалить (204 No Content) |
Защиты:
- `user-01` нельзя удалить или понизить до viewer
- Нельзя удалить последнего администратора
- GET-ответ исключает поле `password_hash`
## Monitoring (API-key auth)
Use `X-API-Key` header (not JWT).
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/monitoring/zabbix` | JSON для Zabbix LLD |
| `GET` | `/api/monitoring/prometheus` | `text/plain` метрики для Prometheus |
### Zabbix Response
```json
{
"data": [
{
"{#NODE_ID}": "pbx-01",
"{#STATUS}": "ok",
"{#ACTIVE_CALLS}": 42,
"{#MAX_CALLS}": 250,
"{#IDLE_CPU}": 35.0,
"{#LOAD_AVG}": 0.85,
"{#CALL_FAILURE_RATE}": 0.5,
"{#SCORE}": 88.0,
"{#DISABLED}": false,
"{#IS_STALE}": false,
"{#SECONDS_AGO}": 3.0
}
],
"route_requests_total": 5000,
"route_fallbacks_total": 42,
"uptime_seconds": 7200
}
```
### Prometheus Metrics
| Метрика | Type | Labels |
|---------|------|--------|
| `pulse_route_requests_total` | counter | |
| `pulse_route_fallbacks_total` | counter | |
| `pulse_nodes_total` | gauge | |
| `pulse_nodes_healthy` | gauge | |
| `pulse_uptime_seconds` | gauge | |
| `pulse_node_active_calls` | gauge | `node` |
| `pulse_node_max_calls` | gauge | `node` |
| `pulse_node_idle_cpu` | gauge | `node` |
| `pulse_node_load_avg` | gauge | `node` |
| `pulse_node_call_failure_rate` | gauge | `node` |
| `pulse_node_score` | gauge | `node` |
| `pulse_node_seconds_ago` | gauge | `node` |
| `pulse_node_disabled` | gauge | `node` (0/1) |
| `pulse_node_stale` | gauge | `node` (0/1) |
| `pulse_node_status_ok` | gauge | `node` (0/1) |
## WebSocket
| Method | Path | Description |
|--------|------|-------------|
| `WS` | `/ws/metrics` | Real-time metrics (JWT в query `?token=`) |
Типы сообщений (поле `type`):
| Тип | Payload | Описание |
|-----|---------|----------|
| `nodes_update` | `[]NodeInfo` | Обновление метрик всех нод (каждые 5 с) |
| `balancer_update` | `BalancerInfo` | Состояние балансировщика (uptime, mode, ESL, NATS) |
| `gateway_update` | `{"gateway","trunk_id","status"}` | Статус FS-gateway (up/down) |
## Examples
```bash
# Route (no auth)
curl 'http://localhost:8080/api/route?caller_id=74951234567&dest=123&ingress_trunk=trk-001'
# → {"node_id":"pbx-03","score":88,"sip_gateway":"sip:pbx03.lan:5060"}
# Login
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin"}' | jq -r '.access_token')
# Nodes with JWT
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/nodes
# Toggle node (admin only)
curl -X PUT http://localhost:8080/api/nodes/pbx-01/toggle \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"disabled":true,"reason":"maintenance"}'
# Route mode (admin)
curl -X PUT http://localhost:8080/api/route/mode \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"mode":"weighted_random"}'
# → {"mode":"weighted_random"}
# Routing rules (admin)
curl -X POST http://localhost:8080/api/routing/rules \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name":"VIP pool",
"priority":10,
"match_caller_id":"^7495",
"action_type":"pool",
"action_pool_ids":["pbx-01","pbx-02"]
}'
# Toggle rule (admin)
curl -X PUT http://localhost:8080/api/routing/rules/rr-abc123/toggle \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"enabled":false}'
# Reorder rules (admin)
curl -X PUT http://localhost:8080/api/routing/rules/reorder \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '["rr-def456", "rr-abc123"]'
# Prometheus metrics (API-key)
curl -H "X-API-Key: $(jq -r '.monitoring_api_key' data/config.json)" \
http://localhost:8080/api/monitoring/prometheus
```
-117
View File
@@ -1,117 +0,0 @@
# Architecture
## Overview
pulse-lets-go — балансировщик телефонной нагрузки на базе FreeSWITCH + NATS.
Агенты PBX отправляют метрики в NATS каждые 5 секунд. Балансировщик вычисляет weighted score готовности каждой ноды, кэширует лучшую и отдаёт её FreeSWITCH через `/api/route` для следующего звонка.
## Data Flow
```
┌─────────────────────┐ ESL/AMI ┌──────────────────────┐
│ PBX-01 │──────────▶│ pulse-lets-go-agent │
│ (FS or Asterisk) │ collect │ (on each PBX node) │
├─────────────────────┤ ├──────────────────────┤
│ PBX-02 │──────────▶│ pulse-lets-go-agent │
│ (FS or Asterisk) │ ├──────────────────────┤
├─────────────────────┤ ├──────────────────────┤
│ PBX-N │──────────▶│ pulse-lets-go-agent │
│ (FS or Asterisk) │ └─────────┬────────────┘
└─────────────────────┘ │ pulse.metrics.<id>
│ every 5s
┌──────────────┐
│ NATS Server │
│ nats://:4222 │
└──────┬───────┘
┌──────────────────────┐
│ pulse-lets-go │
│ │
│ ┌────────────────┐ │
│ │ NATS Sub │ │
│ │ (pulse.metrics)│ │
│ └────────┬───────┘ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Engine │ │
│ │ (scoring) │──│──▶ /api/route
│ └────────┬───────┘ │ ↓
│ ▼ │ FreeSWITCH bridge
│ ┌────────────────┐ │ sip:pbx-03:5060
│ │ REST API │──│──▶ /api/nodes
│ │ (JWT auth) │ │ /api/trunks
│ └────────┬───────┘ │ /api/users
│ ▼ │
│ ┌────────────────┐ │
│ │ WebSocket │──│──▶ SvelteKit UI
│ └────────────────┘ │ (ws://:8080/ws/metrics)
│ ┌────────────────┐ │
│ │ ESL Client │──│──▶ FreeSWITCH ESL
│ │ (gateways) │ │ :8021
│ └────────────────┘ │
│ ┌────────────────┐ │
│ │ Monitoring │──│──▶ /api/monitoring/prometheus
│ │ (API-key) │ │ /api/monitoring/zabbix
│ └────────────────┘ │
└──────────────────────┘
```
## Components
| Component | Role |
|-----------|------|
| **pulse-lets-go** | Central balancer: NATS subscriber, scoring engine, REST API, WebSocket hub, ESL client |
| **pulse-lets-go-agent** | Per-PBX metrics collector (FreeSWITCH ESL / Asterisk AMI) |
| **NATS** | Message bus — `pulse.metrics.<node_id>` subject |
| **FreeSWITCH** | Softswitch — uses `route.lua` + `mod_curl` to query `/api/route` |
| **SvelteKit UI** | Dashboard with real-time metrics via WebSocket |
## Tech Stack
| Component | Technology |
|-----------|-----------|
| Backend | Go 1.26, stdlib `net/http` + `http.ServeMux` |
| Frontend | SvelteKit 2 + Tailwind CSS 4 + Lucide |
| Auth | JWT (golang-jwt/v5, HS256) + bcrypt |
| Message bus | NATS (nats.go) |
| Storage | JSON files (`data/`) |
| ESL | Raw TCP (no external libraries) |
| Deployment | Single binary + systemd |
## Data Flow Summary
1. **Agent** collects metrics from PBX (ESL/AMI + /proc) every 5s
2. **Agent** publishes `pulse.metrics.<node_id>` to NATS
3. **Balancer** NATS subscriber receives metrics, updates in-memory node state
4. **Scoring engine** recalculates scores, caches best node
5. **FreeSWITCH** dialplan calls `/api/route` via `mod_curl`
6. **Balancer** returns best node's `sip_gateway` (O(1) from cache)
7. **FreeSWITCH** bridges the call to the returned gateway
## Project Structure
```
pulse-lets-go/
├── cmd/
│ ├── pulse-lets-go/ # Main entry point
│ ├── pulse-lets-go-agent/ # Metrics collector agent (FS / Asterisk)
│ ├── emulator/ # Metrics emulator for testing
│ └── siptest/ # SIP tester for e2e checks
├── internal/
│ ├── api/ # HTTP handlers (auth, nodes, trunks, users, monitoring, ws, route)
│ ├── config/ # JSON config manager (atomic save, thread-safe)
│ ├── engine/ # Scoring engine + router (sync.RWMutex, best-node cache)
│ ├── esl/ # FreeSWITCH ESL client (raw TCP)
│ ├── ami/ # Asterisk AMI client (raw TCP)
│ ├── log/ # ASCII metrics logger with rotation
│ ├── models/ # Data types (NodeMetric, NodeState, Trunk, User, ...)
│ └── nats/ # NATS subscriber
├── contrib/ # route.lua, agent configs
├── deploy/ # systemd unit
├── web/ # SvelteKit frontend
├── data/ # Runtime JSON files (gitignored)
└── docs/ # Documentation
```
-176
View File
@@ -1,176 +0,0 @@
# Deployment
## Quick Deploy
```bash
make deploy # full deploy to /opt/pulse-lets-go
make install-systemd # install systemd unit
```
## Directory Structure (after deploy)
```
/opt/pulse-lets-go/
├── bin/pulse-lets-go # Binary
├── data/
│ ├── config.json # Configuration
│ ├── trunks.json # Trunks
│ └── users.json # Users
├── web/ # SvelteKit static files
└── log/
└── metrics.YYYY-MM-DD.log # ASCII metrics log
```
## systemd Unit
```ini
# deploy/pulse-lets-go.service
[Unit]
Description=Pulse Lets Go — Telephone Load Balancer
After=network.target
[Service]
User=pulse
Group=pulse
ExecStart=/opt/pulse-lets-go/bin/pulse-lets-go
WorkingDirectory=/opt/pulse-lets-go
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/pulse-lets-go/data /opt/pulse-lets-go/log
[Install]
WantedBy=multi-user.target
```
## Security
- `NoNewPrivileges=true` — privilege escalation blocked
- `ProtectSystem=strict` — system directories read-only
- `ProtectHome=yes` — home directory isolated
- `PrivateTmp=true` — isolated /tmp
- JWT secret and API-key auto-generated on first run
## Production Deployment (8 Steps)
### Step 1 — Deploy to Server
```bash
make deploy
scp -r /opt/pulse-lets-go admin@balancer.lan:/opt/
scp contrib/route.lua admin@balancer.lan:/tmp/
```
### Step 2 — NATS Server (if not installed)
```bash
ssh admin@balancer.lan
sudo mkdir -p /opt/nats
# Install nats-server from repository or copy binary
# Run: nats-server -p 4222 -D &
```
### Step 3 — Deploy Agent on PBX Nodes
Repeat for each UC/Asterisk node:
```bash
# Copy binary and config to each PBX:
scp bin/pulse-lets-go-agent admin@pbx-node:/usr/local/bin/
scp contrib/agent-uc.json admin@pbx-node:/etc/pulse-lets-go-agent/agent.json
# Start:
ssh admin@pbx-node "systemctl start pulse-lets-go-agent"
# Check log:
# journalctl -u pulse-lets-go-agent -f
# → [ami] connected to 127.0.0.1:6154
# → [agent] published metric: calls=0/150 load=0.28 cpu=99% status=ok
```
### Step 4 — route.lua in FS Scripts
```bash
sudo mkdir -p /etc/freeswitch/scripts
sudo cp /tmp/route.lua /etc/freeswitch/scripts/
```
### Step 5 — Dialplan
Add `pulse_route` extension in `/etc/freeswitch/dialplan/default.xml` before other extensions:
```xml
<extension name="pulse_route">
<condition field="destination_number" expression="^(.*)$">
<action application="set" data="balancer_url=http://127.0.0.1:8080"/>
<action application="lua" data="route.lua"/>
</condition>
</extension>
```
```bash
sudo fs_cli -x "reloadxml"
```
### Step 6 — Create Balance Trunks
Trunks are created automatically when an agent sends its first metric with `sip_gateway`. Manual creation:
```bash
curl -X POST .../api/trunks \
-d '{"name":"pbx-01","type":"balance","node_id":"pbx-01","gateway":"sip:pbx-01.lan:5060","enabled":true}'
```
### Step 7 — Start pulse-lets-go
```bash
# config.json auto-creates on first run in /opt/pulse-lets-go/data/
# Add esl block to config.json:
# "esl": {"host": "127.0.0.1", "port": 8021, "password": "ClueCon"}
/opt/pulse-lets-go/bin/pulse-lets-go
```
### Step 8 — Verify
```bash
curl http://127.0.0.1:8080/api/health | jq '.connections'
# → {"nats":"connected","esl":"connected"}
curl 'http://127.0.0.1:8080/api/route?caller_id=123&dest=456'
# → {"node_id":"pbx-03","score":88,"sip_gateway":...}
```
## Rollback
```bash
# 1. Remove pulse_route from dialplan
sudo sed -i '/pulse_route/,/<\/extension>/d' /etc/freeswitch/dialplan/default.xml
sudo fs_cli -x "reloadxml"
# 2. Stop pulse-lets-go
sudo systemctl stop pulse-lets-go
# Calls continue through original dialplan without balancer.
```
## Access Permissions (production)
| Operation | Required Permissions |
|-----------|---------------------|
| Read FS configs | root or freeswitch |
| `fs_cli -x` commands | root or freeswitch |
| ESL (port 8021) | ACL in `event_socket.conf.xml` |
| Install software | root |
| Write to `/etc/freeswitch/scripts/` | root |
| `reloadxml` | root or freeswitch |
## Tested Versions
| Component | Version |
|-----------|---------|
| FreeSWITCH | 1.10.12+ |
| NATS Server | 2.10+ |
| Go | 1.26 |
| OS | Linux (CentOS 7+, AlmaLinux, Arch) |
-119
View File
@@ -1,119 +0,0 @@
# Development
## Requirements
- Go 1.26+
- Node.js 22+
- NATS Server (auto-downloaded via `make nats`)
## Build
```bash
git clone git@git.totmin.ru:en2zmax/pulse-lets-go.git
cd pulse-lets-go
make build
```
## Makefile Commands
| Command | Description |
|---------|-------------|
| `make build` | Full build (Go + SvelteKit) |
| `make dev` | Go backend + SvelteKit dev server (hot-reload) |
| `make test` | Go tests with coverage |
| `make test-race` | Tests with race detector |
| `make lint` | `go vet ./...` |
| `make fmt` | `go fmt ./...` |
| `make build-agent` | Build pulse-lets-go-agent |
| `make build-siptest` | Build SIP tester |
| `make emulate-*` | Metrics emulation (normal, overload, chaos, stale, ...) |
| `make siptest-*` | SIP testing (uas, uac, full) |
| `make nats` | Start local NATS server |
## Running Locally
```bash
# Terminal 1: NATS
make nats
# Terminal 2: balancer
./bin/pulse-lets-go
# Terminal 3: metrics emulator (3 healthy nodes)
make emulate-normal
```
## Code Style
Before committing:
```bash
make fmt
make lint
make test
```
## Project Structure
```
pulse-lets-go/
├── cmd/
│ ├── pulse-lets-go/ # Main entry point
│ ├── pulse-lets-go-agent/ # Metrics collector (FS / Asterisk)
│ ├── emulator/ # Metrics emulator for testing
│ └── siptest/ # SIP tester for e2e checks
├── internal/
│ ├── api/ # HTTP handlers (auth, nodes, trunks, users, monitoring, ws, route)
│ ├── config/ # JSON config manager (atomic save, thread-safe)
│ ├── engine/ # Scoring engine + router (sync.RWMutex, best-node cache)
│ ├── esl/ # FreeSWITCH ESL client (raw TCP)
│ ├── ami/ # Asterisk AMI client (raw TCP)
│ ├── log/ # ASCII metrics logger with rotation
│ ├── models/ # Data types (NodeMetric, NodeState, Trunk, User, ...)
│ └── nats/ # NATS subscriber
├── contrib/ # route.lua, agent configs
├── deploy/ # systemd unit
├── web/ # SvelteKit frontend
└── data/ # Runtime JSON files (gitignored)
```
## Adding a New Endpoint
1. Define model in `internal/models/types.go`
2. Implement handler in `internal/api/<name>.go`
3. Register route in `internal/api/router.go` with required middleware (auth, admin)
4. Add tests in `internal/engine/engine_test.go` if scoring is affected
## Commit Messages
[Conventional Commits](https://www.conventionalcommits.org/) format:
```
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
```
## Implementation Order
1. `cmd/pulse-lets-go/main.go` + Go module + пустая структура `internal/`
2. `internal/models/` — все типы данных
3. `internal/config/` — чтение/запись JSON, атомарное сохранение
4. `internal/nats/` — подписка, in-memory store
5. `internal/engine/` — scorer + router
6. `internal/api/` — все хендлеры
7. `internal/log/` — ASCII-логгер + ring buffer
8. Web-фронт (SvelteKit)
9. Makefile + systemd unit
10. Тесты + graceful shutdown
## PR Process
1. Branch from `main`, named `feature/`, `fix/`, or `docs/`
2. One PR — one logical change
3. All tests pass (`make test`)
4. Code passes `make lint`
5. If API changes — update docs
-104
View File
@@ -1,104 +0,0 @@
# FreeSWITCH Integration
pulse-lets-go routes calls through FreeSWITCH via `mod_curl` → Lua dialplan → `/api/route`.
## Dialplan (route.lua)
FreeSWITCH uses a Lua script in its dialplan. The script calls `/api/route` via `mod_curl` and bridges to the returned gateway.
```lua
-- contrib/route.lua (simplified)
api = freeswitch.API()
caller_id = session:getVariable("caller_id_number") or ""
dest = session:getVariable("destination_number") or ""
url = "http://localhost:8080/api/route?caller_id=" .. caller_id .. "&dest=" .. dest
raw = api:execute("curl", url)
body = raw:match("\r?\n\r?\n(.+)") or raw
local ok, route = pcall(cjson.decode, body)
if not ok then session:hangup("NORMAL_TEMPORARY_FAILURE") return end
if route.fallback then
session:bridge(route.sip_gateway) -- operator
elseif route.sip_gateway then
session:bridge(route.sip_gateway) -- best node
else
session:hangup("NORMAL_TEMPORARY_FAILURE")
end
```
## Dialplan Configuration
```xml
<extension name="route_call">
<condition field="destination_number" expression="^.*$">
<action application="set" data="balancer_url=http://balancer.lan:8080"/>
<action application="set" data="ingress_trunk=trk-001"/>
<action application="lua" data="route.lua"/>
</condition>
</extension>
```
Reload dialplan:
```bash
sudo fs_cli -x "reloadxml"
```
## ESL (Gateway Management)
When `esl.host` is configured in `config.json`, the balancer:
- Connects to FreeSWITCH ESL (`:8021`)
- Synchronizes ingress trunks as Sofia gateways
- Listens for gateway registration events
- Streams gateway status to WebSocket frontend
### Configuration
```json
{
"esl": {
"host": "127.0.0.1",
"port": 8021,
"password": "ClueCon",
"password_env": "",
"sofia_profile": "external",
"gateway_prefix": "pulse"
}
}
```
- `password_env` — alternative: name of environment variable containing password
- `sofia_profile` — Sofia profile to manage (default: `external`)
- `gateway_prefix` — prefix for auto-created gateways (default: `pulse`)
## Prerequisites (before deployment)
| # | Check | Command | Expected |
|:-:|-------|---------|----------|
| 1 | **ESL running** | `nc -z 127.0.0.1 8021 && echo OK` | `OK` |
| 2 | **ESL password** | `echo "auth ClueCon" \| nc 127.0.0.1 8021` | `+OK accepted` |
| 3 | **mod_curl loaded** | `fs_cli -x "show modules" \| grep mod_curl` | `<load module="mod_curl"/>` |
| 4 | **mod_lua loaded** | `fs_cli -x "show modules" \| grep mod_lua` | `<load module="mod_lua"/>` |
| 5 | **ESL ACL** | ACL in `event_socket.conf.xml` allows 127.0.0.1 | `localhost` or `127.0.0.1` in ACL |
## Loading mod_curl Without Restart
```bash
fs_cli -x "load mod_curl"
# Add to modules.conf.xml for persistence:
# <load module="mod_curl"/>
```
## Rollback (if route.lua breaks calls)
```bash
# 1. Remove pulse_route from dialplan
sudo sed -i '/pulse_route/,/<\/extension>/d' /etc/freeswitch/dialplan/default.xml
sudo fs_cli -x "reloadxml"
# 2. Stop pulse-lets-go
sudo systemctl stop pulse-lets-go
# Calls continue through original dialplan without balancer.
```
-119
View File
@@ -1,119 +0,0 @@
# Routing
Маршрутизация звонков состоит из двух уровней:
1. **Routing Rules** — priority-based правила с regex-матчингом (first-match-wins)
2. **Balancing Mode** — стратегия выбора ноды внутри правила или глобально
## Routing Rules
Правила хранятся в `data/routes.json` и проверяются в порядке `Priority` (меньше = раньше). Первое совпавшее правило определяет маршрутизацию. Если правило не сработало (нода мертва / пул пуст) — переход к следующему. Если ни одно правило не совпало — глобальный `PickNode()`.
### Match Conditions (AND-логика)
Все непустые поля должны совпасть одновременно. Пустые поля — не проверяются.
| Поле | Тип | Описание |
|------|-----|----------|
| `match_caller_id` | regex | Caller ID звонящего |
| `match_destination` | regex | Номер назначения |
| `match_ingress_trunk` | regex | ID входящего транка |
| `match_days` | string | Дни недели: `"1-5"` (пн-пт), `"1,3,5"` (пн,ср,пт), `"6"` (сб). Воскресенье = 0 или 7 |
| `match_time_start` | `"HH:MM"` | Начало временного диапазона |
| `match_time_end` | `"HH:MM"` | Конец временного диапазона |
### Action Types
| Тип | Поле | Описание |
|-----|------|----------|
| `node` | `action_node_id` | Маршрутизация на конкретную ноду. Если нода dead/stale/lethal — fallthrough к следующему правилу |
| `pool` | `action_pool_ids` | Выбор лучшей ноды из заданного списка (по текущему режиму балансировки). Пустой/нездоровый пул — fallthrough |
| `auto` | — | Fallthrough к глобальному `PickNode()` с учётом режима балансировки |
### Пример правила
```json
{
"id": "rule-001",
"name": "VIP caller to dedicated pool",
"enabled": true,
"priority": 10,
"match_caller_id": "^7495",
"match_destination": "",
"match_ingress_trunk": "",
"match_days": "1-5",
"match_time_start": "09:00",
"match_time_end": "18:00",
"action_type": "pool",
"action_pool_ids": ["pbx-01", "pbx-02"],
"description": "VIP номера 7495 в бизнес-часы на выделенные ноды"
}
```
## Balancing Modes
Режим балансировки определяет, **как** выбирается нода из множества healthy-нод (после отсева lethal). Применяется как в глобальном `PickNode()`, так и внутри `pool`-правил.
### best
Выбирает ноду с **максимальным score** из кэша. O(1) — `bestScore` предрассчитывается при каждой метрике.
- Всегда одна и та же нода при одинаковых метриках — максимально нагружает лучшую ноду
- Хорошо для тестирования и сценариев где нужна предсказуемость
### weighted_random (по умолчанию)
Выбирает ноду **пропорционально score**: чем выше score, тем выше вероятность быть выбранной. O(n) — сканирует все ноды.
Алгоритм:
1. Отсеивает disabled, stale, lethal (score < 0)
2. Суммирует score всех кандидатов (`totalScore`)
3. `pick = rand(0, totalScore)`, идёт по кандидатам, вычитая score
4. Если `totalScore <= 0` — равновероятный выбор
- Распределяет нагрузку пропорционально готовности
- Предсказуемость ниже, но утилизация выше — менее загруженные ноды получают пропорционально меньше звонков
### Переключение режима
**Через API:**
```
PUT /api/route/mode
Authorization: Bearer <admin-jwt>
{"mode": "best"}
```
**Через конфиг:**
```json
{ "balance_mode": "weighted_random" }
```
Допустимые значения: `"best"`, `"weighted_random"`. API-эндпоинт сразу применяет режим и сохраняет в `config.json`.
## Routing Rules API
Правила маршрутизации CRUD — `/api/routing/rules*`. Все эндпоинты требуют JWT (admin).
| Метод | Путь | Описание |
|-------|------|----------|
| `GET` | `/api/routing/rules` | Список всех правил (с hits) |
| `POST` | `/api/routing/rules` | Создать новое правило |
| `PUT` | `/api/routing/rules/{id}` | Обновить правило (частично) |
| `DELETE` | `/api/routing/rules/{id}` | Удалить правило |
| `PUT` | `/api/routing/rules/{id}/toggle` | Вкл/выкл: `{"enabled":true}` |
| `PUT` | `/api/routing/rules/reorder` | Новый порядок: `["rr-abc", "rr-def"]` — переназначает Priority |
Правила хранятся в `data/routes.json` и применяются в порядке `Priority` (first-match-wins).
## Ответ /api/route
В ответе маршрутизации появилось поле `matched_rule` — имя сработавшего правила (или `"none"` если правило не применилось):
```json
{
"node_id": "pbx-03",
"score": 88,
"sip_gateway": "sip:pbx03.lan:5060",
"matched_rule": "VIP to dedicated"
}
```
-73
View File
@@ -1,73 +0,0 @@
# Scoring Engine
Вычисляет score (0..100) для каждой ноды. Ноды со score = -100 исключаются из роутинга.
## Lethal Conditions (score → -100)
Если хотя бы одно условие истинно — нода исключается:
If at least one is true — node is excluded from routing.
| Condition | Threshold |
|-----------|-----------|
| `status != "ok"` | Any status other than `ok` |
| Stale | > `stale_threshold_sec` (default 20s) |
| `active_calls >= max_calls` | 100% capacity used |
| `idle_cpu < idle_cpu_min` | < 5% |
| `call_failure_rate > call_failure_rate_lethal` | > 15% |
| `disabled == true` | Manual admin disable |
## Weighted Score (0..100)
```
call_score = clamp(100 - (active_calls / max_calls * 100), 0, 100)
load_score = clamp(100 - (load_avg * 50), 0, 100)
idle_score = clamp(idle_cpu, 0, 100)
fail_score = clamp(100 - call_failure_rate, 0, 100)
score = call_score × 0.40 +
load_score × 0.30 +
idle_score × 0.20 +
fail_score × 0.10
```
Weight defaults (configurable in `config.json`): call 40%, load 30%, idle 20%, fail 10%.
## Example
Node `pbx-01`: `active_calls=42`, `max_calls=250`, `load_avg=0.85`, `idle_cpu=35`, `fail_rate=0.5`
```
call_score = clamp(100 - (42/250)*100, 0, 100) = 83.2
load_score = clamp(100 - 0.85*50, 0, 100) = 57.5
idle_score = clamp(35, 0, 100) = 35.0
fail_score = clamp(100 - 0.5, 0, 100) = 99.5
score = 83.2×0.40 + 57.5×0.30 + 35.0×0.20 + 99.5×0.10 = 70.1
```
## Caching
`best_node_id`, `best_score`, and `fallback_active` are recalculated on every metric update.
`/api/route` reads them under RLock — O(1) constant time.
Выбор ноды из кэша зависит от [режима балансировки](routing.md#balancing-modes).
## Configuration
```json
{
"stale_threshold_sec": 20,
"scoring": {
"idle_cpu_min": 5,
"call_failure_rate_lethal": 15.0,
"weights": {
"call_score": 0.40,
"load_score": 0.30,
"idle_score": 0.20,
"fail_score": 0.10
}
}
}
```
Weights must sum to 1.0. All thresholds configurable in `config.json`.
+6 -30
View File
@@ -63,7 +63,7 @@ func NewClient(host string, port int, username, password string) *Client {
func (c *Client) Connect() error { func (c *Client) Connect() error {
c.shouldReconn.Store(true) c.shouldReconn.Store(true)
addr := net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port)) addr := fmt.Sprintf("%s:%d", c.host, c.port)
dialer := net.Dialer{Timeout: 5 * time.Second} dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.Dial("tcp", addr) conn, err := dialer.Dial("tcp", addr)
if err != nil { if err != nil {
@@ -268,9 +268,11 @@ func (c *Client) readEventsLoop() {
if err != nil { if err != nil {
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") { if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
c.connected.Store(false) c.connected.Store(false)
log.Printf("[ami] соединение разорвано, реконнект...") log.Printf("[ami] соединение разорвано")
c.reconnect() if c.shouldReconn.Load() {
continue go c.ConnectWithRetry()
}
return
} }
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
continue continue
@@ -289,29 +291,3 @@ func (c *Client) readEventsLoop() {
} }
} }
} }
// reconnect выполняет реконнект с backoff (без горутин — синхронный вызов).
func (c *Client) reconnect() {
if !c.shouldReconn.Load() {
return
}
log.Printf("[ami] реконнект через %v...", c.backoff)
time.Sleep(c.backoff)
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
if err := c.Connect(); err != nil {
log.Printf("[ami] реконнект не удался: %v", err)
c.backoff *= 2
if c.backoff > 60*time.Second {
c.backoff = 60 * time.Second
}
return
}
c.backoff = 1 * time.Second
log.Printf("[ami] реконнект успешен")
}
+5 -1
View File
@@ -139,7 +139,7 @@ func (a *API) apiKeyMiddleware(next http.Handler) http.Handler {
// handleLogin обрабатывает POST /api/auth/login. // handleLogin обрабатывает POST /api/auth/login.
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) { func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
var req models.LoginRequest var req models.LoginRequest
if err := decodeJSON(r, &req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON") writeError(w, http.StatusBadRequest, "некорректный JSON")
return return
} }
@@ -199,6 +199,10 @@ func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
// handleRefresh выдаёт новый access токен по refresh токену. // handleRefresh выдаёт новый access токен по refresh токену.
func (a *API) handleRefresh(w http.ResponseWriter, r *http.Request) { func (a *API) handleRefresh(w http.ResponseWriter, r *http.Request) {
tokenStr := extractBearerToken(r) tokenStr := extractBearerToken(r)
if tokenStr == "" {
// пробуем получить из query param (для WebSocket)
tokenStr = r.URL.Query().Get("token")
}
if tokenStr == "" { if tokenStr == "" {
writeError(w, http.StatusUnauthorized, "требуется токен") writeError(w, http.StatusUnauthorized, "требуется токен")
return return
-11
View File
@@ -1,13 +1,10 @@
package api package api
import ( import (
"bufio"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net"
"net/http" "net/http"
"time" "time"
) )
@@ -85,11 +82,3 @@ func (w *loggingResponseWriter) WriteHeader(code int) {
func (w *loggingResponseWriter) Write(b []byte) (int, error) { func (w *loggingResponseWriter) Write(b []byte) (int, error) {
return w.ResponseWriter.Write(b) return w.ResponseWriter.Write(b)
} }
func (w *loggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("loggingResponseWriter: underlying ResponseWriter does not implement http.Hijacker")
}
return hijacker.Hijack()
}
+1 -31
View File
@@ -29,13 +29,7 @@ func (a *API) handleZabbix(w http.ResponseWriter, r *http.Request) {
}) })
} }
stats := a.engine.GetHealthStats() writeJSON(w, http.StatusOK, models.ZabbixResponse{Data: data})
writeJSON(w, http.StatusOK, models.ZabbixResponse{
Data: data,
RouteRequests: stats.RouteRequests,
RouteFallbacks: stats.RouteFallbacks,
UptimeSeconds: stats.UptimeSeconds,
})
} }
// handlePrometheus — GET /api/monitoring/prometheus — text/plain метрики для Prometheus. // handlePrometheus — GET /api/monitoring/prometheus — text/plain метрики для Prometheus.
@@ -44,30 +38,6 @@ func (a *API) handlePrometheus(w http.ResponseWriter, r *http.Request) {
var sb strings.Builder var sb strings.Builder
// Общие метрики (counter gauge)
stats := a.engine.GetHealthStats()
fmt.Fprintf(&sb, "# HELP pulse_route_requests_total Total number of route requests.\n")
fmt.Fprintf(&sb, "# TYPE pulse_route_requests_total counter\n")
fmt.Fprintf(&sb, "pulse_route_requests_total %d\n", stats.RouteRequests)
fmt.Fprintf(&sb, "# HELP pulse_nodes_total Total nodes registered.\n")
fmt.Fprintf(&sb, "# TYPE pulse_nodes_total gauge\n")
fmt.Fprintf(&sb, "pulse_nodes_total %d\n", stats.TotalNodes)
fmt.Fprintf(&sb, "# HELP pulse_nodes_healthy Healthy nodes count.\n")
fmt.Fprintf(&sb, "# TYPE pulse_nodes_healthy gauge\n")
fmt.Fprintf(&sb, "pulse_nodes_healthy %d\n", stats.HealthyNodes)
fmt.Fprintf(&sb, "# HELP pulse_route_fallbacks_total Total number of fallback route requests.\n")
fmt.Fprintf(&sb, "# TYPE pulse_route_fallbacks_total counter\n")
fmt.Fprintf(&sb, "pulse_route_fallbacks_total %d\n", stats.RouteFallbacks)
fmt.Fprintf(&sb, "# HELP pulse_uptime_seconds Uptime in seconds.\n")
fmt.Fprintf(&sb, "# TYPE pulse_uptime_seconds gauge\n")
fmt.Fprintf(&sb, "pulse_uptime_seconds %d\n", stats.UptimeSeconds)
fmt.Fprintf(&sb, "\n")
for _, n := range nodes { for _, n := range nodes {
id := sanitizePromLabel(n.NodeID) id := sanitizePromLabel(n.NodeID)
-2
View File
@@ -47,8 +47,6 @@ 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)
} }
+15 -23
View File
@@ -14,6 +14,7 @@ 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 {
@@ -22,22 +23,17 @@ 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
} }
rl := &rateLimiter{ return &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 проверяет, разрешён ли запрос для данного ключа.
@@ -48,11 +44,22 @@ 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
} }
@@ -71,21 +78,6 @@ 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 {
+27 -128
View File
@@ -1,40 +1,28 @@
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()
callerID := r.URL.Query().Get("caller_id") nodeID, score, fallback := a.engine.GetBestNode()
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()
writeJSON(w, http.StatusServiceUnavailable, models.RouteResponse{ writeJSON(w, http.StatusServiceUnavailable, models.RouteResponse{
Error: "no_nodes_registered", Error: "no_nodes_registered",
MatchedRule: matchedRule,
}) })
return return
} }
// Все ноды unhealthy — fallback // Все ноды unhealthy — fallback
if fallback || score < 0 { if fallback || score < 0 {
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{
@@ -42,13 +30,12 @@ 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.cachedBalanceGateway(nodeID) gw, ok := a.findBalanceGateway(nodeID)
if !ok { if !ok {
writeError(w, http.StatusInternalServerError, "gateway не найден для ноды") writeError(w, http.StatusInternalServerError, "gateway не найден для ноды")
return return
@@ -58,123 +45,35 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
NodeID: nodeID, NodeID: nodeID,
Score: score, Score: score,
SIPGateway: gw, SIPGateway: gw,
MatchedRule: matchedRule,
}) })
} }
// handleSetRouteMode — PUT /api/route/mode (admin only). // findBalanceGateway ищет gateway для balance-транка, привязанного к nodeID.
func (a *API) handleSetRouteMode(w http.ResponseWriter, r *http.Request) { func (a *API) findBalanceGateway(nodeID string) (string, bool) {
var req struct { trunks, err := a.configManager.ReadTrunks()
Mode string `json:"mode"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON")
return
}
if req.Mode != "best" && req.Mode != "weighted_random" {
writeError(w, http.StatusBadRequest, "mode должен быть 'best' или 'weighted_random'")
return
}
// Сохраняем в config.json атомарно
if a.configManager != nil {
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 return "", false
} }
tmpPath := path + ".tmp" for _, t := range trunks {
if err := os.WriteFile(tmpPath, b, 0644); err != nil { if t.Type == models.TrunkBalance && t.NodeID == nodeID && t.Enabled {
return return t.Gateway, true
} }
os.Rename(tmpPath, path) }
return "", false
}
// findFallbackGateway ищет gateway для fallback-транка.
func (a *API) findFallbackGateway() (string, bool) {
trunks, err := a.configManager.ReadTrunks()
if err != nil {
return "", false
}
for _, t := range trunks {
if t.Type == models.TrunkFallback && t.Enabled {
return t.Gateway, true
}
}
return "", false
} }
// getRouteNodeInfo возвращает список нод с их скорами для ответа маршрутизации. // getRouteNodeInfo возвращает список нод с их скорами для ответа маршрутизации.
-154
View File
@@ -1,154 +0,0 @@
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)
}
}
+1 -86
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"sync"
"github.com/pulse-lets-go/internal/config" "github.com/pulse-lets-go/internal/config"
"github.com/pulse-lets-go/internal/engine" "github.com/pulse-lets-go/internal/engine"
@@ -24,18 +23,6 @@ type API struct {
logFormat string logFormat string
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
gatewayStates map[string]string // trunkID → "up"/"down"
fsStats esl.FsStats
fsStatsMu sync.RWMutex
routeCachePath string // путь к файловому кэшу маршрута (для Lua)
} }
// NewAPI создаёт новый HTTP API с заданными зависимостями. // NewAPI создаёт новый HTTP API с заданными зависимостями.
@@ -48,8 +35,6 @@ func NewAPI(eng *engine.Engine, cfgMgr *config.Manager, jwtSecret, monitoringAPI
wsHub: newWSHub(), wsHub: newWSHub(),
natsConnected: natsFn, natsConnected: natsFn,
logFormat: cfg.Log.Format, logFormat: cfg.Log.Format,
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)
@@ -89,7 +74,6 @@ 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) ---
@@ -105,15 +89,6 @@ 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)
@@ -155,10 +130,6 @@ func corsMiddleware(next http.Handler) http.Handler {
// BroadcastGatewayEvent транслирует статус gateway всем WS-клиентам. // BroadcastGatewayEvent транслирует статус gateway всем WS-клиентам.
func (a *API) BroadcastGatewayEvent(gatewayName, trunkID, status string) { func (a *API) BroadcastGatewayEvent(gatewayName, trunkID, status string) {
a.gatewayMu.Lock()
a.gatewayStates[trunkID] = status
a.gatewayMu.Unlock()
msg := &models.WsMetricsMessage{ msg := &models.WsMetricsMessage{
Type: "gateway_update", Type: "gateway_update",
Payload: map[string]string{ Payload: map[string]string{
@@ -170,65 +141,9 @@ func (a *API) BroadcastGatewayEvent(gatewayName, trunkID, status string) {
a.wsHub.broadcast(msg) a.wsHub.broadcast(msg)
} }
func (a *API) gatewayStats() (total, up, down int) { // decodeJSON декодирует тело запроса в структуру v.
a.gatewayMu.RLock()
defer a.gatewayMu.RUnlock()
for _, s := range a.gatewayStates {
total++
switch s {
case "up":
up++
case "down":
down++
}
}
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).
func (a *API) UpdateFsStats(stats esl.FsStats) {
a.fsStatsMu.Lock()
a.fsStats = stats
a.fsStatsMu.Unlock()
}
// maxBodySize — максимальный размер тела запроса в байтах (1 MiB).
const maxBodySize = 1 << 20
// decodeJSON декодирует тело запроса в структуру v с ограничением размера.
func decodeJSON(r *http.Request, v interface{}) error { func decodeJSON(r *http.Request, v interface{}) error {
defer r.Body.Close() defer r.Body.Close()
r.Body = http.MaxBytesReader(nil, r.Body, maxBodySize)
if err := json.NewDecoder(r.Body).Decode(v); err != nil { if err := json.NewDecoder(r.Body).Decode(v); err != nil {
return fmt.Errorf("декодирование JSON: %w", err) return fmt.Errorf("декодирование JSON: %w", err)
} }
-317
View File
@@ -1,317 +0,0 @@
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"})
}
+2 -9
View File
@@ -92,8 +92,6 @@ 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)
} }
@@ -168,8 +166,6 @@ 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)
} }
@@ -204,8 +200,6 @@ func (a *API) handleDeleteTrunk(w http.ResponseWriter, r *http.Request) {
return return
} }
a.RebuildTrunkCache()
writeJSON(w, http.StatusNoContent, nil) writeJSON(w, http.StatusNoContent, nil)
} }
@@ -274,9 +268,8 @@ func (a *API) eslPushGatewayDelete(trunk models.Trunk) {
// gatewayParams возвращает параметры для создания FS gateway на основе транка. // gatewayParams возвращает параметры для создания FS gateway на основе транка.
func (a *API) gatewayParams(trunk models.Trunk) (profile, name, proxy string) { func (a *API) gatewayParams(trunk models.Trunk) (profile, name, proxy string) {
cfg := a.readConfig() profile = a.readConfig().ESL.SofiaProfile
profile = cfg.ESL.SofiaProfile name = fmt.Sprintf("pulse-ingress-%s", trunk.ID)
name = fmt.Sprintf("%s-ingress-%s", cfg.ESL.GatewayPrefix, trunk.ID)
proxy = extractHost(trunk.Gateway) proxy = extractHost(trunk.Gateway)
return return
} }
+13 -2
View File
@@ -1,12 +1,13 @@
package api package api
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
"github.com/pulse-lets-go/internal/models" "github.com/pulse-lets-go/internal/models"
"github.com/pulse-lets-go/internal/util"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -226,5 +227,15 @@ func findUserIndex(users []models.User, id string) int {
// generateID генерирует ID вида "prefix-XXXXX". // generateID генерирует ID вида "prefix-XXXXX".
func generateID(prefix string) string { func generateID(prefix string) string {
return fmt.Sprintf("%s-%s", prefix, util.RandomHex(6)) return fmt.Sprintf("%s-%s", prefix, randomHex(6))
}
// randomHex генерирует случайную hex-строку заданной длины.
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
// fallback на time-based, если crypto/rand недоступен
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)[:n]
} }
+18 -75
View File
@@ -20,12 +20,10 @@ var upgrader = websocket.Upgrader{
}, },
} }
const wsSendBufferSize = 32 // размер буфера per-client канала // wsClient — соединение WebSocket одного клиента.
// wsClient — соединение WebSocket одного клиента с выделенной write-горутиной.
type wsClient struct { type wsClient struct {
conn *websocket.Conn conn *websocket.Conn
send chan *models.WsMetricsMessage mu sync.Mutex
} }
// wsHub управляет подключёнными WebSocket-клиентами и рассылает метрики. // wsHub управляет подключёнными WebSocket-клиентами и рассылает метрики.
@@ -48,45 +46,32 @@ 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] {
delete(h.clients, c)
close(c.send)
c.conn.Close() c.conn.Close()
delete(h.clients, c)
} }
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 {
select { client.mu.Lock()
case client.send <- msg: err := client.conn.WriteJSON(msg)
default: client.mu.Unlock()
// Клиент слишком медленный — пропускаем сообщение 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")
@@ -112,20 +97,19 @@ func (a *API) handleWS(w http.ResponseWriter, r *http.Request) {
return return
} }
client := &wsClient{ client := &wsClient{conn: conn}
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() nodes := a.engine.GetAllNodes()
msg := &models.WsMetricsMessage{
Type: "nodes_update",
Payload: nodes,
}
a.wsHub.broadcast(msg)
}() }()
// Читаем из вебсокета (ping/pong + закрытие) // Читаем из вебсокета (ping/pong + закрытие)
@@ -141,7 +125,7 @@ func (a *API) handleWS(w http.ResponseWriter, r *http.Request) {
}() }()
} }
// BroadcastMetrics рассылает метрики нод всем WS-клиентам. // BroadcastMetrics рассылает метрики всем WS-клиентам (вызывается engine при обновлении).
func (a *API) BroadcastMetrics() { func (a *API) BroadcastMetrics() {
nodes := a.engine.GetAllNodes() nodes := a.engine.GetAllNodes()
msg := &models.WsMetricsMessage{ msg := &models.WsMetricsMessage{
@@ -151,47 +135,6 @@ func (a *API) BroadcastMetrics() {
a.wsHub.broadcast(msg) a.wsHub.broadcast(msg)
} }
// BroadcastAll рассылает ноды и состояние балансировщика.
func (a *API) BroadcastAll() {
a.BroadcastMetrics()
a.broadcastBalancerUpdate()
}
func (a *API) broadcastBalancerUpdate() {
stats := a.engine.GetHealthStats()
gwTotal, gwUp, gwDown := a.gatewayStats()
info := models.BalancerInfo{
NatsConnected: a.natsConnected != nil && a.natsConnected(),
GatewaysTotal: gwTotal,
GatewaysUp: gwUp,
GatewaysDown: gwDown,
RouteTotal: stats.RouteRequests,
RouteFallbacks: stats.RouteFallbacks,
UptimeSec: stats.UptimeSeconds,
BalanceMode: a.engine.GetBalancingMode(),
}
if a.eslClient != nil {
eslStats := a.eslClient.GetStats()
info.EslConnected = eslStats.Status == "connected"
info.EslUptimeSec = eslStats.UptimeSec
info.EslReconnects = eslStats.Reconnects
a.fsStatsMu.RLock()
info.FsActiveCalls = a.fsStats.ActiveCalls
info.FsMaxSessions = a.fsStats.MaxSessions
info.FsUptime = a.fsStats.FsUptime
a.fsStatsMu.RUnlock()
}
msg := &models.WsMetricsMessage{
Type: "balancer_update",
Payload: info,
}
a.wsHub.broadcast(msg)
}
// validateToken проверяет JWT и возвращает UserInfo. // validateToken проверяет JWT и возвращает UserInfo.
func (a *API) validateToken(tokenStr string) (*UserInfo, error) { func (a *API) validateToken(tokenStr string) (*UserInfo, error) {
token, err := parseJWT(tokenStr, a.jwtSecret) token, err := parseJWT(tokenStr, a.jwtSecret)
+1 -71
View File
@@ -30,8 +30,6 @@ 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.
@@ -74,16 +72,6 @@ func (e *ESLConfig) GetPassword() string {
return e.Password return e.Password
} }
// GetJWTSecret возвращает JWT-секрет: из переменной окружения, если задан jwt_secret_env, иначе из поля jwt_secret.
func (c *Config) GetJWTSecret() string {
if c.JWTSecretEnv != "" {
if val := os.Getenv(c.JWTSecretEnv); val != "" {
return val
}
}
return c.JWTSecret
}
// Config — корневая конфигурация приложения (config.json). // Config — корневая конфигурация приложения (config.json).
type Config struct { type Config struct {
NatsURL string `json:"nats_url"` NatsURL string `json:"nats_url"`
@@ -91,7 +79,6 @@ type Config struct {
NatsPassword string `json:"nats_password"` NatsPassword string `json:"nats_password"`
ListenAddr string `json:"listen_addr"` ListenAddr string `json:"listen_addr"`
JWTSecret string `json:"jwt_secret"` JWTSecret string `json:"jwt_secret"`
JWTSecretEnv string `json:"jwt_secret_env"` // имя переменной окружения с JWT-секретом
MonitoringAPIKey string `json:"monitoring_api_key"` MonitoringAPIKey string `json:"monitoring_api_key"`
StaleThresholdSec int `json:"stale_threshold_sec"` StaleThresholdSec int `json:"stale_threshold_sec"`
Scoring ScoringConfig `json:"scoring"` Scoring ScoringConfig `json:"scoring"`
@@ -99,7 +86,6 @@ 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 проверяет корректность всех полей конфига.
@@ -128,12 +114,6 @@ 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
@@ -167,13 +147,6 @@ 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 {
@@ -225,11 +198,6 @@ 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
} }
@@ -332,46 +300,11 @@ 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.
@@ -420,7 +353,7 @@ func (m *Manager) createDefaultConfig() error {
ListenAddr: ":8080", ListenAddr: ":8080",
JWTSecret: jwtSecret, JWTSecret: jwtSecret,
MonitoringAPIKey: apiKey, MonitoringAPIKey: apiKey,
StaleThresholdSec: 10, StaleThresholdSec: 20,
Scoring: ScoringConfig{ Scoring: ScoringConfig{
IdleCPUMin: 5.0, IdleCPUMin: 5.0,
CallFailureRateLethal: 15.0, CallFailureRateLethal: 15.0,
@@ -430,8 +363,6 @@ 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,
@@ -455,7 +386,6 @@ func (m *Manager) createDefaultConfig() error {
SofiaProfile: "external", SofiaProfile: "external",
GatewayPrefix: "pulse", GatewayPrefix: "pulse",
}, },
BalanceMode: "weighted_random",
} }
return m.SaveConfig(&cfg) return m.SaveConfig(&cfg)
+6 -192
View File
@@ -4,8 +4,6 @@ package engine
import ( import (
"fmt" "fmt"
"log"
"math/rand/v2"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -31,33 +29,17 @@ type Engine struct {
// Статистика для health endpoint // Статистика для health endpoint
startTime time.Time startTime time.Time
routeRequests atomic.Int64 routeRequests 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,
} }
} }
@@ -76,7 +58,6 @@ 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
@@ -89,7 +70,7 @@ func (e *Engine) UpdateMetric(m *models.NodeMetric) *models.NodeState {
ns.DisabledReason = disabledReason ns.DisabledReason = disabledReason
} }
e.scoreNodeLocked(ns, firstMetric) e.scoreNodeLocked(ns)
e.recalcBestLocked() e.recalcBestLocked()
// Обновляем время последней метрики // Обновляем время последней метрики
@@ -119,93 +100,13 @@ 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()
@@ -268,7 +169,7 @@ func (e *Engine) ToggleNode(nodeID string, disabled bool, reason string) error {
ns.DisabledReason = "" ns.DisabledReason = ""
} }
e.scoreNodeLocked(ns, false) e.scoreNodeLocked(ns)
e.recalcBestLocked() e.recalcBestLocked()
return nil return nil
} }
@@ -287,17 +188,11 @@ func (e *Engine) IncrementRouteRequests() {
e.routeRequests.Add(1) e.routeRequests.Add(1)
} }
// IncrementRouteFallbacks увеличивает счётчик fallback-запросов.
func (e *Engine) IncrementRouteFallbacks() {
e.routeFallbacks.Add(1)
}
// HealthStats возвращает статистику для health endpoint. // HealthStats возвращает статистику для health endpoint.
type HealthStats struct { type HealthStats struct {
TotalNodes int `json:"total_nodes"` TotalNodes int `json:"total_nodes"`
HealthyNodes int `json:"healthy_nodes"` HealthyNodes int `json:"healthy_nodes"`
RouteRequests int64 `json:"route_requests_total"` RouteRequests int64 `json:"route_requests_total"`
RouteFallbacks int64 `json:"route_fallbacks_total"`
UptimeSeconds int64 `json:"uptime_seconds"` UptimeSeconds int64 `json:"uptime_seconds"`
LastMetricTS int64 `json:"last_metric_ts"` LastMetricTS int64 `json:"last_metric_ts"`
} }
@@ -317,79 +212,16 @@ func (e *Engine) GetHealthStats() HealthStats {
TotalNodes: len(e.nodes), TotalNodes: len(e.nodes),
HealthyNodes: healthy, HealthyNodes: healthy,
RouteRequests: e.routeRequests.Load(), RouteRequests: e.routeRequests.Load(),
RouteFallbacks: e.routeFallbacks.Load(),
UptimeSeconds: int64(time.Since(e.startTime).Seconds()), UptimeSeconds: int64(time.Since(e.startTime).Seconds()),
LastMetricTS: e.lastMetricTime.Load(), LastMetricTS: e.lastMetricTime.Load(),
} }
} }
// 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.
// firstMetric — true при первом замере метрики (без EWMA-сглаживания). func (e *Engine) scoreNodeLocked(ns *models.NodeState) {
func (e *Engine) scoreNodeLocked(ns *models.NodeState, firstMetric bool) {
sc := &e.cfg.Scoring sc := &e.cfg.Scoring
// 1. Проверка lethal-условий // 1. Проверка lethal-условий
@@ -433,41 +265,23 @@ func (e *Engine) scoreNodeLocked(ns *models.NodeState, firstMetric bool) {
// 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*loadMultiplier), 0, 100) loadScore := clamp(100.0-(ns.LoadAvg*50.0), 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
rawScore := callScore*w.CallScore + loadScore*w.LoadScore + idleScore*w.IdleScore + failScore*w.FailScore ns.Score = 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
} }
@@ -485,7 +299,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, false) e.scoreNodeLocked(ns)
} }
e.recalcBestLocked() e.recalcBestLocked()
} }
-453
View File
@@ -338,456 +338,3 @@ 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
View File
@@ -1,440 +0,0 @@
// 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
View File
@@ -1,590 +0,0 @@
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]
}
+47 -75
View File
@@ -109,7 +109,7 @@ func (c *Client) tryConnect() error {
c.closeCh = make(chan struct{}) c.closeCh = make(chan struct{})
} }
addr := net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port)) addr := fmt.Sprintf("%s:%d", c.host, c.port)
dialer := net.Dialer{Timeout: 5 * time.Second} dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.Dial("tcp", addr) conn, err := dialer.Dial("tcp", addr)
if err != nil { if err != nil {
@@ -119,7 +119,7 @@ func (c *Client) tryConnect() error {
c.reader = bufio.NewReader(conn) c.reader = bufio.NewReader(conn)
// 1. Читаем auth/request // 1. Читаем auth/request
headers, _, err := c.readMessage() headers, _, err := c.readMessageLocked()
if err != nil { if err != nil {
conn.Close() conn.Close()
return fmt.Errorf("чтение auth/request: %w", err) return fmt.Errorf("чтение auth/request: %w", err)
@@ -137,7 +137,7 @@ func (c *Client) tryConnect() error {
} }
// 3. Читаем auth response // 3. Читаем auth response
headers, _, err = c.readMessage() headers, _, err = c.readMessageLocked()
if err != nil { if err != nil {
conn.Close() conn.Close()
return fmt.Errorf("чтение auth response: %w", err) return fmt.Errorf("чтение auth response: %w", err)
@@ -199,7 +199,7 @@ func (c *Client) Send(command string) (map[string]string, string, error) {
} }
// Читаем ответ // Читаем ответ
return c.readMessage() return c.readMessageLocked()
} }
// Subscribe подписывается на события ESL. // Subscribe подписывается на события ESL.
@@ -263,7 +263,7 @@ func (c *Client) readEventsLoop() {
continue continue
} }
headers, body, err := c.readMessage() headers, body, err := c.readMessageUnlocked()
if err != nil { if err != nil {
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") { if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
@@ -330,9 +330,48 @@ func (c *Client) reconnect() {
// --- Приватные методы --- // --- Приватные методы ---
// readMessage читает одно ESL-сообщение: заголовки + тело. // readMessageUnlocked читает одно ESL-сообщение: заголовки + тело.
// Вызывающий должен гарантировать монопольный доступ к c.reader. // Должна вызываться только из readEventsLoop (одиночный читатель).
func (c *Client) readMessage() (map[string]string, string, error) { func (c *Client) readMessageUnlocked() (map[string]string, string, error) {
headers := make(map[string]string)
for {
line, err := c.reader.ReadString('\n')
if err != nil {
return nil, "", err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break // конец заголовков
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
// Читаем тело согласно Content-Length
cl := headers["Content-Length"]
if cl == "" {
return headers, "", nil
}
length, err := strconv.Atoi(cl)
if err != nil {
return headers, "", fmt.Errorf("некорректный Content-Length: %s", cl)
}
body := make([]byte, length)
if _, err := io.ReadFull(c.reader, body); err != nil {
return headers, "", fmt.Errorf("чтение тела (Content-Length=%d): %w", length, err)
}
return headers, strings.TrimSpace(string(body)), nil
}
// readMessageLocked читает одно ESL-сообщение: заголовки + тело.
// Вызывающий держит c.mu (для синхронных команд Send).
func (c *Client) readMessageLocked() (map[string]string, string, error) {
headers := make(map[string]string) headers := make(map[string]string)
for { for {
@@ -401,70 +440,3 @@ func (c *Client) GetStats() Stats {
func (c *Client) IncGatewayOps() { func (c *Client) IncGatewayOps() {
c.gatewayOps.Add(1) c.gatewayOps.Add(1)
} }
// --- FS stats ---
// FsStats — показатели FreeSWITCH балансировщика.
type FsStats struct {
ActiveCalls int
MaxSessions int
FsUptime string
}
// FetchFsStats запрашивает метрики FreeSWITCH через api команды.
func (c *Client) FetchFsStats() (FsStats, error) {
if !c.connected.Load() {
return FsStats{}, fmt.Errorf("esl не подключён")
}
var stats FsStats
_, body, err := c.Send("api show calls count")
if err != nil {
return FsStats{}, err
}
stats.ActiveCalls = parseFirstInt(body)
_, body, err = c.Send("api status")
if err != nil {
return stats, err
}
stats.MaxSessions = parseMaxSessions(body)
stats.FsUptime = parseUptime(body)
return stats, nil
}
func parseFirstInt(s string) int {
s = strings.TrimSpace(s)
for i, c := range s {
if c < '0' || c > '9' {
if i == 0 {
return 0
}
v, err := strconv.Atoi(s[:i])
if err != nil {
return 0
}
return v
}
}
return 0
}
func parseMaxSessions(body string) int {
for _, line := range strings.Split(body, "\n") {
if strings.Contains(line, "session(s) max") {
return parseFirstInt(line)
}
}
return 0
}
func parseUptime(body string) string {
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(line, "UP ") {
return strings.TrimPrefix(line, "UP ")
}
}
return ""
}
-1
View File
@@ -16,7 +16,6 @@ type EventHandler func(eventName string, headers map[string]string, body string)
// - Вызывает onConnect (например, для GatewaySyncAll) // - Вызывает onConnect (например, для GatewaySyncAll)
// - Подписывается на события SOFIA::gateway_register/unregister // - Подписывается на события SOFIA::gateway_register/unregister
// - Запускает чтение асинхронных событий // - Запускает чтение асинхронных событий
//
// При разрыве соединения — автоматический реконнект. // При разрыве соединения — автоматический реконнект.
func (c *Client) StartEventLoop(ctx context.Context, profile string, onConnect func(), onEvent EventHandler) { func (c *Client) StartEventLoop(ctx context.Context, profile string, onConnect func(), onEvent EventHandler) {
const subscribeEvents = "SOFIA::gateway_register SOFIA::gateway_unregister SOFIA::gateway_expire" const subscribeEvents = "SOFIA::gateway_register SOFIA::gateway_unregister SOFIA::gateway_expire"
+4 -4
View File
@@ -32,8 +32,8 @@ func (c *Client) GatewayDelete(profile, name string) error {
return nil return nil
} }
// GatewayList возвращает список имён gateway в заданном профиле с указанным префиксом. // GatewayList возвращает список имён gateway в заданном профиле.
func (c *Client) GatewayList(profile, prefix string) ([]string, error) { func (c *Client) GatewayList(profile string) ([]string, error) {
cmd := fmt.Sprintf("api sofia profile %s gwlist", profile) cmd := fmt.Sprintf("api sofia profile %s gwlist", profile)
_, body, err := c.Send(cmd) _, body, err := c.Send(cmd)
@@ -49,8 +49,8 @@ func (c *Client) GatewayList(profile, prefix string) ([]string, error) {
if line == "" || strings.HasPrefix(line, "Name:") || strings.HasPrefix(line, "====") { if line == "" || strings.HasPrefix(line, "Name:") || strings.HasPrefix(line, "====") {
continue continue
} }
// Ищем строки вида "<prefix>-ingress-xxxxx sip:..." // Ищем строки вида "pulse-ingress-xxxxx sip:..."
if strings.Contains(line, prefix+"-ingress-") { if strings.Contains(line, "pulse-") {
parts := strings.Fields(line) parts := strings.Fields(line)
if len(parts) > 0 { if len(parts) > 0 {
gateways = append(gateways, parts[0]) gateways = append(gateways, parts[0])
-36
View File
@@ -1,36 +0,0 @@
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)
}
+1 -59
View File
@@ -91,38 +91,6 @@ 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 — ответ на запрос маршрутизации.
@@ -132,9 +100,6 @@ 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"`
@@ -242,35 +207,12 @@ type ZabbixNode struct {
// ZabbixResponse — ответ на запрос Zabbix LLD. // ZabbixResponse — ответ на запрос Zabbix LLD.
type ZabbixResponse struct { type ZabbixResponse struct {
Data []ZabbixNode `json:"data"` Data []ZabbixNode `json:"data"`
RouteRequests int64 `json:"route_requests_total"`
RouteFallbacks int64 `json:"route_fallbacks_total"`
UptimeSeconds int64 `json:"uptime_seconds"`
}
// --- Balancer ---
// BalancerInfo — состояние балансировщика для дашборда.
type BalancerInfo struct {
EslConnected bool `json:"esl_connected"`
EslUptimeSec int64 `json:"esl_uptime_sec,omitempty"`
EslReconnects int64 `json:"esl_reconnects,omitempty"`
NatsConnected bool `json:"nats_connected"`
GatewaysTotal int `json:"gateways_total"`
GatewaysUp int `json:"gateways_up"`
GatewaysDown int `json:"gateways_down"`
FsActiveCalls int `json:"fs_active_calls"`
FsMaxSessions int `json:"fs_max_sessions"`
FsUptime string `json:"fs_uptime"`
RouteTotal int64 `json:"route_total"`
RouteFallbacks int64 `json:"route_fallbacks"`
UptimeSec int64 `json:"uptime_sec"`
BalanceMode string `json:"balance_mode"`
} }
// --- WebSocket --- // --- WebSocket ---
// WsMetricsMessage — сообщение, отправляемое по WebSocket. // WsMetricsMessage — сообщение, отправляемое по WebSocket.
type WsMetricsMessage struct { type WsMetricsMessage struct {
Type string `json:"type"` // "nodes_update", "balancer_update", "gateway_update", etc Type string `json:"type"` // "nodes_update", "node_toggle", etc
Payload interface{} `json:"payload"` Payload interface{} `json:"payload"`
} }
+1 -11
View File
@@ -9,9 +9,9 @@ import (
natsgo "github.com/nats-io/nats.go" natsgo "github.com/nats-io/nats.go"
"github.com/pulse-lets-go/internal/models"
"github.com/pulse-lets-go/internal/engine" "github.com/pulse-lets-go/internal/engine"
filelog "github.com/pulse-lets-go/internal/log" filelog "github.com/pulse-lets-go/internal/log"
"github.com/pulse-lets-go/internal/models"
) )
const subject = "pulse.metrics.>" const subject = "pulse.metrics.>"
@@ -23,7 +23,6 @@ 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
} }
@@ -64,11 +63,6 @@ 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
@@ -92,10 +86,6 @@ 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)
-18
View File
@@ -1,18 +0,0 @@
package util
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
)
// RandomHex генерирует случайную hex-строку из n байт.
// При ошибке crypto/rand использует time-based fallback.
func RandomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())[:n*2]
}
return hex.EncodeToString(b)[:n*2]
}
-78
View File
@@ -184,23 +184,6 @@ export async function deleteUser(id: string) {
} }
// --- WebSocket --- // --- WebSocket ---
export interface BalancerInfo {
esl_connected: boolean;
esl_uptime_sec: number;
esl_reconnects: number;
nats_connected: boolean;
gateways_total: number;
gateways_up: number;
gateways_down: number;
fs_active_calls: number;
fs_max_sessions: number;
fs_uptime: string;
route_total: number;
route_fallbacks: number;
uptime_sec: number;
balance_mode: string;
}
export function connectWebSocket(onMessage: (data: any) => void): WebSocket { export function connectWebSocket(onMessage: (data: any) => void): WebSocket {
const token = getToken(); const token = getToken();
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
@@ -224,64 +207,3 @@ 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),
});
}
+13 -25
View File
@@ -1,40 +1,31 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment';
import { page } from '$app/stores'; import { page } from '$app/stores';
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, ArrowLeftRight } from 'lucide-svelte'; import { LogIn, LayoutDashboard, Phone, Cable, Users } from 'lucide-svelte';
let token: string | null = null; let token: string | null = browser ? localStorage.getItem('token') : null;
let user: { username: string; role: string } | null = null; let user: { username: string; role: string } | null = token
? (() => { try { return JSON.parse(atob(token.split('.')[1])); } catch { return null; } })()
function updateAuth() { : null;
token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
if (token) {
try { user = JSON.parse(atob(token.split('.')[1])); } catch { user = null; }
} else {
user = null;
}
}
function logout() { function logout() {
if (browser) {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('refreshToken'); localStorage.removeItem('refreshToken');
updateAuth(); }
goto('/login'); token = null;
user = null;
} }
let pathname = $derived($page.url.pathname); $: pathname = browser ? window.location.pathname : '';
$: isLogin = pathname === '/login';
$effect(() => {
$page.url.pathname;
updateAuth();
});
</script> </script>
<Toaster position="top-right" /> <Toaster position="top-right" />
{#if pathname !== '/login'} {#if !isLogin}
<div class="flex min-h-screen"> <div class="flex min-h-screen">
<!-- Sidebar --> <!-- Sidebar -->
<aside class="w-64 border-r bg-card flex flex-col"> <aside class="w-64 border-r bg-card flex flex-col">
@@ -53,9 +44,6 @@
<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 -116
View File
@@ -3,43 +3,24 @@
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, setBalanceMode, getNodes, connectWebSocket, isAuthenticated,
type NodeInfo, type BalancerInfo type NodeInfo
} from '$lib/api'; } from '$lib/api';
import { Activity, Phone, Zap, AlertTriangle, Wifi, Server, Plug, Globe, ArrowLeftRight, Network, Shuffle } from 'lucide-svelte'; import { Activity, Phone, Zap, AlertTriangle, Wifi, Server } from 'lucide-svelte';
let nodes: NodeInfo[] = []; let nodes: NodeInfo[] = [];
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) => {
if (msg.type === 'nodes_update') { if (msg.type === 'nodes_update') {
nodes = msg.payload; nodes = msg.payload;
} else if (msg.type === 'balancer_update') {
balancer = msg.payload;
} }
}); });
}); });
@@ -56,19 +37,6 @@
} }
} }
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';
@@ -99,31 +67,10 @@
} }
} }
function dotColor(ok: boolean, degrade?: boolean): string {
if (ok) return 'bg-green-500';
if (degrade) return 'bg-yellow-500';
return 'bg-red-500';
}
function fmtUptime(sec: number): string {
if (sec < 60) return `${sec}с`;
if (sec < 3600) return `${Math.floor(sec / 60)}м`;
if (sec < 86400) return `${Math.floor(sec / 3600)}ч`;
return `${Math.floor(sec / 86400)}д`;
}
function fmtNum(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
}
$: healthyCount = nodes.filter(n => n.score >= 0 && !n.disabled && !n.is_stale).length; $: healthyCount = nodes.filter(n => n.score >= 0 && !n.disabled && !n.is_stale).length;
$: totalCalls = nodes.reduce((s, n) => s + n.active_calls, 0); $: totalCalls = nodes.reduce((s, n) => s + n.active_calls, 0);
$: avgScore = nodes.length > 0 ? nodes.reduce((s, n) => s + n.score, 0) / nodes.length : 0; $: avgScore = nodes.length > 0 ? nodes.reduce((s, n) => s + n.score, 0) / nodes.length : 0;
$: unhealthyList = nodes.filter(n => n.score < 0 || n.disabled || n.is_stale); $: unhealthyList = nodes.filter(n => n.score < 0 || n.disabled || n.is_stale);
$: fallbackPct = balancer && balancer.route_total > 0
? (balancer.route_fallbacks / balancer.route_total * 100).toFixed(1)
: '0';
</script> </script>
{#if error} {#if error}
@@ -132,66 +79,6 @@
</div> </div>
{/if} {/if}
<!-- Balancer Status Bar -->
{#if balancer}
<div class="flex flex-wrap items-center gap-1 text-xs text-muted-foreground mb-4 px-3 py-2 border rounded-md bg-muted/30">
<span class="flex items-center gap-1 mr-2">
<span class="w-1.5 h-1.5 rounded-full {dotColor(balancer.esl_connected)}"></span>
<Plug class="h-3 w-3" />
ESL {balancer.esl_connected ? 'Online' : 'Offline'}
{#if balancer.esl_uptime_sec > 0}{fmtUptime(balancer.esl_uptime_sec)}{/if}
</span>
<span class="opacity-30">|</span>
<span class="flex items-center gap-1 mr-2">
<span class="w-1.5 h-1.5 rounded-full {dotColor(balancer.nats_connected)}"></span>
<Network class="h-3 w-3" />
NATS {balancer.nats_connected ? 'Online' : 'Offline'}
</span>
<span class="opacity-30">|</span>
<span class="flex items-center gap-1 mr-2">
<span class="w-1.5 h-1.5 rounded-full {balancer.gateways_down > 0 ? 'bg-red-500' : 'bg-green-500'}"></span>
<Globe class="h-3 w-3" />
GW {balancer.gateways_up}/{balancer.gateways_total}
{#if balancer.gateways_down > 0}
<span class="text-red-500">({balancer.gateways_down} down)</span>
{/if}
</span>
<span class="opacity-30">|</span>
<span class="flex items-center gap-1">
<ArrowLeftRight class="h-3 w-3" />
R {fmtNum(balancer.route_total)}
{#if balancer.route_fallbacks > 0}
<span class="text-orange-500">({fallbackPct}% fb)</span>
{/if}
</span>
{#if balancer.esl_connected && balancer.fs_max_sessions > 0}
<span class="opacity-30">|</span>
<span class="flex items-center gap-1">
<Phone class="h-3 w-3" />
FS {balancer.fs_active_calls}·{balancer.fs_max_sessions}
{#if balancer.fs_uptime}
<span class="opacity-50">{balancer.fs_uptime}</span>
{/if}
</span>
{/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>
{/if}
<!-- Summary Cards --> <!-- Summary Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="border rounded-lg p-4 bg-card"> <div class="border rounded-lg p-4 bg-card">
-401
View File
@@ -1,401 +0,0 @@
<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>