# 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
