# 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.

Агенты PBX отправляют метрики в NATS каждые 5 секунд. Балансировщик вычисляет weighted score готовности каждой ноды, кэширует лучшую и отдаёт её FreeSWITCH через `/api/route` для следующего звонка.

---

## Features

- **pulse-lets-go-agent** — агент сбора метрик для FreeSWITCH (ESL) и Asterisk (AMI). Работает на каждой PBX-ноде.
- **Scoring engine** — lethal checks (disabled, stale, overload, CPU, failure rate) + weighted score (call, load, idle, fail) с конфигурируемыми весами.
- **NATS шина** — асинхронный сбор метрик через `pulse.metrics.<node_id>`.
- **Auto-создание транков** — при получении первой метрики от новой ноды с `sip_gateway` создаётся balance-транк автоматически.
- **Gateway health WS** — статус FS-gateway (registered/unregistered) транслируется на фронт через WebSocket.
- **REST API** — JWT (access + refresh), роли admin/viewer, rate limiting, CORS, health endpoints.
- **WebSocket** — real-time трансляция метрик на SvelteKit дашборд.
- **FreeSWITCH ESL** — управление Sofia-шлюзами через raw TCP ESL.
- **Monitoring** — Prometheus и Zabbix эндпоинты с отдельным API-key.
- **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

### 1. Требования

- Go 1.26+
- Node.js 22+
- NATS Server (автоматически качается `make nats`)

### 2. Сборка

```bash
git clone git@git.totmin.ru:en2zmax/pulse-lets-go.git
cd pulse-lets-go
make build
```

### 3. Запуск

```bash
# Терминал 1: NATS
make nats

# Терминал 2: балансировщик
./bin/pulse-lets-go

# Терминал 3: эмулятор метрик (3 здоровые ноды)
make emulate-normal
```

### 4. Проверка

```bash
# Route (без авторизации)
curl 'http://localhost:8080/api/route?caller_id=123&dest=456&ingress_trunk=trk-001'

# Login
curl -X POST http://localhost:8080/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"admin"}'
```

---

## Конфигурация

При первом запуске `config.json` создаётся автоматически в директории `data/` (или `$PULSE_DATA_DIR`). JWT-секрет и API-key генерируются случайно.

### config.json

```json
{
  "nats_url": "nats://localhost:4222",
  "nats_user": "",
  "nats_password": "",
  "listen_addr": ":8080",
  "jwt_secret": "aabbccdd...32-байта-hex",
  "monitoring_api_key": "11223344...32-байта-hex",
  "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"
  }
}
```

| Поле | Дефолт | Описание |
|------|--------|----------|
| `nats_url` | `nats://localhost:4222` | URL NATS сервера |
| `nats_user` | `""` | Имя пользователя NATS (если нужна аутентификация) |
| `nats_password` | `""` | Пароль NATS |
| `listen_addr` | `:8080` | Адрес HTTP-сервера |
| `jwt_secret` | auto | Секрет для подписи JWT (32 байта hex) |
| `monitoring_api_key` | auto | API-ключ для `/api/monitoring/*` |
| `stale_threshold_sec` | `20` | Через сколько секунд без метрик нода считается stale |
| `scoring.idle_cpu_min` | `5` | Минимальный idle CPU. Ниже — lethal |
| `scoring.call_failure_rate_lethal` | `15.0` | Максимальный failure rate. Выше — lethal |
| `scoring.weights.*` | 0.40/0.30/0.20/0.10 | Веса компонентов скора (сумма = 1.0) |
| `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` | `""` | Или имя переменной окружения с паролем |

---

## Scoring Engine

### 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 deploy      # полный деплой в /opt/pulse-lets-go
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
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
```

### Структура директорий

```
/opt/pulse-lets-go/
├── bin/pulse-lets-go       # Бинарник
├── data/
│   ├── config.json         # Конфигурация
│   ├── trunks.json         # Транки
│   └── users.json          # Пользователи
├── web/                    # SvelteKit статика
└── log/
    └── 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"/>
```

### Порядок деплоя (8 шагов)

**Шаг 1 — Деплой на сервер**
```bash
make deploy
scp -r /opt/pulse-lets-go admin@balancer.lan:/opt/
scp contrib/route.lua admin@balancer.lan:/tmp/
```

**Шаг 2 — NATS сервер** (если не установлен)
```bash
ssh admin@balancer.lan
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 admin@PBX_IP:/usr/local/bin/
scp contrib/agent-uc.json admin@PBX_IP:/etc/pulse-lets-go-agent/agent.json

# Запустить:
ssh admin@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:uc-pbx.lan:5060","enabled":true}'
```
Транки также создаются автоматически при получении первой метрики от агента (если в метрике передан `sip_gateway`).

**Шаг 7 — Запуск pulse-lets-go**
```bash
# config.json создастся автоматически при первом запуске в /opt/pulse-lets-go/data/
# Добавить esl блок в config.json:
# "esl": {"host": "127.0.0.1", "port": 8021, "password": "ClueCon"}
/opt/pulse-lets-go/bin/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:uc-pbx.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" }
}
```

| Поле | Тип | 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 (ses-pbx.lan) | freeswitch |

### Deployment

```bash
# 1. Собрать
make build-agent

# 2. Скопировать на ноду
scp bin/pulse-lets-go-agent admin@NODE_IP:/usr/local/bin/

# 3. Создать конфиг
scp contrib/agent-uc.json admin@NODE_IP:/etc/pulse-lets-go-agent/agent.json

# 4. Запустить
ssh admin@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)
```
