pulse-lets-go/AGENTS.md
2026-06-25 14:27:41 +07:00

8.0 KiB
Raw Blame History

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 сек)

{
  "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
}

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 (успех):

{ "node_id": "pbx-03", "score": 88, "sip_gateway": "sip:pbx03.lan:5060" }

Response (fallback — все ноды unhealthy):

{
  "fallback": true,
  "sip_gateway": "sip:operator.lan:5060",
  "reason": "all_nodes_unhealthy",
  "nodes": [...]
}

Response (нет нод вообще):

{ "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

Модель транка:

{
  "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="route_call">
  <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

{
  "nats_url": "nats://localhost:4222",
  "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
    }
  }
}

Структура проекта

pulse-lets-go/
├── cmd/pulse-lets-go/main.go
├── internal/
│   ├── api/          — handlers: auth, nodes, trunks, users, monitoring, ws
│   ├── engine/       — scorer + router (sync.RWMutex, best_node кэш)
│   ├── nats/         — subscriber, in-memory node store
│   ├── config/       — manager: чтение/атомарная запись JSON
│   ├── models/       — все общие типы
│   └── log/          — ASCII-metrics логгер + ротация
├── data/             — JSON конфиги (gitignored)
├── web/              — SvelteKit + shadcn-svelte + Tailwind
│   ├── src/routes/
│   │   ├── +layout.svelte
│   │   ├── +page.svelte          # дашборд
│   │   ├── login/+page.svelte
│   │   ├── trunks/+page.svelte
│   │   └── nodes/+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