first commit
This commit is contained in:
commit
44f5fa88e5
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
data/
|
||||||
|
web/build/
|
||||||
|
web/.svelte-kit/
|
||||||
|
web/node_modules/
|
||||||
|
bin/
|
||||||
|
*.log
|
||||||
250
AGENTS.md
Normal file
250
AGENTS.md
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
# 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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="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
|
||||||
|
|
||||||
|
```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
|
||||||
142
Makefile
Normal file
142
Makefile
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
# pulse-lets-go — Telephone Load Balancer
|
||||||
|
# Сборка Go backend + SvelteKit фронта в единый бинарник.
|
||||||
|
|
||||||
|
APP := pulse-lets-go
|
||||||
|
BINDIR := bin
|
||||||
|
WEB_DIR := web
|
||||||
|
DATA_DIR := data
|
||||||
|
|
||||||
|
.PHONY: all build build-go build-web clean run test install \
|
||||||
|
emulate emulate-normal emulate-overload emulate-cpu-low emulate-fail-high \
|
||||||
|
emulate-node-down emulate-stale emulate-chaos build-emulator
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
# Полная сборка: фронт + бекенд
|
||||||
|
build: build-web build-go
|
||||||
|
@echo "✓ Сборка завершена: $(BINDIR)/$(APP)"
|
||||||
|
|
||||||
|
# Сборка SvelteKit фронта
|
||||||
|
build-web:
|
||||||
|
@echo "→ Сборка SvelteKit..."
|
||||||
|
cd $(WEB_DIR) && npm run build
|
||||||
|
|
||||||
|
# Сборка Go бинарника
|
||||||
|
build-go:
|
||||||
|
@mkdir -p $(BINDIR)
|
||||||
|
@echo "→ Сборка Go backend..."
|
||||||
|
go build -o $(BINDIR)/$(APP) ./cmd/$(APP)
|
||||||
|
|
||||||
|
# Быстрая сборка только Go (без фронта)
|
||||||
|
build-go-only:
|
||||||
|
@mkdir -p $(BINDIR)
|
||||||
|
go build -o $(BINDIR)/$(APP) ./cmd/$(APP)
|
||||||
|
|
||||||
|
# Запуск в dev-режиме: Go backend на :8080, SvelteKit dev server на :5173
|
||||||
|
run:
|
||||||
|
@echo "→ Запуск pulse-lets-go (dev)"
|
||||||
|
$(BINDIR)/$(APP) &
|
||||||
|
cd $(WEB_DIR) && npm run dev
|
||||||
|
|
||||||
|
# Продакшн сборка с оптимизациями
|
||||||
|
build-prod:
|
||||||
|
@mkdir -p $(BINDIR)
|
||||||
|
cd $(WEB_DIR) && npm run build
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BINDIR)/$(APP) ./cmd/$(APP)
|
||||||
|
|
||||||
|
# Установка: копирует бинарник и статику в /usr/local
|
||||||
|
install: build
|
||||||
|
@echo "→ Установка в /usr/local"
|
||||||
|
mkdir -p /usr/local/share/$(APP)/web
|
||||||
|
cp -r $(WEB_DIR)/build /usr/local/share/$(APP)/web/
|
||||||
|
cp $(BINDIR)/$(APP) /usr/local/bin/$(APP)
|
||||||
|
chmod +x /usr/local/bin/$(APP)
|
||||||
|
@echo "✓ Установлен в /usr/local/bin/$(APP)"
|
||||||
|
|
||||||
|
# Установка systemd unit
|
||||||
|
install-systemd:
|
||||||
|
@echo "→ Установка systemd unit"
|
||||||
|
cp deploy/$(APP).service /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
@echo "✓ systemd unit установлен. Запуск: systemctl start $(APP)"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Эмулятор метрик
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Сборка эмулятора
|
||||||
|
build-emulator:
|
||||||
|
@mkdir -p $(BINDIR)
|
||||||
|
go build -o $(BINDIR)/emulator ./cmd/emulator
|
||||||
|
|
||||||
|
# По умолчанию — normal
|
||||||
|
emulate: emulate-normal
|
||||||
|
|
||||||
|
# Все ноды здоровы, случайная загрузка
|
||||||
|
emulate-normal: nats build-emulator
|
||||||
|
./$(BINDIR)/emulator --scenario normal
|
||||||
|
|
||||||
|
# Последняя нода перегружена (active_calls=max_calls)
|
||||||
|
emulate-overload: nats build-emulator
|
||||||
|
./$(BINDIR)/emulator --scenario overload
|
||||||
|
|
||||||
|
# Последняя нода с низким idle_cpu (< 5)
|
||||||
|
emulate-cpu-low: nats build-emulator
|
||||||
|
./$(BINDIR)/emulator --scenario cpu-low
|
||||||
|
|
||||||
|
# Последняя нода с высоким call_failure_rate (> 15)
|
||||||
|
emulate-fail-high: nats build-emulator
|
||||||
|
./$(BINDIR)/emulator --scenario fail-high
|
||||||
|
|
||||||
|
# Последняя нода status="down"
|
||||||
|
emulate-node-down: nats build-emulator
|
||||||
|
./$(BINDIR)/emulator --scenario node-down
|
||||||
|
|
||||||
|
# Первая нода замолкает через 10 сек (stale)
|
||||||
|
emulate-stale: nats build-emulator
|
||||||
|
./$(BINDIR)/emulator --scenario stale
|
||||||
|
|
||||||
|
# Случайные lethal-состояния
|
||||||
|
emulate-chaos: nats build-emulator
|
||||||
|
./$(BINDIR)/emulator --scenario chaos
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# NATS и запуск
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Запуск NATS-сервера для разработки (в фоне)
|
||||||
|
nats:
|
||||||
|
@echo "→ Запуск NATS на порту 4222..."
|
||||||
|
@if ! pgrep -x nats-server > /dev/null; then \
|
||||||
|
$(BINDIR)/nats-server -p 4222 -D &>/dev/null & \
|
||||||
|
disown; \
|
||||||
|
echo "✓ NATS запущен"; \
|
||||||
|
else \
|
||||||
|
echo "NATS уже запущен"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Быстрый старт: NATS + pulse-lets-go
|
||||||
|
start: nats
|
||||||
|
@echo "→ Запуск pulse-lets-go..."
|
||||||
|
$(BINDIR)/$(APP)
|
||||||
|
|
||||||
|
# Очистка
|
||||||
|
clean:
|
||||||
|
rm -rf $(BINDIR)
|
||||||
|
cd $(WEB_DIR) && rm -rf build .svelte-kit
|
||||||
|
|
||||||
|
# Тесты
|
||||||
|
test:
|
||||||
|
go test ./... -v -cover
|
||||||
|
|
||||||
|
# Запуск тестов с race detector
|
||||||
|
test-race:
|
||||||
|
go test ./... -race -v
|
||||||
|
|
||||||
|
# Линтинг
|
||||||
|
lint:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# Форматирование
|
||||||
|
fmt:
|
||||||
|
go fmt ./...
|
||||||
332
cmd/emulator/main.go
Normal file
332
cmd/emulator/main.go
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
// Эмулятор метрик для pulse-lets-go.
|
||||||
|
// Публикует метрики от эмулируемых нод в NATS, имитируя разные сценарии нагрузки.
|
||||||
|
//
|
||||||
|
// Сценарии:
|
||||||
|
// normal — все ноды здоровы, случайная загрузка (проверка weighted score + best_node)
|
||||||
|
// overload — последняя нода перегружена (active_calls >= max_calls → lethal)
|
||||||
|
// cpu-low — последняя нода с низким idle_cpu (< 5 → lethal)
|
||||||
|
// fail-high — последняя нода с высоким call_failure_rate (> 15 → lethal)
|
||||||
|
// node-down — последняя нода status="down" (→ lethal)
|
||||||
|
// stale — первая нода перестаёт отправлять метрики через 10 сек (→ lethal)
|
||||||
|
// chaos — случайное чередование lethal-состояний у случайных нод
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
natsgo "github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Модель метрики ---
|
||||||
|
|
||||||
|
type nodeMetric struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
TS int64 `json:"ts"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ActiveCalls int `json:"active_calls"`
|
||||||
|
MaxCalls int `json:"max_calls"`
|
||||||
|
IdleCPU float64 `json:"idle_cpu"`
|
||||||
|
LoadAvg float64 `json:"load_avg"`
|
||||||
|
CallFailureRate float64 `json:"call_failure_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Спецификация значения (фиксированное или случайный диапазон) ---
|
||||||
|
|
||||||
|
type valueSpec struct {
|
||||||
|
fixed float64
|
||||||
|
min float64
|
||||||
|
max float64
|
||||||
|
isRand bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixed(v float64) valueSpec { return valueSpec{fixed: v} }
|
||||||
|
func rrange(min, max float64) valueSpec {
|
||||||
|
return valueSpec{min: min, max: max, isRand: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v valueSpec) get() float64 {
|
||||||
|
if v.isRand {
|
||||||
|
return v.min + rand.Float64()*(v.max-v.min)
|
||||||
|
}
|
||||||
|
return v.fixed
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Конфигурация ноды в эмуляторе ---
|
||||||
|
|
||||||
|
type nodeSpec struct {
|
||||||
|
nodeID string
|
||||||
|
status valueSpec // строка: ok / down
|
||||||
|
calls valueSpec
|
||||||
|
maxCalls float64 // фиксированное
|
||||||
|
idleCPU valueSpec
|
||||||
|
loadAvg valueSpec
|
||||||
|
failRate valueSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ns *nodeSpec) generate() nodeMetric {
|
||||||
|
return nodeMetric{
|
||||||
|
NodeID: ns.nodeID,
|
||||||
|
TS: time.Now().Unix(),
|
||||||
|
Status: statusFromValue(ns.status.get()),
|
||||||
|
ActiveCalls: int(ns.calls.get()),
|
||||||
|
MaxCalls: int(ns.maxCalls),
|
||||||
|
IdleCPU: ns.idleCPU.get(),
|
||||||
|
LoadAvg: ns.loadAvg.get(),
|
||||||
|
CallFailureRate: ns.failRate.get(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusFromValue(v float64) string {
|
||||||
|
if v < 0.5 {
|
||||||
|
return "down"
|
||||||
|
}
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Сценарии ---
|
||||||
|
|
||||||
|
func scenarioNormal() []nodeSpec {
|
||||||
|
return []nodeSpec{
|
||||||
|
{
|
||||||
|
nodeID: "pbx-01", status: fixed(1),
|
||||||
|
calls: rrange(10, 100), maxCalls: 250,
|
||||||
|
idleCPU: rrange(40, 95), loadAvg: rrange(0.1, 1.0), failRate: rrange(0, 2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeID: "pbx-02", status: fixed(1),
|
||||||
|
calls: rrange(50, 200), maxCalls: 250,
|
||||||
|
idleCPU: rrange(15, 45), loadAvg: rrange(0.5, 2.0), failRate: rrange(1, 8),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeID: "pbx-03", status: fixed(1),
|
||||||
|
calls: rrange(5, 60), maxCalls: 250,
|
||||||
|
idleCPU: rrange(60, 90), loadAvg: rrange(0.05, 0.5), failRate: rrange(0, 1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenarioOverload() []nodeSpec {
|
||||||
|
specs := scenarioNormal()
|
||||||
|
// Последняя нода перегружена: active_calls = max_calls → lethal
|
||||||
|
specs[len(specs)-1].calls = fixed(250)
|
||||||
|
specs[len(specs)-1].idleCPU = fixed(10)
|
||||||
|
specs[len(specs)-1].loadAvg = fixed(5.0)
|
||||||
|
return specs
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenarioCPULow() []nodeSpec {
|
||||||
|
specs := scenarioNormal()
|
||||||
|
specs[len(specs)-1].idleCPU = fixed(3) // < 5 → lethal
|
||||||
|
return specs
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenarioFailHigh() []nodeSpec {
|
||||||
|
specs := scenarioNormal()
|
||||||
|
specs[len(specs)-1].failRate = fixed(20) // > 15 → lethal
|
||||||
|
return specs
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenarioNodeDown() []nodeSpec {
|
||||||
|
specs := scenarioNormal()
|
||||||
|
specs[len(specs)-1].status = fixed(0) // status=down → lethal
|
||||||
|
return specs
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenarioStale() []nodeSpec {
|
||||||
|
// Identical to normal — stale управляется логикой в main (пропуск отправки)
|
||||||
|
return scenarioNormal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main ---
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
scenario := flag.String("scenario", "normal",
|
||||||
|
"Сценарий: normal | overload | cpu-low | fail-high | node-down | stale | chaos")
|
||||||
|
natsURL := flag.String("nats", "nats://localhost:4222", "NATS URL")
|
||||||
|
interval := flag.Duration("interval", 5*time.Second, "Интервал отправки метрик")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
nc, err := natsgo.Connect(*natsURL, natsgo.MaxReconnects(-1))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("ошибка NATS: %v", err)
|
||||||
|
}
|
||||||
|
defer nc.Close()
|
||||||
|
|
||||||
|
var specs []nodeSpec
|
||||||
|
switch *scenario {
|
||||||
|
case "normal":
|
||||||
|
specs = scenarioNormal()
|
||||||
|
case "overload":
|
||||||
|
specs = scenarioOverload()
|
||||||
|
case "cpu-low":
|
||||||
|
specs = scenarioCPULow()
|
||||||
|
case "fail-high":
|
||||||
|
specs = scenarioFailHigh()
|
||||||
|
case "node-down":
|
||||||
|
specs = scenarioNodeDown()
|
||||||
|
case "stale":
|
||||||
|
specs = scenarioStale()
|
||||||
|
case "chaos":
|
||||||
|
specs = scenarioChaos()
|
||||||
|
default:
|
||||||
|
log.Fatalf("неизвестный сценарий: %s", *scenario)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[emulator] сценарий=%s, нод=%d, NATS=%s, интервал=%s",
|
||||||
|
*scenario, len(specs), *natsURL, *interval)
|
||||||
|
|
||||||
|
if *scenario == "chaos" {
|
||||||
|
runChaos(nc, specs, *interval)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *scenario == "stale" {
|
||||||
|
runStale(nc, specs, *interval)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runNormal(nc, specs, *interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runNormal — обычный цикл: все ноды шлют метрики каждые interval.
|
||||||
|
func runNormal(nc *natsgo.Conn, specs []nodeSpec, interval time.Duration) {
|
||||||
|
log.Printf("[emulator] запущен, Ctrl+C для остановки")
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
// Первая отправка сразу
|
||||||
|
publishAll(nc, specs)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
publishAll(nc, specs)
|
||||||
|
case <-quit:
|
||||||
|
log.Println("[emulator] остановлен")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStale — первая нода шлёт метрики только первые 10 секунд, потом замолкает.
|
||||||
|
func runStale(nc *natsgo.Conn, specs []nodeSpec, interval time.Duration) {
|
||||||
|
log.Printf("[emulator] сценарий stale: нода %s замолкнет через 10 сек", specs[0].nodeID)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
staleNodeID := specs[0].nodeID
|
||||||
|
|
||||||
|
publishAll(nc, specs)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
for _, spec := range specs {
|
||||||
|
if spec.nodeID == staleNodeID && elapsed > 10*time.Second {
|
||||||
|
continue // пропускаем stale-ноду
|
||||||
|
}
|
||||||
|
metric := spec.generate()
|
||||||
|
publish(nc, metric)
|
||||||
|
}
|
||||||
|
case <-quit:
|
||||||
|
log.Println("[emulator] остановлен")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runChaos — случайно чередует lethal-состояния у случайных нод.
|
||||||
|
func runChaos(nc *natsgo.Conn, specs []nodeSpec, interval time.Duration) {
|
||||||
|
log.Printf("[emulator] сценарий chaos: случайные lethal-состояния")
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
// Сохраняем оригинальные спецификации для восстановления
|
||||||
|
original := make([]nodeSpec, len(specs))
|
||||||
|
copy(original, specs)
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
publishAll(nc, specs)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
mu.Lock()
|
||||||
|
// Каждые 15-30 секунд случайно портим случайную ноду
|
||||||
|
if rand.Intn(4) == 0 { // ~25% шанс на каждом тике
|
||||||
|
idx := rand.Intn(len(specs))
|
||||||
|
kind := rand.Intn(5)
|
||||||
|
switch kind {
|
||||||
|
case 0:
|
||||||
|
specs[idx].status = fixed(0) // down
|
||||||
|
case 1:
|
||||||
|
specs[idx].calls = fixed(250) // overload
|
||||||
|
case 2:
|
||||||
|
specs[idx].idleCPU = fixed(2) // idle_cpu < 5
|
||||||
|
case 3:
|
||||||
|
specs[idx].failRate = fixed(25) // failure rate > 15
|
||||||
|
case 4:
|
||||||
|
// Восстановить оригинал
|
||||||
|
specs[idx] = original[idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publishAll(nc, specs)
|
||||||
|
mu.Unlock()
|
||||||
|
case <-quit:
|
||||||
|
log.Println("[emulator] остановлен")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenarioChaos() []nodeSpec {
|
||||||
|
// Начинаем с normal
|
||||||
|
return scenarioNormal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func publishAll(nc *natsgo.Conn, specs []nodeSpec) {
|
||||||
|
for _, spec := range specs {
|
||||||
|
metric := spec.generate()
|
||||||
|
publish(nc, metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publish(nc *natsgo.Conn, m nodeMetric) {
|
||||||
|
subject := fmt.Sprintf("pulse.metrics.%s", m.NodeID)
|
||||||
|
data, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[emulator] ошибка сериализации %s: %v", m.NodeID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := nc.Publish(subject, data); err != nil {
|
||||||
|
log.Printf("[emulator] ошибка публикации %s: %v", m.NodeID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[emulator] → %s: calls=%d/%d idle=%.0f%% load=%.2f fail=%.1f%% status=%s",
|
||||||
|
m.NodeID, m.ActiveCalls, m.MaxCalls, m.IdleCPU, m.LoadAvg, m.CallFailureRate, m.Status)
|
||||||
|
}
|
||||||
258
cmd/pulse-lets-go/main.go
Normal file
258
cmd/pulse-lets-go/main.go
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
// pulse-lets-go — балансировщик телефонной нагрузки на базе FreeSWITCH + NATS.
|
||||||
|
// Принимает метрики от нижестоящих АТС, вычисляет score готовности,
|
||||||
|
// возвращает лучшую ноду для следующего вызова через /api/route.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/api"
|
||||||
|
"github.com/pulse-lets-go/internal/config"
|
||||||
|
"github.com/pulse-lets-go/internal/engine"
|
||||||
|
"github.com/pulse-lets-go/internal/esl"
|
||||||
|
filelog "github.com/pulse-lets-go/internal/log"
|
||||||
|
"github.com/pulse-lets-go/internal/nats"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Определяем рабочую директорию
|
||||||
|
exe, _ := os.Executable()
|
||||||
|
exeDir := filepath.Dir(exe)
|
||||||
|
dataDir := filepath.Join(exeDir, "data")
|
||||||
|
|
||||||
|
// Разрешаем переопределение через переменную окружения
|
||||||
|
if envDir := os.Getenv("PULSE_DATA_DIR"); envDir != "" {
|
||||||
|
dataDir = envDir
|
||||||
|
}
|
||||||
|
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
log.Printf("[main] запуск pulse-lets-go, data_dir=%s", dataDir)
|
||||||
|
|
||||||
|
// 1. Инициализация конфиг-менеджера (создаёт data/ и дефолтные JSON при первом запуске)
|
||||||
|
cfgMgr, err := config.NewManager(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[main] ошибка инициализации конфигов: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Чтение конфига
|
||||||
|
cfg, err := cfgMgr.ReadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[main] ошибка чтения config.json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.1 Валидация конфига
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
log.Fatalf("[main] ошибка валидации конфига:\n%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[main] NATS=%s, listen=%s, stale_threshold=%dсек, tls=%v, log=%s/%s",
|
||||||
|
cfg.NatsURL, cfg.ListenAddr, cfg.StaleThresholdSec, cfg.TLS.Enabled, cfg.Log.Level, cfg.Log.Format)
|
||||||
|
|
||||||
|
// 3. Engine
|
||||||
|
eng := engine.NewEngine(cfg)
|
||||||
|
|
||||||
|
// 4. ASCII логгер метрик
|
||||||
|
metricsLogger, err := filelog.NewLogger(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[main] ошибка создания логгера: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. NATS подписчик
|
||||||
|
sub, err := nats.NewSubscriber(cfg.NatsURL, cfg.NatsUser, cfg.NatsPassword, eng, metricsLogger)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[main] ошибка NATS: %v", err)
|
||||||
|
}
|
||||||
|
defer sub.Close()
|
||||||
|
|
||||||
|
// 6. HTTP API
|
||||||
|
apiHandler := api.NewAPI(eng, cfgMgr, cfg.JWTSecret, cfg.MonitoringAPIKey, sub.IsConnected, cfg)
|
||||||
|
handler := apiHandler.Handler()
|
||||||
|
|
||||||
|
// 6.1 ESL-клиент (опционально — если сконфигурирован)
|
||||||
|
var eslClient *esl.Client
|
||||||
|
if cfg.ESL.Host != "" {
|
||||||
|
eslPassword := cfg.ESL.GetPassword()
|
||||||
|
eslClient = esl.NewClient(cfg.ESL.Host, cfg.ESL.Port, eslPassword)
|
||||||
|
apiHandler.SetESLClient(eslClient)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() {
|
||||||
|
eslClient.StartEventLoop(ctx, cfg.ESL.SofiaProfile,
|
||||||
|
func() {
|
||||||
|
trunks, err := cfgMgr.ReadTrunks()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[esl] ошибка чтения транков для синхронизации: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var specs []esl.GatewaySpec
|
||||||
|
for _, t := range trunks {
|
||||||
|
if t.Type == "ingress" && t.Enabled {
|
||||||
|
specs = append(specs, esl.GatewaySpec{
|
||||||
|
Name: fmt.Sprintf("pulse-ingress-%s", t.ID),
|
||||||
|
Proxy: extractHostFromGateway(t.Gateway),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eslClient.GatewaySyncAll(cfg.ESL.SofiaProfile, specs)
|
||||||
|
},
|
||||||
|
func(eventName string, headers map[string]string, body string) {
|
||||||
|
log.Printf("[esl] событие: %s", eventName)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESL shutdown при завершении main
|
||||||
|
defer func() {
|
||||||
|
if eslClient != nil {
|
||||||
|
eslClient.Disconnect()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Подключаем раздачу SvelteKit статики, если директория существует
|
||||||
|
webDir := findWebDir(exeDir)
|
||||||
|
if webDir != "" {
|
||||||
|
log.Printf("[main] раздача SvelteKit статики из %s", webDir)
|
||||||
|
handler = withSPA(webDir, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.ListenAddr,
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Запуск HTTP-сервера
|
||||||
|
go func() {
|
||||||
|
log.Printf("[main] HTTP сервер запущен на %s (tls=%v)", cfg.ListenAddr, cfg.TLS.Enabled)
|
||||||
|
log.Printf("[main] готов к работе:")
|
||||||
|
log.Printf(" GET /api/health — статус зависимостей")
|
||||||
|
log.Printf(" POST /api/auth/login — аутентификация")
|
||||||
|
log.Printf(" GET /api/route — маршрутизация (FreeSWITCH)")
|
||||||
|
log.Printf(" GET /api/nodes — список нод")
|
||||||
|
log.Printf(" GET /api/trunks — управление транками")
|
||||||
|
log.Printf(" GET /api/users — управление пользователями")
|
||||||
|
log.Printf(" GET /api/monitoring/zabbix — Zabbix LLD + items")
|
||||||
|
log.Printf(" GET /api/monitoring/prometheus — Prometheus метрики")
|
||||||
|
log.Printf(" WS /ws/metrics — real-time метрики на фронт")
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if cfg.TLS.Enabled {
|
||||||
|
err = srv.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile)
|
||||||
|
} else {
|
||||||
|
err = srv.ListenAndServe()
|
||||||
|
}
|
||||||
|
if err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("[main] ошибка HTTP сервера: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 8. Graceful shutdown
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
sig := <-quit
|
||||||
|
log.Printf("[main] получен сигнал %s, завершение работы...", sig)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("[main] ошибка остановки HTTP сервера: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub.Close()
|
||||||
|
metricsLogger.Close()
|
||||||
|
|
||||||
|
log.Println("[main] pulse-lets-go остановлен")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findWebDir ищет директорию со SvelteKit сборкой (web/build/).
|
||||||
|
// Пробует по порядку: переменная окружения, рядом с бинарником, в корне проекта, CWD.
|
||||||
|
func findWebDir(exeDir string) string {
|
||||||
|
candidates := []string{
|
||||||
|
os.Getenv("PULSE_WEB_DIR"),
|
||||||
|
filepath.Join(exeDir, "web", "build"), // bin/web/build
|
||||||
|
filepath.Join(filepath.Dir(exeDir), "web", "build"), // ../web/build (корень проекта)
|
||||||
|
"web/build", // ./web/build (CWD)
|
||||||
|
}
|
||||||
|
for _, d := range candidates {
|
||||||
|
if d == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stat, err := os.Stat(d); err == nil && stat.IsDir() {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSPA — middleware для раздачи SvelteKit SPA.
|
||||||
|
// Запросы к /api/* и /ws/* проксируются в API handler.
|
||||||
|
// Остальные запросы ищут статический файл в webDir; если файл не найден,
|
||||||
|
// возвращается index.html (SPA fallback для клиентского роутинга).
|
||||||
|
func withSPA(webDir string, apiHandler http.Handler) http.Handler {
|
||||||
|
fs := http.FileServer(http.Dir(webDir))
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
|
||||||
|
// API и WebSocket запросы — передаём в API
|
||||||
|
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") {
|
||||||
|
apiHandler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пробуем отдать статический файл
|
||||||
|
filePath := filepath.Join(webDir, path)
|
||||||
|
info, err := os.Stat(filePath)
|
||||||
|
|
||||||
|
// Если файл существует и не является директорией — отдаём
|
||||||
|
if err == nil && !info.IsDir() {
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA fallback: index.html
|
||||||
|
indexPath := filepath.Join(webDir, "index.html")
|
||||||
|
if _, err := os.Stat(indexPath); err == nil {
|
||||||
|
http.ServeFile(w, r, indexPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если index.html тоже нет — 404
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHostFromGateway извлекает хост из SIP URI.
|
||||||
|
// "sip:mts-gw.lan:5060" → "mts-gw.lan"
|
||||||
|
func extractHostFromGateway(gateway string) string {
|
||||||
|
s := strings.TrimPrefix(gateway, "sip:")
|
||||||
|
s = strings.TrimPrefix(s, "sips:")
|
||||||
|
if idx := strings.LastIndex(s, ":"); idx > 0 {
|
||||||
|
port := s[idx+1:]
|
||||||
|
isNumeric := true
|
||||||
|
for _, c := range port {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
isNumeric = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isNumeric {
|
||||||
|
s = s[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
156
contrib/route.lua
Normal file
156
contrib/route.lua
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
--[[
|
||||||
|
route.lua — запрашивает pulse-lets-go для маршрутизации вызова.
|
||||||
|
Телефонный балансировщик нагрузки на базе FreeSWITCH + NATS.
|
||||||
|
|
||||||
|
┌─ Размещение ─────────────────────────────────────────────────────┐
|
||||||
|
│ /etc/freeswitch/scripts/route.lua │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Использование в dialplan ────────────────────────────────────┐│
|
||||||
|
│ │ <extension name="route_call"> ││
|
||||||
|
│ │ <condition field="destination_number" expression="^.*$"> ││
|
||||||
|
│ │ <action application="lua" data="route.lua"/> ││
|
||||||
|
│ │ </condition> ││
|
||||||
|
│ │ </extension> ││
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘│
|
||||||
|
│ │
|
||||||
|
│ ┌─ Переменные канала (опционально) ─────────────────────────────┐│
|
||||||
|
│ │ balancer_url — URL pulse-lets-go (default: localhost:8080) ││
|
||||||
|
│ │ ingress_trunk — ID входящего транка ││
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘│
|
||||||
|
│ │
|
||||||
|
│ ┌─ Требования FreeSWITCH ───────────────────────────────────────┐│
|
||||||
|
│ │ mod_curl — <load module="mod_curl"/> в modules.conf.xml ││
|
||||||
|
│ │ cjson — входит в стандартную поставку FS Lua ││
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘│
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Логика:
|
||||||
|
1. Извлечь caller_id + destination из переменных сессии
|
||||||
|
2. HTTP GET /api/route?caller_id=X&dest=Y&ingress_trunk=Z
|
||||||
|
3. Разобрать JSON-ответ
|
||||||
|
4. При fallback=true → bridge на fallback gateway (оператор)
|
||||||
|
5. При ошибке → кладём трубку с NORMAL_TEMPORARY_FAILURE
|
||||||
|
6. Нормальный маршрут → bridge на sip_gateway выбранной ноды
|
||||||
|
]]--
|
||||||
|
|
||||||
|
-- ===================================================================
|
||||||
|
-- Конфигурация
|
||||||
|
-- ===================================================================
|
||||||
|
|
||||||
|
-- URL балансировщика (переопределяется через переменную канала)
|
||||||
|
local BALANCER_URL = session:getVariable("balancer_url") or "http://localhost:8080"
|
||||||
|
|
||||||
|
-- Таймаут HTTP-запроса в секундах (через параметр curl)
|
||||||
|
local HTTP_TIMEOUT = tonumber(session:getVariable("route_timeout") or "3")
|
||||||
|
|
||||||
|
-- JSON-парсер
|
||||||
|
local cjson = require("cjson")
|
||||||
|
|
||||||
|
-- API FreeSWITCH для вызова mod_curl
|
||||||
|
local api = freeswitch.API()
|
||||||
|
|
||||||
|
-- ===================================================================
|
||||||
|
-- Вспомогательные функции
|
||||||
|
-- ===================================================================
|
||||||
|
|
||||||
|
-- Логирование с префиксом [route]
|
||||||
|
local function log(level, msg)
|
||||||
|
freeswitch.consoleLog(level, "[route] " .. msg .. "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Выполнить HTTP GET и вернуть тело ответа (без заголовков).
|
||||||
|
-- mod_curl API возвращает сырой HTTP-ответ: заголовки + \r?\n\r?\n + тело.
|
||||||
|
local function http_get(url)
|
||||||
|
local cmd = "curl " .. url
|
||||||
|
local raw = api:execute("curl", cmd)
|
||||||
|
|
||||||
|
if not raw or raw == "" then
|
||||||
|
return nil, "пустой ответ от curl"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Отделяем заголовки от тела
|
||||||
|
-- Ищем двойной перенос строки (CRLF или LF)
|
||||||
|
local body = raw:match("\r?\n\r?\n(.+)")
|
||||||
|
if not body then
|
||||||
|
-- Если нет заголовков — весь ответ это тело
|
||||||
|
return raw, nil
|
||||||
|
end
|
||||||
|
return body, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Безопасная отправка трубки с кодом причины
|
||||||
|
local function safe_hangup(cause)
|
||||||
|
if session:ready() then
|
||||||
|
session:hangup(cause or "NORMAL_TEMPORARY_FAILURE")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ===================================================================
|
||||||
|
-- Основная логика
|
||||||
|
-- ===================================================================
|
||||||
|
|
||||||
|
-- Извлекаем параметры вызова
|
||||||
|
local caller_id = session:getVariable("caller_id_number") or ""
|
||||||
|
local dest = session:getVariable("destination_number") or ""
|
||||||
|
local ing_trunk = session:getVariable("ingress_trunk") or ""
|
||||||
|
|
||||||
|
-- Собираем URL
|
||||||
|
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
|
||||||
|
|
||||||
|
log("info", "запрос маршрута: " .. url)
|
||||||
|
|
||||||
|
-- Запрашиваем маршрут у pulse-lets-go
|
||||||
|
local body, curl_err = http_get(url)
|
||||||
|
if not body then
|
||||||
|
log("err", "ошибка HTTP: " .. (curl_err or "unknown"))
|
||||||
|
safe_hangup()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Парсим JSON
|
||||||
|
local ok, route = pcall(cjson.decode, body)
|
||||||
|
if not ok or type(route) ~= "table" then
|
||||||
|
log("err", "ошибка парсинга JSON: " .. tostring(route))
|
||||||
|
safe_hangup()
|
||||||
|
return
|
||||||
|
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()
|
||||||
30
deploy/pulse-lets-go.service
Normal file
30
deploy/pulse-lets-go.service
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=pulse-lets-go — Telephone Load Balancer
|
||||||
|
Documentation=https://github.com/pulse-lets-go
|
||||||
|
After=network.target nats.service
|
||||||
|
Wants=nats.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pulse
|
||||||
|
Group=pulse
|
||||||
|
ExecStart=/usr/local/bin/pulse-lets-go
|
||||||
|
WorkingDirectory=/usr/local/share/pulse-lets-go
|
||||||
|
Environment=PULSE_DATA_DIR=/etc/pulse-lets-go
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Безопасность
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ReadWritePaths=/etc/pulse-lets-go /var/log/pulse-lets-go
|
||||||
|
|
||||||
|
# Логирование
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=pulse-lets-go
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
17
go.mod
Normal file
17
go.mod
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
module github.com/pulse-lets-go
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/nats-io/nats.go v1.52.0
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
)
|
||||||
16
go.sum
Normal file
16
go.sum
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
|
||||||
|
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||||
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
271
internal/api/auth.go
Normal file
271
internal/api/auth.go
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
// Package api реализует HTTP API pulse-lets-go.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWT claims
|
||||||
|
type claims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextKey — тип для ключей context.
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
userContextKey contextKey = "user"
|
||||||
|
refreshTokenKey contextKey = "refresh_token"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserInfo — информация о пользователе, извлекаемая из JWT.
|
||||||
|
type UserInfo struct {
|
||||||
|
UserID string
|
||||||
|
Username string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
jwtExpiry = 15 * time.Minute
|
||||||
|
refreshExpiry = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateJWT создаёт access+refresh токены.
|
||||||
|
func (a *API) generateJWT(user *models.User) (*models.LoginResponse, error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Access token
|
||||||
|
accessClaims := claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: string(user.Role),
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(jwtExpiry)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(a.jwtSecret))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
refreshClaims := claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: string(user.Role),
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(refreshExpiry)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(a.jwtSecret))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.LoginResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresIn: int64(jwtExpiry.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authMiddleware — проверяет JWT в заголовке Authorization.
|
||||||
|
func (a *API) authMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tokenStr := extractBearerToken(r)
|
||||||
|
if tokenStr == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "требуется авторизация")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, c, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(a.jwtSecret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
writeError(w, http.StatusUnauthorized, "недействительный токен")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &UserInfo{
|
||||||
|
UserID: c.UserID,
|
||||||
|
Username: c.Username,
|
||||||
|
Role: c.Role,
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminOnly — middleware проверки роли admin.
|
||||||
|
func (a *API) adminOnly(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
if user == nil || user.Role != string(models.RoleAdmin) {
|
||||||
|
writeError(w, http.StatusForbidden, "требуется роль admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiKeyMiddleware — проверяет API-key для monitoring endpoint.
|
||||||
|
func (a *API) apiKeyMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key := r.Header.Get("X-API-Key")
|
||||||
|
if key == "" || key != a.monitoringAPIKey {
|
||||||
|
writeError(w, http.StatusUnauthorized, "недействительный API-ключ")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogin обрабатывает POST /api/auth/login.
|
||||||
|
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "имя и пароль обязательны")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := a.configManager.ReadUsers()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[auth] ошибка чтения users.json: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка сервера")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var found *models.User
|
||||||
|
for i := range users {
|
||||||
|
if users[i].Username == req.Username {
|
||||||
|
found = &users[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found == nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "неверное имя или пароль")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если пароль не задан (плейсхолдер), разрешаем вход с паролем "admin"
|
||||||
|
if found.PasswordHash == "" {
|
||||||
|
if req.Password != "admin" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "неверное имя или пароль")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Хешируем пароль и сохраняем
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
found.PasswordHash = string(hash)
|
||||||
|
a.configManager.SaveUsers(users)
|
||||||
|
} else {
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(found.PasswordHash), []byte(req.Password)); err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "неверное имя или пароль")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := a.generateJWT(found)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[auth] ошибка генерации JWT: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка сервера")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRefresh выдаёт новый access токен по refresh токену.
|
||||||
|
func (a *API) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tokenStr := extractBearerToken(r)
|
||||||
|
if tokenStr == "" {
|
||||||
|
// пробуем получить из query param (для WebSocket)
|
||||||
|
tokenStr = r.URL.Query().Get("token")
|
||||||
|
}
|
||||||
|
if tokenStr == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "требуется токен")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, c, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(a.jwtSecret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
writeError(w, http.StatusUnauthorized, "недействительный refresh токен")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
ID: c.UserID,
|
||||||
|
Username: c.Username,
|
||||||
|
Role: models.UserRole(c.Role),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := a.generateJWT(user)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка сервера")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Хелперы ---
|
||||||
|
|
||||||
|
// extractBearerToken извлекает JWT из заголовка Authorization: Bearer <token>.
|
||||||
|
func extractBearerToken(r *http.Request) string {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserFromContext извлекает UserInfo из контекста запроса.
|
||||||
|
func GetUserFromContext(ctx context.Context) *UserInfo {
|
||||||
|
user, ok := ctx.Value(userContextKey).(*UserInfo)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON отправляет JSON-ответ.
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeError отправляет JSON-ошибку.
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
63
internal/api/health.go
Normal file
63
internal/api/health.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleHealth — GET /api/health — возвращает статус всех зависимостей.
|
||||||
|
func (a *API) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stats := a.engine.GetHealthStats()
|
||||||
|
|
||||||
|
natsStatus := "disconnected"
|
||||||
|
if a.natsConnected != nil && a.natsConnected() {
|
||||||
|
natsStatus = "connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
eslInfo := map[string]interface{}{"status": "disabled"}
|
||||||
|
if a.eslClient != nil {
|
||||||
|
eslStats := a.eslClient.GetStats()
|
||||||
|
eslInfo = map[string]interface{}{
|
||||||
|
"status": eslStats.Status,
|
||||||
|
"host": eslStats.Host,
|
||||||
|
"uptime_seconds": eslStats.UptimeSec,
|
||||||
|
"reconnects": eslStats.Reconnects,
|
||||||
|
"gateway_ops": eslStats.GatewayOps,
|
||||||
|
"events_recv": eslStats.EventsRecv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"connections": map[string]interface{}{
|
||||||
|
"nats": natsStatus,
|
||||||
|
"esl": eslInfo,
|
||||||
|
},
|
||||||
|
"stats": stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLiveness — GET /api/health/live — всегда 200, если процесс жив.
|
||||||
|
func (a *API) handleLiveness(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "alive"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReadiness — GET /api/health/ready — 200 когда NATS подключён, метрики поступают.
|
||||||
|
// ESL опционален — не требуется для готовности.
|
||||||
|
func (a *API) handleReadiness(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.natsConnected != nil && !a.natsConnected() {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||||
|
"status": "not_ready",
|
||||||
|
"reason": "nats_disconnected",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !a.engine.HasRecentMetrics(60) {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||||
|
"status": "not_ready",
|
||||||
|
"reason": "no_recent_metrics",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||||
|
}
|
||||||
84
internal/api/logging.go
Normal file
84
internal/api/logging.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requestLoggingMiddleware логирует каждый HTTP-запрос с Request ID, методом, путём, статусом и длительностью.
|
||||||
|
func requestLoggingMiddleware(logFormat string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Не логируем health-check запросы и OPTIONS
|
||||||
|
if r.URL.Path == "/api/health/live" || r.URL.Path == "/api/health/ready" || r.Method == "OPTIONS" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reqID := generateRequestID()
|
||||||
|
r.Header.Set("X-Request-ID", reqID)
|
||||||
|
w.Header().Set("X-Request-ID", reqID)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
|
|
||||||
|
next.ServeHTTP(lrw, r)
|
||||||
|
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
// Извлекаем пользователя из контекста (может быть nil)
|
||||||
|
userStr := "-"
|
||||||
|
if user := GetUserFromContext(r.Context()); user != nil {
|
||||||
|
userStr = user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
switch logFormat {
|
||||||
|
case "json":
|
||||||
|
entry := map[string]interface{}{
|
||||||
|
"ts": start.UTC().Format(time.RFC3339),
|
||||||
|
"id": reqID,
|
||||||
|
"method": r.Method,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"status": lrw.statusCode,
|
||||||
|
"duration_ms": float64(duration.Microseconds()) / 1000.0,
|
||||||
|
"remote_addr": r.RemoteAddr,
|
||||||
|
"user": userStr,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(entry)
|
||||||
|
log.Printf("%s", string(data))
|
||||||
|
default:
|
||||||
|
log.Printf("[%s] %s %s %d %.2fms %s user=%s",
|
||||||
|
reqID, r.Method, r.URL.Path, lrw.statusCode,
|
||||||
|
float64(duration.Microseconds())/1000.0, r.RemoteAddr, userStr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRequestID генерирует случайный Request ID: req-XXXXXXXX.
|
||||||
|
func generateRequestID() string {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "req-" + hex.EncodeToString([]byte{0, 0, 0, 0, 0, 0})
|
||||||
|
}
|
||||||
|
return "req-" + hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingResponseWriter оборачивает http.ResponseWriter для перехвата статус-кода.
|
||||||
|
type loggingResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *loggingResponseWriter) WriteHeader(code int) {
|
||||||
|
w.statusCode = code
|
||||||
|
w.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *loggingResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
78
internal/api/monitoring.go
Normal file
78
internal/api/monitoring.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleZabbix — GET /api/monitoring/zabbix — возвращает JSON для Zabbix LLD + items.
|
||||||
|
func (a *API) handleZabbix(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nodes := a.engine.GetAllNodes()
|
||||||
|
|
||||||
|
data := make([]models.ZabbixNode, 0, len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
data = append(data, models.ZabbixNode{
|
||||||
|
NodeID: n.NodeID,
|
||||||
|
Status: n.Status,
|
||||||
|
ActiveCalls: n.ActiveCalls,
|
||||||
|
MaxCalls: n.MaxCalls,
|
||||||
|
IdleCPU: n.IdleCPU,
|
||||||
|
LoadAvg: n.LoadAvg,
|
||||||
|
CallFailureRate: n.CallFailureRate,
|
||||||
|
Score: n.Score,
|
||||||
|
Disabled: n.Disabled,
|
||||||
|
IsStale: n.IsStale,
|
||||||
|
SecondsAgo: n.SecondsAgo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, models.ZabbixResponse{Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePrometheus — GET /api/monitoring/prometheus — text/plain метрики для Prometheus.
|
||||||
|
func (a *API) handlePrometheus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nodes := a.engine.GetAllNodes()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
for _, n := range nodes {
|
||||||
|
id := sanitizePromLabel(n.NodeID)
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "pulse_node_active_calls{node=\"%s\"} %d\n", id, n.ActiveCalls)
|
||||||
|
fmt.Fprintf(&sb, "pulse_node_max_calls{node=\"%s\"} %d\n", id, n.MaxCalls)
|
||||||
|
fmt.Fprintf(&sb, "pulse_node_idle_cpu{node=\"%s\"} %.2f\n", id, n.IdleCPU)
|
||||||
|
fmt.Fprintf(&sb, "pulse_node_load_avg{node=\"%s\"} %.2f\n", id, n.LoadAvg)
|
||||||
|
fmt.Fprintf(&sb, "pulse_node_call_failure_rate{node=\"%s\"} %.2f\n", id, n.CallFailureRate)
|
||||||
|
fmt.Fprintf(&sb, "pulse_node_score{node=\"%s\"} %.2f\n", id, n.Score)
|
||||||
|
fmt.Fprintf(&sb, "pulse_node_seconds_ago{node=\"%s\"} %.2f\n", id, n.SecondsAgo)
|
||||||
|
|
||||||
|
disabledVal := 0
|
||||||
|
staleVal := 0
|
||||||
|
statusVal := 1 // ok
|
||||||
|
if n.Disabled {
|
||||||
|
disabledVal = 1
|
||||||
|
}
|
||||||
|
if n.IsStale {
|
||||||
|
staleVal = 1
|
||||||
|
}
|
||||||
|
if n.Status != "ok" {
|
||||||
|
statusVal = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "pulse_node_disabled{node=\"%s\"} %d\n", id, disabledVal)
|
||||||
|
fmt.Fprintf(&sb, "pulse_node_stale{node=\"%s\"} %d\n", id, staleVal)
|
||||||
|
fmt.Fprintf(&sb, "pulse_node_status_ok{node=\"%s\"} %d\n", id, statusVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(sb.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizePromLabel заменяет символы, недопустимые в Prometheus-лейблах.
|
||||||
|
func sanitizePromLabel(s string) string {
|
||||||
|
return strings.ReplaceAll(strings.ReplaceAll(s, ".", "_"), "-", "_")
|
||||||
|
}
|
||||||
52
internal/api/nodes.go
Normal file
52
internal/api/nodes.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGetNodes — GET /api/nodes — список всех нод с текущими score.
|
||||||
|
func (a *API) handleGetNodes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nodes := a.engine.GetAllNodes()
|
||||||
|
writeJSON(w, http.StatusOK, nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetNodeMetrics — GET /api/nodes/{id}/metrics — история метрик ноды из ring buffer.
|
||||||
|
func (a *API) handleGetNodeMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nodeID := r.PathValue("id")
|
||||||
|
if nodeID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "не указан id ноды")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots := a.engine.GetNodeHistory(nodeID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"node_id": nodeID,
|
||||||
|
"snapshots": snapshots,
|
||||||
|
"count": len(snapshots),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleToggleNode — PUT /api/nodes/{id}/toggle — включение/выключение ноды.
|
||||||
|
func (a *API) handleToggleNode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nodeID := r.PathValue("id")
|
||||||
|
if nodeID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "не указан id ноды")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.ToggleNodeRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.engine.ToggleNode(nodeID, req.Disabled, req.Reason); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, _ := a.engine.GetNodeInfo(nodeID)
|
||||||
|
writeJSON(w, http.StatusOK, info)
|
||||||
|
}
|
||||||
109
internal/api/ratelimit.go
Normal file
109
internal/api/ratelimit.go
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rateLimiter — in-memory token bucket rate limiter.
|
||||||
|
type rateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buckets map[string]*tokenBucket
|
||||||
|
rate float64 // токенов в секунду
|
||||||
|
burst int // максимальный размер бакета
|
||||||
|
cleanupInterval time.Duration
|
||||||
|
lastCleanup time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenBucket struct {
|
||||||
|
tokens float64
|
||||||
|
lastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRateLimiter создаёт rate limiter с заданными параметрами.
|
||||||
|
func newRateLimiter(ratePerSec, burst int) *rateLimiter {
|
||||||
|
if burst <= 0 {
|
||||||
|
burst = ratePerSec
|
||||||
|
}
|
||||||
|
return &rateLimiter{
|
||||||
|
buckets: make(map[string]*tokenBucket),
|
||||||
|
rate: float64(ratePerSec),
|
||||||
|
burst: burst,
|
||||||
|
cleanupInterval: 60 * time.Second,
|
||||||
|
lastCleanup: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow проверяет, разрешён ли запрос для данного ключа.
|
||||||
|
// Возвращает true если разрешён, false если превышен лимит.
|
||||||
|
func (rl *rateLimiter) allow(key string) bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
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]
|
||||||
|
if !exists {
|
||||||
|
// Новый бакет с полным запасом токенов
|
||||||
|
b = &tokenBucket{tokens: float64(rl.burst), lastSeen: now}
|
||||||
|
rl.buckets[key] = b
|
||||||
|
b.tokens-- // расходуем один токен
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пополняем токены
|
||||||
|
elapsed := now.Sub(b.lastSeen).Seconds()
|
||||||
|
b.tokens += elapsed * rl.rate
|
||||||
|
if b.tokens > float64(rl.burst) {
|
||||||
|
b.tokens = float64(rl.burst)
|
||||||
|
}
|
||||||
|
b.lastSeen = now
|
||||||
|
|
||||||
|
if b.tokens >= 1.0 {
|
||||||
|
b.tokens--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimitMiddleware создаёт middleware с rate limiting по IP.
|
||||||
|
// Ключ — RemoteAddr (или X-Forwarded-For, если за проксей).
|
||||||
|
func rateLimitMiddleware(limiter *rateLimiter) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key := stripPort(r.RemoteAddr)
|
||||||
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||||
|
key = fwd
|
||||||
|
}
|
||||||
|
|
||||||
|
if !limiter.allow(key) {
|
||||||
|
w.Header().Set("Retry-After", "1")
|
||||||
|
writeError(w, http.StatusTooManyRequests, "слишком много запросов")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripPort убирает порт из адреса (напр. "[::1]:58544" → "::1", "127.0.0.1:1234" → "127.0.0.1").
|
||||||
|
func stripPort(addr string) string {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return addr // нет порта
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
102
internal/api/route.go
Normal file
102
internal/api/route.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleRoute — GET /api/route — главный эндпоинт маршрутизации вызова.
|
||||||
|
func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.engine.IncrementRouteRequests()
|
||||||
|
|
||||||
|
nodeID, score, fallback := a.engine.GetBestNode()
|
||||||
|
|
||||||
|
// Нет зарегистрированных нод
|
||||||
|
if nodeID == "" {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, models.RouteResponse{
|
||||||
|
Error: "no_nodes_registered",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все ноды unhealthy — fallback
|
||||||
|
if fallback || score < 0 {
|
||||||
|
fallbackGW, _ := a.findFallbackGateway()
|
||||||
|
nodes := a.getRouteNodeInfo()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, models.RouteResponse{
|
||||||
|
Fallback: true,
|
||||||
|
SIPGateway: fallbackGW,
|
||||||
|
Reason: "all_nodes_unhealthy",
|
||||||
|
Nodes: nodes,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нормальный маршрут
|
||||||
|
gw, ok := a.findBalanceGateway(nodeID)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusInternalServerError, "gateway не найден для ноды")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, models.RouteResponse{
|
||||||
|
NodeID: nodeID,
|
||||||
|
Score: score,
|
||||||
|
SIPGateway: gw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBalanceGateway ищет gateway для balance-транка, привязанного к nodeID.
|
||||||
|
func (a *API) findBalanceGateway(nodeID string) (string, bool) {
|
||||||
|
trunks, err := a.configManager.ReadTrunks()
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, t := range trunks {
|
||||||
|
if t.Type == models.TrunkBalance && t.NodeID == nodeID && t.Enabled {
|
||||||
|
return t.Gateway, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 возвращает список нод с их скорами для ответа маршрутизации.
|
||||||
|
func (a *API) getRouteNodeInfo() []models.RouteNodeInfo {
|
||||||
|
nodes := a.engine.GetAllNodes()
|
||||||
|
result := make([]models.RouteNodeInfo, 0, len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
info := models.RouteNodeInfo{
|
||||||
|
ID: n.NodeID,
|
||||||
|
Score: n.Score,
|
||||||
|
Reason: n.LethalReason,
|
||||||
|
Status: n.Status,
|
||||||
|
}
|
||||||
|
if n.Disabled {
|
||||||
|
info.Status = "disabled"
|
||||||
|
} else if n.IsStale {
|
||||||
|
info.Status = "stale"
|
||||||
|
} else if n.Score < 0 {
|
||||||
|
info.Status = "unhealthy"
|
||||||
|
} else {
|
||||||
|
info.Status = "healthy"
|
||||||
|
}
|
||||||
|
result = append(result, info)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
137
internal/api/router.go
Normal file
137
internal/api/router.go
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/config"
|
||||||
|
"github.com/pulse-lets-go/internal/engine"
|
||||||
|
"github.com/pulse-lets-go/internal/esl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// API — главная структура HTTP API, агрегирует все зависимости.
|
||||||
|
type API struct {
|
||||||
|
engine *engine.Engine
|
||||||
|
configManager *config.Manager
|
||||||
|
jwtSecret string
|
||||||
|
monitoringAPIKey string
|
||||||
|
wsHub *wsHub
|
||||||
|
natsConnected func() bool // колбэк для проверки NATS-статуса (health)
|
||||||
|
eslClient *esl.Client // ESL-клиент (nil если не сконфигурирован)
|
||||||
|
logFormat string
|
||||||
|
routeLimiter *rateLimiter
|
||||||
|
apiLimiter *rateLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAPI создаёт новый HTTP API с заданными зависимостями.
|
||||||
|
func NewAPI(eng *engine.Engine, cfgMgr *config.Manager, jwtSecret, monitoringAPIKey string, natsFn func() bool, cfg *config.Config) *API {
|
||||||
|
a := &API{
|
||||||
|
engine: eng,
|
||||||
|
configManager: cfgMgr,
|
||||||
|
jwtSecret: jwtSecret,
|
||||||
|
monitoringAPIKey: monitoringAPIKey,
|
||||||
|
wsHub: newWSHub(),
|
||||||
|
natsConnected: natsFn,
|
||||||
|
logFormat: cfg.Log.Format,
|
||||||
|
}
|
||||||
|
if cfg.RateLimit.Enabled {
|
||||||
|
a.routeLimiter = newRateLimiter(cfg.RateLimit.RoutePerSec, cfg.RateLimit.RoutePerSec)
|
||||||
|
a.apiLimiter = newRateLimiter(cfg.RateLimit.APIPerSec, cfg.RateLimit.APIPerSec)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetESLClient устанавливает ESL-клиент (вызывается после инициализации ESL).
|
||||||
|
func (a *API) SetESLClient(client *esl.Client) {
|
||||||
|
a.eslClient = client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler возвращает http.Handler со всеми зарегистрированными маршрутами.
|
||||||
|
func (a *API) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// --- Health endpoints (без авторизации) ---
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/health", a.handleHealth)
|
||||||
|
mux.HandleFunc("GET /api/health/live", a.handleLiveness)
|
||||||
|
mux.HandleFunc("GET /api/health/ready", a.handleReadiness)
|
||||||
|
|
||||||
|
// --- Публичные маршруты (без авторизации) ---
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/auth/login", a.handleLogin)
|
||||||
|
mux.HandleFunc("POST /api/auth/refresh", a.handleRefresh)
|
||||||
|
mux.HandleFunc("GET /api/route", a.handleRoute)
|
||||||
|
mux.HandleFunc("GET /ws/metrics", a.handleWS)
|
||||||
|
|
||||||
|
// Monitoring с API-key (без JWT)
|
||||||
|
mux.Handle("GET /api/monitoring/zabbix", a.apiKeyMiddleware(http.HandlerFunc(a.handleZabbix)))
|
||||||
|
mux.Handle("GET /api/monitoring/prometheus", a.apiKeyMiddleware(http.HandlerFunc(a.handlePrometheus)))
|
||||||
|
|
||||||
|
// --- Админка: Nodes (admin + viewer) ---
|
||||||
|
|
||||||
|
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("PUT /api/nodes/{id}/toggle", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleToggleNode))))
|
||||||
|
|
||||||
|
// --- Админка: Trunks (admin only) ---
|
||||||
|
|
||||||
|
mux.Handle("GET /api/trunks", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleGetTrunks))))
|
||||||
|
mux.Handle("POST /api/trunks", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleCreateTrunk))))
|
||||||
|
mux.Handle("PUT /api/trunks/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleUpdateTrunk))))
|
||||||
|
mux.Handle("DELETE /api/trunks/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleDeleteTrunk))))
|
||||||
|
|
||||||
|
// --- Админка: Users (admin only) ---
|
||||||
|
|
||||||
|
mux.Handle("GET /api/users", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleGetUsers))))
|
||||||
|
mux.Handle("POST /api/users", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleCreateUser))))
|
||||||
|
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))))
|
||||||
|
|
||||||
|
// Логирование + CORS (навешиваем после mux)
|
||||||
|
var wrapped http.Handler = mux
|
||||||
|
wrapped = requestLoggingMiddleware(a.logFormat)(wrapped)
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
if a.routeLimiter != nil && a.apiLimiter != nil {
|
||||||
|
handlerBeforeRL := wrapped
|
||||||
|
wrapped = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/route" {
|
||||||
|
rateLimitMiddleware(a.routeLimiter)(handlerBeforeRL).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(r.URL.Path) > 5 && r.URL.Path[:5] == "/api/" {
|
||||||
|
rateLimitMiddleware(a.apiLimiter)(handlerBeforeRL).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlerBeforeRL.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return corsMiddleware(wrapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// corsMiddleware добавляет CORS-заголовки.
|
||||||
|
func corsMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeJSON декодирует тело запроса в структуру v.
|
||||||
|
func decodeJSON(r *http.Request, v interface{}) error {
|
||||||
|
defer r.Body.Close()
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||||
|
return fmt.Errorf("декодирование JSON: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
306
internal/api/trunks.go
Normal file
306
internal/api/trunks.go
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/config"
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGetTrunks — GET /api/trunks — список всех транков с опциональным фильтром.
|
||||||
|
func (a *API) handleGetTrunks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
trunks, err := a.configManager.ReadTrunks()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка чтения транков")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по типу
|
||||||
|
typeFilter := r.URL.Query().Get("type")
|
||||||
|
if typeFilter != "" {
|
||||||
|
filtered := make([]models.Trunk, 0)
|
||||||
|
for _, t := range trunks {
|
||||||
|
if string(t.Type) == typeFilter {
|
||||||
|
filtered = append(filtered, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trunks = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортировка по created_at
|
||||||
|
sort.Slice(trunks, func(i, j int) bool {
|
||||||
|
return trunks[i].CreatedAt.Before(trunks[j].CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, trunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateTrunk — POST /api/trunks — создание нового транка.
|
||||||
|
func (a *API) handleCreateTrunk(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.CreateTrunkRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateTrunk(req.Name, req.Type, req.Gateway); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем: fallback может быть только один
|
||||||
|
trunks, err := a.configManager.ReadTrunks()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка чтения транков")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Type == models.TrunkFallback {
|
||||||
|
for _, t := range trunks {
|
||||||
|
if t.Type == models.TrunkFallback {
|
||||||
|
writeError(w, http.StatusConflict, "fallback-транк уже существует (должен быть ровно 1)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
trunk := models.Trunk{
|
||||||
|
ID: generateID("trk"),
|
||||||
|
Name: req.Name,
|
||||||
|
Type: req.Type,
|
||||||
|
NodeID: req.NodeID,
|
||||||
|
Gateway: req.Gateway,
|
||||||
|
Codecs: req.Codecs,
|
||||||
|
Context: req.Context,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
Description: req.Description,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
trunks = append(trunks, trunk)
|
||||||
|
if err := a.configManager.SaveTrunks(trunks); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка сохранения транка")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESL push: создаём gateway на FS для ingress-транка
|
||||||
|
a.eslPushGatewayAdd(trunk)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, trunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateTrunk — PUT /api/trunks/{id} — обновление транка.
|
||||||
|
func (a *API) handleUpdateTrunk(w http.ResponseWriter, r *http.Request) {
|
||||||
|
trunkID := r.PathValue("id")
|
||||||
|
if trunkID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "не указан id транка")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.UpdateTrunkRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trunks, err := a.configManager.ReadTrunks()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка чтения транков")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := findTrunkIndex(trunks, trunkID)
|
||||||
|
if idx < 0 {
|
||||||
|
writeError(w, http.StatusNotFound, "транк не найден")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем частичные обновления
|
||||||
|
t := &trunks[idx]
|
||||||
|
if req.Name != nil {
|
||||||
|
t.Name = *req.Name
|
||||||
|
}
|
||||||
|
if req.Type != nil {
|
||||||
|
// Проверка fallback-уникальности
|
||||||
|
if *req.Type == models.TrunkFallback && t.Type != models.TrunkFallback {
|
||||||
|
for _, other := range trunks {
|
||||||
|
if other.Type == models.TrunkFallback && other.ID != trunkID {
|
||||||
|
writeError(w, http.StatusConflict, "fallback-транк уже существует")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Type = *req.Type
|
||||||
|
}
|
||||||
|
if req.NodeID != nil {
|
||||||
|
t.NodeID = *req.NodeID
|
||||||
|
}
|
||||||
|
if req.Gateway != nil {
|
||||||
|
t.Gateway = *req.Gateway
|
||||||
|
}
|
||||||
|
if req.Codecs != nil {
|
||||||
|
t.Codecs = req.Codecs
|
||||||
|
}
|
||||||
|
if req.Context != nil {
|
||||||
|
t.Context = *req.Context
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
t.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
t.Description = *req.Description
|
||||||
|
}
|
||||||
|
t.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
if err := a.configManager.SaveTrunks(trunks); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка сохранения транка")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESL push: обновляем gateway на FS
|
||||||
|
a.eslPushGatewayUpdate(*t)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteTrunk — DELETE /api/trunks/{id} — удаление транка.
|
||||||
|
func (a *API) handleDeleteTrunk(w http.ResponseWriter, r *http.Request) {
|
||||||
|
trunkID := r.PathValue("id")
|
||||||
|
if trunkID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "не указан id транка")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trunks, err := a.configManager.ReadTrunks()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка чтения транков")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := findTrunkIndex(trunks, trunkID)
|
||||||
|
if idx < 0 {
|
||||||
|
writeError(w, http.StatusNotFound, "транк не найден")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedTrunk := trunks[idx]
|
||||||
|
trunks = append(trunks[:idx], trunks[idx+1:]...)
|
||||||
|
|
||||||
|
// ESL push: удаляем gateway на FS
|
||||||
|
a.eslPushGatewayDelete(deletedTrunk)
|
||||||
|
|
||||||
|
if err := a.configManager.SaveTrunks(trunks); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка сохранения транков")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func validateTrunk(name string, trunkType models.TrunkType, gateway string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("имя транка обязательно")
|
||||||
|
}
|
||||||
|
if gateway == "" {
|
||||||
|
return fmt.Errorf("gateway обязателен")
|
||||||
|
}
|
||||||
|
switch trunkType {
|
||||||
|
case models.TrunkIngress, models.TrunkBalance, models.TrunkFallback:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("некорректный тип транка: %s", trunkType)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTrunkIndex(trunks []models.Trunk, id string) int {
|
||||||
|
for i := range trunks {
|
||||||
|
if trunks[i].ID == id {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ESL push helpers ---
|
||||||
|
|
||||||
|
// eslPushGatewayAdd создаёт gateway на FS для ingress-транка.
|
||||||
|
func (a *API) eslPushGatewayAdd(trunk models.Trunk) {
|
||||||
|
if a.eslClient == nil || !a.eslClient.IsConnected() || trunk.Type != models.TrunkIngress {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profile, name, proxy := a.gatewayParams(trunk)
|
||||||
|
if err := a.eslClient.GatewayAdd(profile, name, proxy); err != nil {
|
||||||
|
log.Printf("[esl] GatewayAdd %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslPushGatewayUpdate обновляет gateway на FS (удаляет старый + создаёт новый).
|
||||||
|
func (a *API) eslPushGatewayUpdate(trunk models.Trunk) {
|
||||||
|
if a.eslClient == nil || !a.eslClient.IsConnected() || trunk.Type != models.TrunkIngress {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profile, name, proxy := a.gatewayParams(trunk)
|
||||||
|
_ = a.eslClient.GatewayDelete(profile, name) // игнорируем ошибку — может не существовать
|
||||||
|
if err := a.eslClient.GatewayAdd(profile, name, proxy); err != nil {
|
||||||
|
log.Printf("[esl] GatewayUpdate %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslPushGatewayDelete удаляет gateway на FS для ingress-транка.
|
||||||
|
func (a *API) eslPushGatewayDelete(trunk models.Trunk) {
|
||||||
|
if a.eslClient == nil || !a.eslClient.IsConnected() || trunk.Type != models.TrunkIngress {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profile, name, _ := a.gatewayParams(trunk)
|
||||||
|
if err := a.eslClient.GatewayDelete(profile, name); err != nil {
|
||||||
|
log.Printf("[esl] GatewayDelete %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatewayParams возвращает параметры для создания FS gateway на основе транка.
|
||||||
|
func (a *API) gatewayParams(trunk models.Trunk) (profile, name, proxy string) {
|
||||||
|
profile = a.readConfig().ESL.SofiaProfile
|
||||||
|
name = fmt.Sprintf("pulse-ingress-%s", trunk.ID)
|
||||||
|
proxy = extractHost(trunk.Gateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHost извлекает хост из SIP URI.
|
||||||
|
// "sip:mts-gw.lan:5060" → "mts-gw.lan"
|
||||||
|
func extractHost(gateway string) string {
|
||||||
|
s := strings.TrimPrefix(gateway, "sip:")
|
||||||
|
s = strings.TrimPrefix(s, "sips:")
|
||||||
|
if idx := strings.LastIndex(s, ":"); idx > 0 {
|
||||||
|
// Проверяем, порт ли это (все цифры после двоеточия)
|
||||||
|
port := s[idx+1:]
|
||||||
|
isNumeric := true
|
||||||
|
for _, c := range port {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
isNumeric = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isNumeric {
|
||||||
|
s = s[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// readConfig возвращает текущий конфиг (для получения esl.sofia_profile).
|
||||||
|
func (a *API) readConfig() *config.Config {
|
||||||
|
cfg, _ := a.configManager.ReadConfig()
|
||||||
|
if cfg == nil {
|
||||||
|
return &config.Config{ESL: config.ESLConfig{SofiaProfile: "external"}}
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
241
internal/api/users.go
Normal file
241
internal/api/users.go
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGetUsers — GET /api/users — список пользователей.
|
||||||
|
func (a *API) handleGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := a.configManager.ReadUsers()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка чтения пользователей")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Не возвращаем хэши паролей
|
||||||
|
type safeUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role models.UserRole `json:"role"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
result := make([]safeUser, len(users))
|
||||||
|
for i, u := range users {
|
||||||
|
result[i] = safeUser{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Role: u.Role,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
UpdatedAt: u.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateUser — POST /api/users — создание пользователя.
|
||||||
|
func (a *API) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.CreateUserRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "имя и пароль обязательны")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Role != models.RoleAdmin && req.Role != models.RoleViewer {
|
||||||
|
writeError(w, http.StatusBadRequest, "роль должна быть admin или viewer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := a.configManager.ReadUsers()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка чтения пользователей")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности имени
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Username == req.Username {
|
||||||
|
writeError(w, http.StatusConflict, "пользователь с таким именем уже существует")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка хеширования пароля")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
user := models.User{
|
||||||
|
ID: generateID("user"),
|
||||||
|
Username: req.Username,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Role: req.Role,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
users = append(users, user)
|
||||||
|
if err := a.configManager.SaveUsers(users); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка сохранения пользователя")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]string{
|
||||||
|
"id": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"role": string(user.Role),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateUser — PUT /api/users/{id} — обновление пользователя.
|
||||||
|
func (a *API) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.PathValue("id")
|
||||||
|
if userID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "не указан id пользователя")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.UpdateUserRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := a.configManager.ReadUsers()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка чтения пользователей")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := findUserIndex(users, userID)
|
||||||
|
if idx < 0 {
|
||||||
|
writeError(w, http.StatusNotFound, "пользователь не найден")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u := &users[idx]
|
||||||
|
if req.Username != nil {
|
||||||
|
// Проверка уникальности
|
||||||
|
for i, other := range users {
|
||||||
|
if other.Username == *req.Username && i != idx {
|
||||||
|
writeError(w, http.StatusConflict, "пользователь с таким именем уже существует")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.Username = *req.Username
|
||||||
|
}
|
||||||
|
if req.Password != nil {
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||||
|
u.PasswordHash = string(hash)
|
||||||
|
}
|
||||||
|
if req.Role != nil {
|
||||||
|
if *req.Role != models.RoleAdmin && *req.Role != models.RoleViewer {
|
||||||
|
writeError(w, http.StatusBadRequest, "роль должна быть admin или viewer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Защита первичного администратора от понижения роли
|
||||||
|
if userID == "user-01" && *req.Role != models.RoleAdmin {
|
||||||
|
writeError(w, http.StatusConflict, "роль первичного администратора нельзя изменить")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.Role = *req.Role
|
||||||
|
}
|
||||||
|
u.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
if err := a.configManager.SaveUsers(users); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка сохранения пользователя")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"id": u.ID,
|
||||||
|
"username": u.Username,
|
||||||
|
"role": string(u.Role),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteUser — DELETE /api/users/{id} — удаление пользователя.
|
||||||
|
func (a *API) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.PathValue("id")
|
||||||
|
if userID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "не указан id пользователя")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := a.configManager.ReadUsers()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка чтения пользователей")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := findUserIndex(users, userID)
|
||||||
|
if idx < 0 {
|
||||||
|
writeError(w, http.StatusNotFound, "пользователь не найден")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Защита первичного администратора от удаления
|
||||||
|
if userID == "user-01" {
|
||||||
|
writeError(w, http.StatusConflict, "первичного администратора нельзя удалить")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Не даём удалить последнего админа
|
||||||
|
adminCount := 0
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Role == models.RoleAdmin {
|
||||||
|
adminCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if users[idx].Role == models.RoleAdmin && adminCount <= 1 {
|
||||||
|
writeError(w, http.StatusConflict, "нельзя удалить последнего администратора")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users = append(users[:idx], users[idx+1:]...)
|
||||||
|
if err := a.configManager.SaveUsers(users); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ошибка сохранения пользователей")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findUserIndex находит индекс пользователя по ID.
|
||||||
|
func findUserIndex(users []models.User, id string) int {
|
||||||
|
for i := range users {
|
||||||
|
if users[i].ID == id {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateID генерирует ID вида "prefix-XXXXX".
|
||||||
|
func generateID(prefix string) string {
|
||||||
|
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]
|
||||||
|
}
|
||||||
161
internal/api/ws.go
Normal file
161
internal/api/ws.go
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true // Разрешаем все origins для dev
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsClient — соединение WebSocket одного клиента.
|
||||||
|
type wsClient struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsHub управляет подключёнными WebSocket-клиентами и рассылает метрики.
|
||||||
|
type wsHub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[*wsClient]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWSHub создаёт хаб WebSocket.
|
||||||
|
func newWSHub() *wsHub {
|
||||||
|
return &wsHub{
|
||||||
|
clients: make(map[*wsClient]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add добавляет клиента в хаб.
|
||||||
|
func (h *wsHub) add(c *wsClient) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[c] = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove удаляет клиента из хаба.
|
||||||
|
func (h *wsHub) remove(c *wsClient) {
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.clients[c] {
|
||||||
|
c.conn.Close()
|
||||||
|
delete(h.clients, c)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcast рассылает метрики всем подключённым клиентам.
|
||||||
|
func (h *wsHub) broadcast(msg *models.WsMetricsMessage) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
for client := range h.clients {
|
||||||
|
client.mu.Lock()
|
||||||
|
err := client.conn.WriteJSON(msg)
|
||||||
|
client.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ws] ошибка отправки: %v", err)
|
||||||
|
go h.remove(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWS — WebSocket /ws/metrics.
|
||||||
|
func (a *API) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
// пробуем заголовок
|
||||||
|
token = extractBearerToken(r)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "требуется токен")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация JWT
|
||||||
|
userInfo, err := a.validateToken(token)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "недействительный токен")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ws] ошибка upgrade: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &wsClient{conn: conn}
|
||||||
|
a.wsHub.add(client)
|
||||||
|
|
||||||
|
log.Printf("[ws] клиент подключён: %s (%s)", userInfo.Username, r.RemoteAddr)
|
||||||
|
|
||||||
|
// Отправляем текущее состояние при подключении
|
||||||
|
go func() {
|
||||||
|
nodes := a.engine.GetAllNodes()
|
||||||
|
msg := &models.WsMetricsMessage{
|
||||||
|
Type: "nodes_update",
|
||||||
|
Payload: nodes,
|
||||||
|
}
|
||||||
|
a.wsHub.broadcast(msg)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Читаем из вебсокета (ping/pong + закрытие)
|
||||||
|
go func() {
|
||||||
|
defer a.wsHub.remove(client)
|
||||||
|
for {
|
||||||
|
_, _, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ws] клиент отключён: %s", r.RemoteAddr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastMetrics рассылает метрики всем WS-клиентам (вызывается engine при обновлении).
|
||||||
|
func (a *API) BroadcastMetrics() {
|
||||||
|
nodes := a.engine.GetAllNodes()
|
||||||
|
msg := &models.WsMetricsMessage{
|
||||||
|
Type: "nodes_update",
|
||||||
|
Payload: nodes,
|
||||||
|
}
|
||||||
|
a.wsHub.broadcast(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateToken проверяет JWT и возвращает UserInfo.
|
||||||
|
func (a *API) validateToken(tokenStr string) (*UserInfo, error) {
|
||||||
|
token, err := parseJWT(tokenStr, a.jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJWT парсит JWT токен.
|
||||||
|
func parseJWT(tokenStr string, secret string) (*UserInfo, error) {
|
||||||
|
c := &claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, c, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, fmt.Errorf("недействительный токен: %w", err)
|
||||||
|
}
|
||||||
|
return &UserInfo{
|
||||||
|
UserID: c.UserID,
|
||||||
|
Username: c.Username,
|
||||||
|
Role: c.Role,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
420
internal/config/manager.go
Normal file
420
internal/config/manager.go
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
// Package config управляет JSON-конфигами приложения: config.json, trunks.json, users.json.
|
||||||
|
// Обеспечивает атомарную запись (tmp → rename) и thread-safe доступ через sync.RWMutex.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScoringWeights — веса компонентов скора.
|
||||||
|
type ScoringWeights struct {
|
||||||
|
CallScore float64 `json:"call_score"`
|
||||||
|
LoadScore float64 `json:"load_score"`
|
||||||
|
IdleScore float64 `json:"idle_score"`
|
||||||
|
FailScore float64 `json:"fail_score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScoringConfig — пороги и веса для scoring engine.
|
||||||
|
type ScoringConfig struct {
|
||||||
|
IdleCPUMin float64 `json:"idle_cpu_min"`
|
||||||
|
CallFailureRateLethal float64 `json:"call_failure_rate_lethal"`
|
||||||
|
Weights ScoringWeights `json:"weights"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSConfig — настройки HTTPS.
|
||||||
|
type TLSConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CertFile string `json:"cert_file"`
|
||||||
|
KeyFile string `json:"key_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitConfig — ограничения частоты запросов.
|
||||||
|
type RateLimitConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
RoutePerSec int `json:"route_per_sec"`
|
||||||
|
APIPerSec int `json:"api_per_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogConfig — настройки логирования.
|
||||||
|
type LogConfig struct {
|
||||||
|
Level string `json:"level"` // debug, info, warn, error
|
||||||
|
Format string `json:"format"` // text, json
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESLConfig — настройки подключения к FreeSWITCH ESL.
|
||||||
|
type ESLConfig struct {
|
||||||
|
Host string `json:"host"` // если пустой — ESL не запускается
|
||||||
|
Port int `json:"port"` // порт ESL (по умолчанию 8021)
|
||||||
|
Password string `json:"password"`
|
||||||
|
PasswordEnv string `json:"password_env"` // имя переменной окружения с паролем
|
||||||
|
SofiaProfile string `json:"sofia_profile"` // профиль Sofia (external/internal)
|
||||||
|
GatewayPrefix string `json:"gateway_prefix"` // префикс gateway (pulse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPassword возвращает ESL-пароль: из переменной окружения, если задан password_env, иначе из поля password.
|
||||||
|
func (e *ESLConfig) GetPassword() string {
|
||||||
|
if e.PasswordEnv != "" {
|
||||||
|
if val := os.Getenv(e.PasswordEnv); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config — корневая конфигурация приложения (config.json).
|
||||||
|
type Config struct {
|
||||||
|
NatsURL string `json:"nats_url"`
|
||||||
|
NatsUser string `json:"nats_user"`
|
||||||
|
NatsPassword string `json:"nats_password"`
|
||||||
|
ListenAddr string `json:"listen_addr"`
|
||||||
|
JWTSecret string `json:"jwt_secret"`
|
||||||
|
MonitoringAPIKey string `json:"monitoring_api_key"`
|
||||||
|
StaleThresholdSec int `json:"stale_threshold_sec"`
|
||||||
|
Scoring ScoringConfig `json:"scoring"`
|
||||||
|
TLS TLSConfig `json:"tls"`
|
||||||
|
RateLimit RateLimitConfig `json:"rate_limit"`
|
||||||
|
Log LogConfig `json:"log"`
|
||||||
|
ESL ESLConfig `json:"esl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate проверяет корректность всех полей конфига.
|
||||||
|
// Возвращает читаемое описание всех найденных ошибок.
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
if c.NatsURL == "" {
|
||||||
|
errs = append(errs, "nats_url обязателен")
|
||||||
|
}
|
||||||
|
if c.ListenAddr == "" {
|
||||||
|
errs = append(errs, "listen_addr обязателен")
|
||||||
|
}
|
||||||
|
if c.StaleThresholdSec <= 0 {
|
||||||
|
errs = append(errs, "stale_threshold_sec должен быть > 0")
|
||||||
|
}
|
||||||
|
if c.JWTSecret == "" {
|
||||||
|
errs = append(errs, "jwt_secret обязателен")
|
||||||
|
}
|
||||||
|
if c.MonitoringAPIKey == "" {
|
||||||
|
errs = append(errs, "monitoring_api_key обязателен")
|
||||||
|
}
|
||||||
|
if c.Scoring.IdleCPUMin < 0 {
|
||||||
|
errs = append(errs, "scoring.idle_cpu_min должен быть >= 0")
|
||||||
|
}
|
||||||
|
if c.Scoring.CallFailureRateLethal < 0 || c.Scoring.CallFailureRateLethal > 100 {
|
||||||
|
errs = append(errs, "scoring.call_failure_rate_lethal должен быть 0..100")
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &c.Scoring.Weights
|
||||||
|
sum := w.CallScore + w.LoadScore + w.IdleScore + w.FailScore
|
||||||
|
if math.Abs(sum-1.0) > 0.01 {
|
||||||
|
errs = append(errs, fmt.Sprintf("scoring.weights сумма должна быть ≈1.0 (сейчас %.2f)", sum))
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.TLS.Enabled {
|
||||||
|
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" {
|
||||||
|
errs = append(errs, "tls.cert_file и tls.key_file обязательны при tls.enabled=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.RateLimit.Enabled {
|
||||||
|
if c.RateLimit.RoutePerSec <= 0 {
|
||||||
|
errs = append(errs, "rate_limit.route_per_sec должен быть > 0")
|
||||||
|
}
|
||||||
|
if c.RateLimit.APIPerSec <= 0 {
|
||||||
|
errs = append(errs, "rate_limit.api_per_sec должен быть > 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch c.Log.Level {
|
||||||
|
case "debug", "info", "warn", "error", "":
|
||||||
|
default:
|
||||||
|
errs = append(errs, "log.level должен быть debug/info/warn/error")
|
||||||
|
}
|
||||||
|
switch c.Log.Format {
|
||||||
|
case "text", "json", "":
|
||||||
|
default:
|
||||||
|
errs = append(errs, "log.format должен быть text/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESL validation
|
||||||
|
if c.ESL.Host != "" {
|
||||||
|
if c.ESL.Port <= 0 || c.ESL.Port > 65535 {
|
||||||
|
errs = append(errs, "esl.port должен быть 1-65535")
|
||||||
|
}
|
||||||
|
if c.ESL.SofiaProfile == "" {
|
||||||
|
errs = append(errs, "esl.sofia_profile обязателен при esl.host")
|
||||||
|
}
|
||||||
|
if c.ESL.Password == "" && c.ESL.PasswordEnv == "" {
|
||||||
|
errs = append(errs, "esl.password или esl.password_env обязателен при esl.host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("ошибки конфигурации:\n - %s", strings.Join(errs, "\n - "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager управляет чтением и атомарной записью JSON-конфигов.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
dir string // путь к data/
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager создаёт новый менеджер конфигов из директории dataDir.
|
||||||
|
// Если файлы отсутствуют — создаёт их с дефолтными значениями.
|
||||||
|
func NewManager(dataDir string) (*Manager, error) {
|
||||||
|
// Убеждаемся, что директория существует
|
||||||
|
if err := os.MkdirAll(dataDir, 0750); err != nil {
|
||||||
|
return nil, fmt.Errorf("создание data/: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Manager{dir: dataDir}
|
||||||
|
|
||||||
|
// Инициализируем конфиги при первом запуске
|
||||||
|
if _, err := os.Stat(m.configPath()); os.IsNotExist(err) {
|
||||||
|
if err := m.createDefaultConfig(); err != nil {
|
||||||
|
return nil, fmt.Errorf("создание config.json: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(m.trunksPath()); os.IsNotExist(err) {
|
||||||
|
if err := m.saveFile(m.trunksPath(), []models.Trunk{}); err != nil {
|
||||||
|
return nil, fmt.Errorf("создание trunks.json: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(m.usersPath()); os.IsNotExist(err) {
|
||||||
|
if err := m.createDefaultUsers(); err != nil {
|
||||||
|
return nil, fmt.Errorf("создание users.json: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadConfig читает config.json. Потокобезопасно.
|
||||||
|
func (m *Manager) ReadConfig() (*Config, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(m.configPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("чтение config.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("парсинг config.json: %w", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConfig атомарно сохраняет config.json.
|
||||||
|
func (m *Manager) SaveConfig(cfg *Config) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("сериализация config.json: %w", err)
|
||||||
|
}
|
||||||
|
return m.saveFile(m.configPath(), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadTrunks читает trunks.json. Потокобезопасно.
|
||||||
|
func (m *Manager) ReadTrunks() ([]models.Trunk, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.readTrunksLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readTrunksLocked — внутреннее чтение без блокировки (вызывающий держит lock).
|
||||||
|
func (m *Manager) readTrunksLocked() ([]models.Trunk, error) {
|
||||||
|
data, err := os.ReadFile(m.trunksPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("чтение trunks.json: %w", err)
|
||||||
|
}
|
||||||
|
var trunks []models.Trunk
|
||||||
|
if err := json.Unmarshal(data, &trunks); err != nil {
|
||||||
|
return nil, fmt.Errorf("парсинг trunks.json: %w", err)
|
||||||
|
}
|
||||||
|
return trunks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTrunks атомарно сохраняет trunks.json.
|
||||||
|
func (m *Manager) SaveTrunks(trunks []models.Trunk) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.saveTrunksLocked(trunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) saveTrunksLocked(trunks []models.Trunk) error {
|
||||||
|
data, err := json.MarshalIndent(trunks, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("сериализация trunks.json: %w", err)
|
||||||
|
}
|
||||||
|
return m.saveFile(m.trunksPath(), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadUsers читает users.json. Потокобезопасно.
|
||||||
|
func (m *Manager) ReadUsers() ([]models.User, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.readUsersLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) readUsersLocked() ([]models.User, error) {
|
||||||
|
data, err := os.ReadFile(m.usersPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("чтение users.json: %w", err)
|
||||||
|
}
|
||||||
|
var users []models.User
|
||||||
|
if err := json.Unmarshal(data, &users); err != nil {
|
||||||
|
return nil, fmt.Errorf("парсинг users.json: %w", err)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveUsers атомарно сохраняет users.json.
|
||||||
|
func (m *Manager) SaveUsers(users []models.User) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.saveUsersLocked(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) saveUsersLocked(users []models.User) error {
|
||||||
|
data, err := json.MarshalIndent(users, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("сериализация users.json: %w", err)
|
||||||
|
}
|
||||||
|
return m.saveFile(m.usersPath(), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Приватные методы ---
|
||||||
|
|
||||||
|
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) usersPath() string { return filepath.Join(m.dir, "users.json") }
|
||||||
|
|
||||||
|
// saveFile атомарно сохраняет данные в файл: tmp → rename.
|
||||||
|
// Вызывающий держит write lock.
|
||||||
|
func (m *Manager) saveFile(path string, data interface{}) error {
|
||||||
|
var raw []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch v := data.(type) {
|
||||||
|
case []byte:
|
||||||
|
raw = v
|
||||||
|
default:
|
||||||
|
raw, err = json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("сериализация: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, raw, 0640); err != nil {
|
||||||
|
return fmt.Errorf("запись временного файла: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
os.Remove(tmpPath) // clean up
|
||||||
|
return fmt.Errorf("атомарный rename: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDefaultConfig генерирует config.json со значениями по умолчанию.
|
||||||
|
func (m *Manager) createDefaultConfig() error {
|
||||||
|
jwtSecret, err := generateRandomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiKey, err := generateRandomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
NatsURL: "nats://localhost:4222",
|
||||||
|
NatsUser: "",
|
||||||
|
NatsPassword: "",
|
||||||
|
ListenAddr: ":8080",
|
||||||
|
JWTSecret: jwtSecret,
|
||||||
|
MonitoringAPIKey: apiKey,
|
||||||
|
StaleThresholdSec: 20,
|
||||||
|
Scoring: ScoringConfig{
|
||||||
|
IdleCPUMin: 5.0,
|
||||||
|
CallFailureRateLethal: 15.0,
|
||||||
|
Weights: ScoringWeights{
|
||||||
|
CallScore: 0.40,
|
||||||
|
LoadScore: 0.30,
|
||||||
|
IdleScore: 0.20,
|
||||||
|
FailScore: 0.10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TLS: TLSConfig{
|
||||||
|
Enabled: false,
|
||||||
|
CertFile: "",
|
||||||
|
KeyFile: "",
|
||||||
|
},
|
||||||
|
RateLimit: RateLimitConfig{
|
||||||
|
Enabled: true,
|
||||||
|
RoutePerSec: 5000,
|
||||||
|
APIPerSec: 100,
|
||||||
|
},
|
||||||
|
Log: LogConfig{
|
||||||
|
Level: "info",
|
||||||
|
Format: "text",
|
||||||
|
},
|
||||||
|
ESL: ESLConfig{
|
||||||
|
Host: "",
|
||||||
|
Port: 8021,
|
||||||
|
Password: "",
|
||||||
|
PasswordEnv: "",
|
||||||
|
SofiaProfile: "external",
|
||||||
|
GatewayPrefix: "pulse",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.SaveConfig(&cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDefaultUsers создаёт users.json с дефолтным админом (admin/admin).
|
||||||
|
// Пароль хранится в bcrypt. На первом запуске запрашивается смена.
|
||||||
|
func (m *Manager) createDefaultUsers() error {
|
||||||
|
// Используем плейсхолдер-хэш "admin" — при первом логине админ обязан сменить пароль.
|
||||||
|
// Хэш будет заменён на bcrypt при создании/обновлении через API.
|
||||||
|
defaultTime := time.Now().UTC()
|
||||||
|
users := []models.User{
|
||||||
|
{
|
||||||
|
ID: "user-01",
|
||||||
|
Username: "admin",
|
||||||
|
Role: models.RoleAdmin,
|
||||||
|
CreatedAt: defaultTime,
|
||||||
|
UpdatedAt: defaultTime,
|
||||||
|
// PasswordHash будет установлен при первом логине через API
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return m.saveUsersLocked(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomHex генерирует случайную hex-строку заданной длины в байтах.
|
||||||
|
func generateRandomHex(bytes int) (string, error) {
|
||||||
|
buf := make([]byte, bytes)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("генерация случайных байт: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
331
internal/engine/engine.go
Normal file
331
internal/engine/engine.go
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
// Package engine содержит scoring engine и router выбора лучшей ноды.
|
||||||
|
// Потокобезопасен: RWMutex разделяет чтение (роутинг) и запись (обновление метрик).
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/config"
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultRingCapacity = 360 // 30 минут истории при метриках каждые 5 сек
|
||||||
|
|
||||||
|
// Engine управляет состоянием нод и вычислением скора.
|
||||||
|
type Engine struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
nodes map[string]*models.NodeState
|
||||||
|
rings map[string]*MetricsRingBuffer // история метрик на каждую ноду
|
||||||
|
cfg *config.Config
|
||||||
|
|
||||||
|
// Кэш для O(1) доступа из /api/route
|
||||||
|
bestNodeID string
|
||||||
|
bestScore float64
|
||||||
|
fallbackActive bool // true, если все ноды имеют score < 0
|
||||||
|
|
||||||
|
// Статистика для health endpoint
|
||||||
|
startTime time.Time
|
||||||
|
routeRequests atomic.Int64
|
||||||
|
lastMetricTime atomic.Int64 // unix ts последней полученной метрики
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine создаёт новый engine с заданной конфигурацией.
|
||||||
|
func NewEngine(cfg *config.Config) *Engine {
|
||||||
|
return &Engine{
|
||||||
|
nodes: make(map[string]*models.NodeState),
|
||||||
|
rings: make(map[string]*MetricsRingBuffer),
|
||||||
|
cfg: cfg,
|
||||||
|
bestScore: -1,
|
||||||
|
startTime: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateConfig обновляет конфигурацию скоринга (при изменении через API).
|
||||||
|
func (e *Engine) UpdateConfig(cfg *config.Config) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.cfg = cfg
|
||||||
|
e.recalculateAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMetric принимает метрику из NATS, обновляет состояние ноды и пересчитывает score.
|
||||||
|
// disabled-флаг НЕ трогается — он управляется только через ToggleNode.
|
||||||
|
func (e *Engine) UpdateMetric(m *models.NodeMetric) *models.NodeState {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
ns, exists := e.nodes[m.NodeID]
|
||||||
|
if !exists {
|
||||||
|
ns = &models.NodeState{NodeMetric: *m}
|
||||||
|
e.nodes[m.NodeID] = ns
|
||||||
|
} else {
|
||||||
|
// Сохраняем admin-флаги
|
||||||
|
disabled := ns.Disabled
|
||||||
|
disabledReason := ns.DisabledReason
|
||||||
|
ns.NodeMetric = *m
|
||||||
|
ns.Disabled = disabled
|
||||||
|
ns.DisabledReason = disabledReason
|
||||||
|
}
|
||||||
|
|
||||||
|
e.scoreNodeLocked(ns)
|
||||||
|
e.recalcBestLocked()
|
||||||
|
|
||||||
|
// Обновляем время последней метрики
|
||||||
|
e.lastMetricTime.Store(m.TS)
|
||||||
|
|
||||||
|
// Добавляем снапшот в кольцевой буфер истории
|
||||||
|
ring, ok := e.rings[m.NodeID]
|
||||||
|
if !ok {
|
||||||
|
ring = newMetricsRingBuffer(defaultRingCapacity)
|
||||||
|
e.rings[m.NodeID] = ring
|
||||||
|
}
|
||||||
|
ring.push(NodeSnapshot{
|
||||||
|
ActiveCalls: ns.ActiveCalls,
|
||||||
|
MaxCalls: ns.MaxCalls,
|
||||||
|
IdleCPU: ns.IdleCPU,
|
||||||
|
LoadAvg: ns.LoadAvg,
|
||||||
|
CallFailureRate: ns.CallFailureRate,
|
||||||
|
Score: ns.Score,
|
||||||
|
TS: ns.TS,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ns
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBestNode возвращает ID лучшей ноды и fallback-статус.
|
||||||
|
// O(1) — читает кэш, не сканирует все ноды.
|
||||||
|
func (e *Engine) GetBestNode() (nodeID string, score float64, fallback bool) {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(e.nodes) == 0 {
|
||||||
|
return "", 0, true
|
||||||
|
}
|
||||||
|
return e.bestNodeID, e.bestScore, e.fallbackActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeInfo возвращает информацию о конкретной ноде.
|
||||||
|
func (e *Engine) GetNodeInfo(nodeID string) (*models.NodeInfo, bool) {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
|
||||||
|
ns, exists := e.nodes[nodeID]
|
||||||
|
if !exists {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return stateToNodeInfo(ns, e.cfg.StaleThresholdSec, time.Now()), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllNodes возвращает массив NodeInfo для всех нод.
|
||||||
|
func (e *Engine) GetAllNodes() []*models.NodeInfo {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
result := make([]*models.NodeInfo, 0, len(e.nodes))
|
||||||
|
for _, ns := range e.nodes {
|
||||||
|
result = append(result, stateToNodeInfo(ns, e.cfg.StaleThresholdSec, now))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeHistory возвращает историю метрик ноды из ring buffer.
|
||||||
|
func (e *Engine) GetNodeHistory(nodeID string) []NodeSnapshot {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
|
||||||
|
ring, ok := e.rings[nodeID]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ring.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleNode включает/выключает ноду из распределения.
|
||||||
|
func (e *Engine) ToggleNode(nodeID string, disabled bool, reason string) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
ns, exists := e.nodes[nodeID]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("нода %s не найдена", nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ns.Disabled = disabled
|
||||||
|
if disabled {
|
||||||
|
ns.DisabledReason = reason
|
||||||
|
} else {
|
||||||
|
ns.DisabledReason = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
e.scoreNodeLocked(ns)
|
||||||
|
e.recalcBestLocked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRecentMetrics возвращает true, если была получена хотя бы одна метрика за последние N секунд.
|
||||||
|
func (e *Engine) HasRecentMetrics(seconds int64) bool {
|
||||||
|
lastTS := e.lastMetricTime.Load()
|
||||||
|
if lastTS == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().Unix()-lastTS <= seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementRouteRequests увеличивает счётчик запросов /api/route.
|
||||||
|
func (e *Engine) IncrementRouteRequests() {
|
||||||
|
e.routeRequests.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthStats возвращает статистику для health endpoint.
|
||||||
|
type HealthStats struct {
|
||||||
|
TotalNodes int `json:"total_nodes"`
|
||||||
|
HealthyNodes int `json:"healthy_nodes"`
|
||||||
|
RouteRequests int64 `json:"route_requests_total"`
|
||||||
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||||
|
LastMetricTS int64 `json:"last_metric_ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHealthStats возвращает текущую HealthStats (потокобезопасно).
|
||||||
|
func (e *Engine) GetHealthStats() HealthStats {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
|
||||||
|
healthy := 0
|
||||||
|
for _, ns := range e.nodes {
|
||||||
|
if ns.Score >= 0 && !ns.Disabled {
|
||||||
|
healthy++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return HealthStats{
|
||||||
|
TotalNodes: len(e.nodes),
|
||||||
|
HealthyNodes: healthy,
|
||||||
|
RouteRequests: e.routeRequests.Load(),
|
||||||
|
UptimeSeconds: int64(time.Since(e.startTime).Seconds()),
|
||||||
|
LastMetricTS: e.lastMetricTime.Load(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Приватные методы ---
|
||||||
|
|
||||||
|
// scoreNodeLocked вычисляет score для одной ноды.
|
||||||
|
// Вызывающий держит write lock.
|
||||||
|
func (e *Engine) scoreNodeLocked(ns *models.NodeState) {
|
||||||
|
sc := &e.cfg.Scoring
|
||||||
|
|
||||||
|
// 1. Проверка lethal-условий
|
||||||
|
if ns.Disabled {
|
||||||
|
ns.Score = -100
|
||||||
|
ns.LethalReason = fmt.Sprintf("disabled: %s", ns.DisabledReason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ns.Status != "ok" {
|
||||||
|
ns.Score = -100
|
||||||
|
ns.LethalReason = fmt.Sprintf("status: %s", ns.Status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
staleSec := int(now - ns.TS)
|
||||||
|
if staleSec > e.cfg.StaleThresholdSec {
|
||||||
|
ns.Score = -100
|
||||||
|
ns.LethalReason = fmt.Sprintf("stale: %d сек без метрик", staleSec)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ns.ActiveCalls >= ns.MaxCalls && ns.MaxCalls > 0 {
|
||||||
|
ns.Score = -100
|
||||||
|
ns.LethalReason = "active_calls >= max_calls"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ns.IdleCPU < sc.IdleCPUMin {
|
||||||
|
ns.Score = -100
|
||||||
|
ns.LethalReason = fmt.Sprintf("idle_cpu %.1f < %.0f", ns.IdleCPU, sc.IdleCPUMin)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ns.CallFailureRate > sc.CallFailureRateLethal {
|
||||||
|
ns.Score = -100
|
||||||
|
ns.LethalReason = fmt.Sprintf("call_failure_rate %.1f > %.0f", ns.CallFailureRate, sc.CallFailureRateLethal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Weighted score
|
||||||
|
ns.LethalReason = ""
|
||||||
|
|
||||||
|
callScore := clamp(100.0-(float64(ns.ActiveCalls)/float64(ns.MaxCalls)*100.0), 0, 100)
|
||||||
|
loadScore := clamp(100.0-(ns.LoadAvg*50.0), 0, 100)
|
||||||
|
idleScore := clamp(ns.IdleCPU, 0, 100)
|
||||||
|
failScore := clamp(100.0-ns.CallFailureRate, 0, 100)
|
||||||
|
|
||||||
|
w := &sc.Weights
|
||||||
|
ns.Score = callScore*w.CallScore + loadScore*w.LoadScore + idleScore*w.IdleScore + failScore*w.FailScore
|
||||||
|
}
|
||||||
|
|
||||||
|
// recalcBestLocked пересчитывает кэш лучшей ноды.
|
||||||
|
// Вызывающий держит write lock.
|
||||||
|
func (e *Engine) recalcBestLocked() {
|
||||||
|
bestID := ""
|
||||||
|
bestScore := -999.0
|
||||||
|
anyHealthy := false
|
||||||
|
|
||||||
|
for id, ns := range e.nodes {
|
||||||
|
if ns.Score >= 0 {
|
||||||
|
anyHealthy = true
|
||||||
|
}
|
||||||
|
if ns.Score > bestScore {
|
||||||
|
bestScore = ns.Score
|
||||||
|
bestID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.bestNodeID = bestID
|
||||||
|
e.bestScore = bestScore
|
||||||
|
e.fallbackActive = !anyHealthy
|
||||||
|
}
|
||||||
|
|
||||||
|
// recalculateAll пересчитывает score всех нод и обновляет кэш.
|
||||||
|
func (e *Engine) recalculateAll() {
|
||||||
|
for _, ns := range e.nodes {
|
||||||
|
e.scoreNodeLocked(ns)
|
||||||
|
}
|
||||||
|
e.recalcBestLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateToNodeInfo конвертирует NodeState в NodeInfo для API.
|
||||||
|
func stateToNodeInfo(ns *models.NodeState, staleThresholdSec int, now time.Time) *models.NodeInfo {
|
||||||
|
secondsAgo := now.Sub(time.Unix(ns.TS, 0)).Seconds()
|
||||||
|
isStale := int(secondsAgo) > staleThresholdSec
|
||||||
|
|
||||||
|
return &models.NodeInfo{
|
||||||
|
NodeID: ns.NodeID,
|
||||||
|
TS: ns.TS,
|
||||||
|
Status: ns.Status,
|
||||||
|
ActiveCalls: ns.ActiveCalls,
|
||||||
|
MaxCalls: ns.MaxCalls,
|
||||||
|
IdleCPU: ns.IdleCPU,
|
||||||
|
LoadAvg: ns.LoadAvg,
|
||||||
|
CallFailureRate: ns.CallFailureRate,
|
||||||
|
Disabled: ns.Disabled,
|
||||||
|
DisabledReason: ns.DisabledReason,
|
||||||
|
Score: ns.Score,
|
||||||
|
LethalReason: ns.LethalReason,
|
||||||
|
IsStale: isStale,
|
||||||
|
SecondsAgo: secondsAgo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clamp ограничивает значение диапазоном [minVal, maxVal].
|
||||||
|
func clamp(v, minVal, maxVal float64) float64 {
|
||||||
|
if v < minVal {
|
||||||
|
return minVal
|
||||||
|
}
|
||||||
|
if v > maxVal {
|
||||||
|
return maxVal
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
340
internal/engine/engine_test.go
Normal file
340
internal/engine/engine_test.go
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/config"
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultCfg() *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
StaleThresholdSec: 20,
|
||||||
|
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 freshMetric(nodeID string) *models.NodeMetric {
|
||||||
|
return &models.NodeMetric{
|
||||||
|
NodeID: nodeID,
|
||||||
|
TS: time.Now().Unix(),
|
||||||
|
Status: "ok",
|
||||||
|
ActiveCalls: 42,
|
||||||
|
MaxCalls: 250,
|
||||||
|
IdleCPU: 35,
|
||||||
|
LoadAvg: 0.85,
|
||||||
|
CallFailureRate: 0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewEngine(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
if eng == nil {
|
||||||
|
t.Fatal("ожидался engine, получен nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMetric_CreatesNode(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
ns := eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
if ns.NodeID != "pbx-01" {
|
||||||
|
t.Errorf("ожидался node_id=pbx-01, получен %s", ns.NodeID)
|
||||||
|
}
|
||||||
|
if len(eng.nodes) != 1 {
|
||||||
|
t.Errorf("ожидалась 1 нода, получено %d", len(eng.nodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMetric_PreservesDisabled(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
// Отключаем ноду вручную
|
||||||
|
eng.ToggleNode("pbx-01", true, "test")
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||||
|
if !ns.Disabled {
|
||||||
|
t.Error("ожидалось disabled=true")
|
||||||
|
}
|
||||||
|
if ns.DisabledReason != "test" {
|
||||||
|
t.Errorf("ожидалась причина 'test', получена '%s'", ns.DisabledReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Включаем обратно
|
||||||
|
eng.ToggleNode("pbx-01", false, "")
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
ns, _ = eng.GetNodeInfo("pbx-01")
|
||||||
|
if ns.Disabled {
|
||||||
|
t.Error("ожидалось disabled=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_LethalDisabled(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
eng.ToggleNode("pbx-01", true, "maintenance")
|
||||||
|
|
||||||
|
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||||
|
if ns.Score != -100 {
|
||||||
|
t.Errorf("disabled нода должна иметь score=-100, получен %.0f", ns.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_LethalStatusNotOk(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.Status = "down"
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||||
|
if ns.Score != -100 {
|
||||||
|
t.Errorf("status==down нода должна иметь score=-100, получен %.0f", ns.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_LethalStale(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
// Устанавливаем ts на 30 секунд назад (stale_threshold=20)
|
||||||
|
m.TS = time.Now().Unix() - 30
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||||
|
if ns.Score != -100 {
|
||||||
|
t.Errorf("stale нода должна иметь score=-100, получен %.0f", ns.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_LethalMaxCalls(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.ActiveCalls = 250
|
||||||
|
m.MaxCalls = 250
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||||
|
if ns.Score != -100 {
|
||||||
|
t.Errorf("нода с исчерпанной ёмкостью должна иметь score=-100, получен %.0f", ns.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_LethalIdleCPU(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.IdleCPU = 2.0
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||||
|
if ns.Score != -100 {
|
||||||
|
t.Errorf("нода с idle_cpu < 5 должна иметь score=-100, получен %.0f", ns.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_LethalFailureRate(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.CallFailureRate = 20.0
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||||
|
if ns.Score != -100 {
|
||||||
|
t.Errorf("нода с call_failure_rate > 15 должна иметь score=-100, получен %.0f", ns.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_Healthy(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
ns := eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
if ns.Score <= 0 {
|
||||||
|
t.Errorf("здоровая нода должна иметь положительный score, получен %.0f", ns.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_HealthyFormula(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.ActiveCalls = 0
|
||||||
|
m.MaxCalls = 100
|
||||||
|
m.IdleCPU = 100
|
||||||
|
m.LoadAvg = 0
|
||||||
|
m.CallFailureRate = 0
|
||||||
|
|
||||||
|
ns := eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
// Все компоненты = 100
|
||||||
|
// score = 100*0.4 + 100*0.3 + 100*0.2 + 100*0.1 = 100
|
||||||
|
if ns.Score != 100.0 {
|
||||||
|
t.Errorf("идеальная нода должна иметь score=100, получен %.1f", ns.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_WeightedFormula(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.ActiveCalls = 50
|
||||||
|
m.MaxCalls = 100
|
||||||
|
m.IdleCPU = 50
|
||||||
|
m.LoadAvg = 1.0
|
||||||
|
m.CallFailureRate = 5.0
|
||||||
|
|
||||||
|
ns := eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
// call_score = 100 - (50/100 * 100) = 50
|
||||||
|
// load_score = 100 - (1.0 * 50) = 50
|
||||||
|
// idle_score = 50
|
||||||
|
// fail_score = 100 - 5 = 95
|
||||||
|
// score = 50*0.4 + 50*0.3 + 50*0.2 + 95*0.1 = 20+15+10+9.5 = 54.5
|
||||||
|
expected := 54.5
|
||||||
|
|
||||||
|
if ns.Score < expected-0.1 || ns.Score > expected+0.1 {
|
||||||
|
t.Errorf("ожидался score=%.1f, получен %.1f", expected, ns.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBestNode_NoNodes(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
_, _, fallback := eng.GetBestNode()
|
||||||
|
if !fallback {
|
||||||
|
t.Error("без нод должен быть fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBestNode_OneNode(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
eng.UpdateMetric(freshMetric("pbx-01"))
|
||||||
|
|
||||||
|
id, score, fallback := eng.GetBestNode()
|
||||||
|
if fallback {
|
||||||
|
t.Error("не должно быть fallback с одной здоровой нодой")
|
||||||
|
}
|
||||||
|
if id != "pbx-01" {
|
||||||
|
t.Errorf("ожидалась pbx-01, получена %s", id)
|
||||||
|
}
|
||||||
|
if score <= 0 {
|
||||||
|
t.Errorf("ожидался положительный score, получен %.0f", score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBestNode_AllUnhealthy(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m1 := freshMetric("pbx-01")
|
||||||
|
m1.Status = "down"
|
||||||
|
eng.UpdateMetric(m1)
|
||||||
|
|
||||||
|
m2 := freshMetric("pbx-02")
|
||||||
|
m2.ActiveCalls = 500
|
||||||
|
m2.MaxCalls = 500
|
||||||
|
eng.UpdateMetric(m2)
|
||||||
|
|
||||||
|
_, _, fallback := eng.GetBestNode()
|
||||||
|
if !fallback {
|
||||||
|
t.Error("все ноды unhealthy — должен быть fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBestNode_MultipleNodes(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
|
||||||
|
// pbx-01: 80% загружена (меньше score)
|
||||||
|
m1 := freshMetric("pbx-01")
|
||||||
|
m1.ActiveCalls = 200
|
||||||
|
m1.MaxCalls = 250
|
||||||
|
eng.UpdateMetric(m1)
|
||||||
|
|
||||||
|
// pbx-02: 20% загружена (выше score)
|
||||||
|
m2 := freshMetric("pbx-02")
|
||||||
|
m2.ActiveCalls = 20
|
||||||
|
m2.MaxCalls = 250
|
||||||
|
eng.UpdateMetric(m2)
|
||||||
|
|
||||||
|
id, _, _ := eng.GetBestNode()
|
||||||
|
if id != "pbx-02" {
|
||||||
|
t.Errorf("ожидалась pbx-02 (меньше загрузки), получена %s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNodeHistory(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
eng.UpdateMetric(freshMetric("pbx-01"))
|
||||||
|
|
||||||
|
// Повторяем обновление для накопления истории
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
m.ActiveCalls = i * 10
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
history := eng.GetNodeHistory("pbx-01")
|
||||||
|
if len(history) < 3 {
|
||||||
|
t.Errorf("ожидалось минимум 3 снапшота, получено %d", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNodeHistory_NoNode(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
history := eng.GetNodeHistory("nonexistent")
|
||||||
|
if history != nil {
|
||||||
|
t.Error("для несуществующей ноды ожидался nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToggleNode_NotFound(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
err := eng.ToggleNode("nonexistent", true, "test")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("ожидалась ошибка для несуществующей ноды")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfig(t *testing.T) {
|
||||||
|
eng := NewEngine(defaultCfg())
|
||||||
|
m := freshMetric("pbx-01")
|
||||||
|
eng.UpdateMetric(m)
|
||||||
|
|
||||||
|
// Меняем конфиг на более строгий
|
||||||
|
newCfg := defaultCfg()
|
||||||
|
newCfg.Scoring.CallFailureRateLethal = 0.1
|
||||||
|
newCfg.Scoring.IdleCPUMin = 50
|
||||||
|
eng.UpdateConfig(newCfg)
|
||||||
|
|
||||||
|
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||||
|
if ns.Score != -100 {
|
||||||
|
t.Errorf("после ужесточения конфига нода должна стать unhealthy, получен score=%.0f", ns.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClamp(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
v, min, max, expected float64
|
||||||
|
}{
|
||||||
|
{50, 0, 100, 50},
|
||||||
|
{-10, 0, 100, 0},
|
||||||
|
{150, 0, 100, 100},
|
||||||
|
{0, 0, 0, 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := clamp(tt.v, tt.min, tt.max)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("clamp(%.0f, %.0f, %.0f) = %.0f, ожидалось %.0f",
|
||||||
|
tt.v, tt.min, tt.max, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/engine/ring.go
Normal file
52
internal/engine/ring.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
// MetricsRingBuffer — кольцевой буфер истории метрик для одной ноды.
|
||||||
|
// Хранит до `capacity` последних снапшотов NodeState.
|
||||||
|
// Потокобезопасен: используется внутри Engine (защищён engine.mu).
|
||||||
|
type MetricsRingBuffer struct {
|
||||||
|
buf []NodeSnapshot
|
||||||
|
capacity int
|
||||||
|
head int // позиция следующей записи
|
||||||
|
size int // текущее количество элементов
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeSnapshot — снапшот метрик на момент времени.
|
||||||
|
type NodeSnapshot struct {
|
||||||
|
ActiveCalls int
|
||||||
|
MaxCalls int
|
||||||
|
IdleCPU float64
|
||||||
|
LoadAvg float64
|
||||||
|
CallFailureRate float64
|
||||||
|
Score float64
|
||||||
|
TS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMetricsRingBuffer создаёт кольцевой буфер заданной ёмкости.
|
||||||
|
func newMetricsRingBuffer(capacity int) *MetricsRingBuffer {
|
||||||
|
return &MetricsRingBuffer{
|
||||||
|
buf: make([]NodeSnapshot, capacity),
|
||||||
|
capacity: capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// push добавляет снапшот в буфер. Если буфер заполнен, затирает самый старый.
|
||||||
|
func (b *MetricsRingBuffer) push(snapshot NodeSnapshot) {
|
||||||
|
b.buf[b.head] = snapshot
|
||||||
|
b.head = (b.head + 1) % b.capacity
|
||||||
|
if b.size < b.capacity {
|
||||||
|
b.size++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot возвращает копию всех снапшотов в хронологическом порядке.
|
||||||
|
func (b *MetricsRingBuffer) snapshot() []NodeSnapshot {
|
||||||
|
result := make([]NodeSnapshot, b.size)
|
||||||
|
start := b.head - b.size
|
||||||
|
if start < 0 {
|
||||||
|
start += b.capacity
|
||||||
|
}
|
||||||
|
for i := 0; i < b.size; i++ {
|
||||||
|
result[i] = b.buf[(start+i)%b.capacity]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
377
internal/esl/client.go
Normal file
377
internal/esl/client.go
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
// Package esl реализует клиент FreeSWITCH Event Socket Library (ESL).
|
||||||
|
// Использует сырой TCP — протокол ESL прост (HTTP-подобные заголовки +
|
||||||
|
// Content-Length тело), не нужна внешняя библиотека.
|
||||||
|
//
|
||||||
|
// Клиент обеспечивает:
|
||||||
|
// - Подключение и аутентификацию
|
||||||
|
// - Синхронные команды (api)
|
||||||
|
// - Подписку на события
|
||||||
|
// - Автоматический реконнект с exponential backoff
|
||||||
|
// - Потокобезопасность (mutex на отправку команд)
|
||||||
|
package esl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event — событие, полученное от FreeSWITCH ESL.
|
||||||
|
type Event struct {
|
||||||
|
Name string
|
||||||
|
Headers map[string]string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client — ESL-клиент для управления FreeSWITCH.
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
password string
|
||||||
|
|
||||||
|
conn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
mu sync.Mutex // защищает отправку команд
|
||||||
|
|
||||||
|
connected atomic.Bool
|
||||||
|
eventsCh chan *Event
|
||||||
|
closeCh chan struct{}
|
||||||
|
|
||||||
|
// Переподключение
|
||||||
|
shouldReconn atomic.Bool
|
||||||
|
backoff time.Duration
|
||||||
|
|
||||||
|
// Статистика
|
||||||
|
reconnects atomic.Int64
|
||||||
|
gatewayOps atomic.Int64
|
||||||
|
eventsRecv atomic.Int64
|
||||||
|
startTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient создаёт нового ESL-клиента.
|
||||||
|
func NewClient(host string, port int, password string) *Client {
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
password: password,
|
||||||
|
eventsCh: make(chan *Event, 100),
|
||||||
|
closeCh: make(chan struct{}),
|
||||||
|
backoff: 1 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect подключается к FreeSWITCH и запускает фоновый реконнект.
|
||||||
|
// Блокируется до первой успешной аутентификации.
|
||||||
|
func (c *Client) Connect() error {
|
||||||
|
c.shouldReconn.Store(true)
|
||||||
|
if err := c.tryConnect(); err != nil {
|
||||||
|
return fmt.Errorf("esl connect: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectWithRetry подключается в фоне с бесконечными попытками.
|
||||||
|
func (c *Client) ConnectWithRetry() {
|
||||||
|
c.shouldReconn.Store(true)
|
||||||
|
for {
|
||||||
|
err := c.tryConnect()
|
||||||
|
if err == nil {
|
||||||
|
return // успех
|
||||||
|
}
|
||||||
|
if !c.shouldReconn.Load() {
|
||||||
|
return // остановка
|
||||||
|
}
|
||||||
|
log.Printf("[esl] ошибка подключения: %v, повтор через %v", err, c.backoff)
|
||||||
|
time.Sleep(c.backoff)
|
||||||
|
c.backoff *= 2
|
||||||
|
if c.backoff > 60*time.Second {
|
||||||
|
c.backoff = 60 * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryConnect выполняет одну попытку подключения и аутентификации.
|
||||||
|
func (c *Client) tryConnect() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
// Закрываем предыдущее соединение если было
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.closeCh = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.host, c.port)
|
||||||
|
dialer := net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
conn, err := dialer.Dial("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(conn)
|
||||||
|
|
||||||
|
// 1. Читаем auth/request
|
||||||
|
headers, _, err := c.readMessageLocked()
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("чтение auth/request: %w", err)
|
||||||
|
}
|
||||||
|
if headers["Content-Type"] != "auth/request" {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("ожидался auth/request, получен %s", headers["Content-Type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Отправляем пароль
|
||||||
|
authCmd := fmt.Sprintf("auth %s\n\n", c.password)
|
||||||
|
if _, err := conn.Write([]byte(authCmd)); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("отправка auth: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Читаем auth response
|
||||||
|
headers, _, err = c.readMessageLocked()
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("чтение auth response: %w", err)
|
||||||
|
}
|
||||||
|
if reply := headers["Reply-Text"]; !strings.HasPrefix(reply, "+OK") {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("auth rejected: %s", reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.connected.Store(true)
|
||||||
|
c.startTime = time.Now()
|
||||||
|
c.backoff = 1 * time.Second // сброс backoff после успешного коннекта
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s:%d", c.host, c.port)
|
||||||
|
log.Printf("[esl] подключён к %s", endpoint)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect отключает клиент и останавливает реконнект.
|
||||||
|
func (c *Client) Disconnect() {
|
||||||
|
c.shouldReconn.Store(false)
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.connected.Store(false)
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
close(c.closeCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected возвращает статус подключения.
|
||||||
|
func (c *Client) IsConnected() bool {
|
||||||
|
return c.connected.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send отправляет синхронную команду и возвращает тело ответа.
|
||||||
|
// Пример: Send("api sofia profile external gwlist") → список gateway.
|
||||||
|
func (c *Client) Send(command string) (map[string]string, string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if !c.connected.Load() || c.conn == nil {
|
||||||
|
return nil, "", fmt.Errorf("esl не подключён")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем команду
|
||||||
|
fullCmd := command + "\n\n"
|
||||||
|
if _, err := c.conn.Write([]byte(fullCmd)); err != nil {
|
||||||
|
c.connected.Store(false)
|
||||||
|
return nil, "", fmt.Errorf("отправка команды: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем ответ
|
||||||
|
return c.readMessageLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe подписывается на события ESL.
|
||||||
|
// events — имена событий для фильтра: "SOFIA::gateway_register SOFIA::gateway_unregister".
|
||||||
|
func (c *Client) Subscribe(events string) error {
|
||||||
|
cmd := fmt.Sprintf("event plain %s", events)
|
||||||
|
_, _, err := c.Send(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("подписка на события: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[esl] подписан на события: %s", events)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events возвращает канал с входящими событиями.
|
||||||
|
func (c *Client) Events() <-chan *Event {
|
||||||
|
return c.eventsCh
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartReadingEvents запускает горутину чтения асинхронных событий FS.
|
||||||
|
// Должна вызываться после Connect().
|
||||||
|
func (c *Client) StartReadingEvents() {
|
||||||
|
if !c.shouldReconn.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go c.readEventsLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readEventsLoop читает асинхронные сообщения (события) от FS.
|
||||||
|
func (c *Client) readEventsLoop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.closeCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.connected.Load() || c.conn == nil {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
headers, body, err := c.readMessageLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
|
||||||
|
c.connected.Store(false)
|
||||||
|
log.Printf("[esl] соединение разорвано, реконнект...")
|
||||||
|
c.reconnects.Add(1)
|
||||||
|
c.reconnect()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("[esl] ошибка чтения события: %v", err)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
eventName := headers["Event-Name"]
|
||||||
|
if eventName == "" {
|
||||||
|
continue // не событие (например, ответ на команду)
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := &Event{
|
||||||
|
Name: eventName,
|
||||||
|
Headers: headers,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
c.eventsRecv.Add(1)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c.eventsCh <- evt:
|
||||||
|
default:
|
||||||
|
log.Printf("[esl] канал событий переполнен, пропускаем %s", eventName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnect выполняет реконнект с backoff.
|
||||||
|
func (c *Client) reconnect() {
|
||||||
|
if !c.shouldReconn.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[esl] реконнект через %v...", c.backoff)
|
||||||
|
time.Sleep(c.backoff)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if err := c.tryConnect(); err != nil {
|
||||||
|
log.Printf("[esl] реконнект не удался: %v", err)
|
||||||
|
c.backoff *= 2
|
||||||
|
if c.backoff > 60*time.Second {
|
||||||
|
c.backoff = 60 * time.Second
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Успешный реконнект
|
||||||
|
c.backoff = 1 * time.Second
|
||||||
|
log.Printf("[esl] реконнект успешен, переподписываемся на события")
|
||||||
|
// TODO: gatewaySyncAll() и resubscribe на события вызываются снаружи
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Приватные методы ---
|
||||||
|
|
||||||
|
// readMessageLocked читает одно ESL-сообщение: заголовки + тело.
|
||||||
|
// Вызывающий держит c.mu.
|
||||||
|
func (c *Client) readMessageLocked() (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
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Метрики ---
|
||||||
|
|
||||||
|
// Stats возвращает статистику ESL-клиента для health endpoint.
|
||||||
|
type Stats struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Reconnects int64 `json:"reconnects"`
|
||||||
|
GatewayOps int64 `json:"gateway_ops_total"`
|
||||||
|
EventsRecv int64 `json:"events_received"`
|
||||||
|
UptimeSec int64 `json:"uptime_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats возвращает текущую статистику.
|
||||||
|
func (c *Client) GetStats() Stats {
|
||||||
|
st := "connected"
|
||||||
|
if !c.connected.Load() {
|
||||||
|
st = "disconnected"
|
||||||
|
}
|
||||||
|
return Stats{
|
||||||
|
Status: st,
|
||||||
|
Host: fmt.Sprintf("%s:%d", c.host, c.port),
|
||||||
|
Reconnects: c.reconnects.Load(),
|
||||||
|
GatewayOps: c.gatewayOps.Load(),
|
||||||
|
EventsRecv: c.eventsRecv.Load(),
|
||||||
|
UptimeSec: int64(time.Since(c.startTime).Seconds()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncGatewayOps увеличивает счётчик операций gateway.
|
||||||
|
func (c *Client) IncGatewayOps() {
|
||||||
|
c.gatewayOps.Add(1)
|
||||||
|
}
|
||||||
91
internal/esl/events.go
Normal file
91
internal/esl/events.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package esl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventHandler — callback для обработки событий ESL.
|
||||||
|
// Принимает имя события и заголовки, вызывается из readEventsLoop.
|
||||||
|
type EventHandler func(eventName string, headers map[string]string, body string)
|
||||||
|
|
||||||
|
// StartEventLoop запускает основной цикл реконнекта и чтения событий.
|
||||||
|
// При успешном подключении:
|
||||||
|
// - Вызывает onConnect (например, для GatewaySyncAll)
|
||||||
|
// - Подписывается на события SOFIA::gateway_register/unregister
|
||||||
|
// - Запускает чтение асинхронных событий
|
||||||
|
// При разрыве соединения — автоматический реконнект.
|
||||||
|
func (c *Client) StartEventLoop(ctx context.Context, profile string, onConnect func(), onEvent EventHandler) {
|
||||||
|
const subscribeEvents = "SOFIA::gateway_register SOFIA::gateway_unregister SOFIA::gateway_expire"
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
c.Disconnect()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пытаемся подключиться
|
||||||
|
log.Printf("[esl] попытка подключения к %s:%d", c.host, c.port)
|
||||||
|
if err := c.Connect(); err != nil {
|
||||||
|
log.Printf("[esl] ошибка подключения: %v, повтор через %v", err, c.backoff)
|
||||||
|
time.Sleep(c.backoff)
|
||||||
|
c.backoff *= 2
|
||||||
|
if c.backoff > 60*time.Second {
|
||||||
|
c.backoff = 60 * time.Second
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Успешное подключение
|
||||||
|
c.backoff = 1 * time.Second
|
||||||
|
|
||||||
|
// Подписываемся на события
|
||||||
|
if err := c.Subscribe(subscribeEvents); err != nil {
|
||||||
|
log.Printf("[esl] ошибка подписки: %v", err)
|
||||||
|
c.Disconnect()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем callback (GatewaySyncAll)
|
||||||
|
if onConnect != nil {
|
||||||
|
onConnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем события пока соединение живо
|
||||||
|
readEvents := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(readEvents)
|
||||||
|
c.readEventsLoop()
|
||||||
|
// Не ждём реконнект внутри readEventsLoop
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Основной цикл обработки событий
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
c.Disconnect()
|
||||||
|
return
|
||||||
|
case <-readEvents:
|
||||||
|
// Соединение разорвалось — идём на реконнект
|
||||||
|
log.Printf("[esl] чтение событий остановлено")
|
||||||
|
goto reconnect
|
||||||
|
case evt, ok := <-c.Events():
|
||||||
|
if !ok {
|
||||||
|
goto reconnect
|
||||||
|
}
|
||||||
|
if onEvent != nil {
|
||||||
|
onEvent(evt.Name, evt.Headers, evt.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnect:
|
||||||
|
c.Disconnect()
|
||||||
|
time.Sleep(c.backoff)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
107
internal/esl/gateway.go
Normal file
107
internal/esl/gateway.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package esl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GatewayAdd создаёт gateway на FreeSWITCH через Sofia профиль.
|
||||||
|
// Возвращает nil если gateway создан или уже существует (идемпотентность).
|
||||||
|
func (c *Client) GatewayAdd(profile, name, proxy string) error {
|
||||||
|
cmd := fmt.Sprintf("api sofia profile %s gwadd %s %s", profile, name, proxy)
|
||||||
|
headers, body, err := c.Send(cmd)
|
||||||
|
c.IncGatewayOps()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[esl] GatewayAdd %s: ошибка команды: %v", name, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reply := headers["Reply-Text"]
|
||||||
|
if strings.HasPrefix(reply, "+OK") {
|
||||||
|
log.Printf("[esl] GatewayAdd %s: создан (%s → %s)", name, profile, proxy)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway уже существует — не ошибка
|
||||||
|
if strings.Contains(body, "GATEWAY ALREADY") || strings.Contains(reply, "GATEWAY ALREADY") {
|
||||||
|
log.Printf("[esl] GatewayAdd %s: уже существует, ок", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[esl] GatewayAdd %s: ошибка FS — Reply: %s, Body: %s", name, reply, body)
|
||||||
|
return fmt.Errorf("GatewayAdd %s: %s (%s)", name, reply, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatewayDelete удаляет gateway на FreeSWITCH.
|
||||||
|
// Возвращает nil если gateway удалён или не найден (идемпотентность).
|
||||||
|
func (c *Client) GatewayDelete(profile, name string) error {
|
||||||
|
cmd := fmt.Sprintf("api sofia profile %s gwdel %s", profile, name)
|
||||||
|
headers, body, err := c.Send(cmd)
|
||||||
|
c.IncGatewayOps()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[esl] GatewayDelete %s: ошибка команды: %v", name, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reply := headers["Reply-Text"]
|
||||||
|
if strings.HasPrefix(reply, "+OK") {
|
||||||
|
log.Printf("[esl] GatewayDelete %s: удалён", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway не найден — не ошибка
|
||||||
|
if strings.Contains(body, "GATEWAY NOT FOUND") || strings.Contains(reply, "GATEWAY NOT FOUND") {
|
||||||
|
log.Printf("[esl] GatewayDelete %s: не найден, ок", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[esl] GatewayDelete %s: ошибка FS — Reply: %s, Body: %s", name, reply, body)
|
||||||
|
return fmt.Errorf("GatewayDelete %s: %s (%s)", name, reply, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatewayList возвращает список имён gateway в заданном профиле.
|
||||||
|
func (c *Client) GatewayList(profile string) ([]string, error) {
|
||||||
|
cmd := fmt.Sprintf("api sofia profile %s gwlist", profile)
|
||||||
|
_, body, err := c.Send(cmd)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GatewayList: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим вывод gwlist — имена gateway на отдельных строках
|
||||||
|
lines := strings.Split(body, "\n")
|
||||||
|
var gateways []string
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "Name:") || strings.HasPrefix(line, "====") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Ищем строки вида "pulse-ingress-xxxxx sip:..."
|
||||||
|
if strings.Contains(line, "pulse-") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
gateways = append(gateways, parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gateways, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatewaySyncAll синхронизирует все ingress-транки как gateway на FS.
|
||||||
|
// Вызывается после реконнекта.
|
||||||
|
func (c *Client) GatewaySyncAll(profile string, gateways []GatewaySpec) {
|
||||||
|
for _, g := range gateways {
|
||||||
|
if err := c.GatewayAdd(profile, g.Name, g.Proxy); err != nil {
|
||||||
|
log.Printf("[esl] GatewaySyncAll: ошибка %s: %v", g.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatewaySpec описывает gateway для синхронизации.
|
||||||
|
type GatewaySpec struct {
|
||||||
|
Name string
|
||||||
|
Proxy string
|
||||||
|
}
|
||||||
93
internal/log/logger.go
Normal file
93
internal/log/logger.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// Package log реализует ASCII-логгер метрик с ротацией по дням.
|
||||||
|
// Формат: "2026-06-25T10:00:00Z pbx-01 calls=42 load=0.85 idle_cpu=35 fail=0.5 score=73"
|
||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Logger — потокобезопасный логгер метрик в ASCII-файл с дневной ротацией.
|
||||||
|
type Logger struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
dir string
|
||||||
|
file *os.File
|
||||||
|
today string // YYYY-MM-DD последнего открытого файла
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger создаёт логгер, записывающий в dataDir/.
|
||||||
|
// Файлы именуются как metrics.YYYY-MM-DD.log.
|
||||||
|
func NewLogger(dataDir string) (*Logger, error) {
|
||||||
|
if err := os.MkdirAll(dataDir, 0750); err != nil {
|
||||||
|
return nil, fmt.Errorf("создание data/: %w", err)
|
||||||
|
}
|
||||||
|
l := &Logger{dir: dataDir}
|
||||||
|
if err := l.ensureFile(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log записывает снапшот метрик ноды в лог-файл.
|
||||||
|
func (l *Logger) Log(ns *models.NodeState) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
if err := l.ensureFile(); err != nil {
|
||||||
|
return // тихая ошибка — не блокируем метрики из-за логгера
|
||||||
|
}
|
||||||
|
|
||||||
|
line := l.formatLine(ns)
|
||||||
|
l.file.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLine форматирует строку лога.
|
||||||
|
func (l *Logger) formatLine(ns *models.NodeState) string {
|
||||||
|
t := time.Unix(ns.TS, 0).UTC().Format(time.RFC3339)
|
||||||
|
return fmt.Sprintf("%s %s calls=%d load=%.2f idle_cpu=%.1f fail=%.1f score=%.1f disabled=%t",
|
||||||
|
t, ns.NodeID, ns.ActiveCalls, ns.LoadAvg, ns.IdleCPU, ns.CallFailureRate, ns.Score, ns.Disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close закрывает файл лога.
|
||||||
|
func (l *Logger) Close() error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.file != nil {
|
||||||
|
return l.file.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureFile открывает файл на текущий день, создавая новый, если день сменился.
|
||||||
|
// Вызывающий держит mu.
|
||||||
|
func (l *Logger) ensureFile() error {
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
if l.file != nil && l.today == today {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if l.file != nil {
|
||||||
|
l.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(l.dir, "metrics."+today+".log")
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("открытие лог-файла %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.file = f
|
||||||
|
l.today = today
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrimPrefix обрезает "metrics." и ".log" из имени файла (для API истории).
|
||||||
|
func TrimPrefix(filename string) string {
|
||||||
|
return strings.TrimPrefix(strings.TrimSuffix(filename, ".log"), "metrics.")
|
||||||
|
}
|
||||||
217
internal/models/types.go
Normal file
217
internal/models/types.go
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
// Package models содержит общие типы данных pulse-lets-go.
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// --- NATS: метрика от нижестоящей АТС ---
|
||||||
|
|
||||||
|
// NodeMetric — сообщение, публикуемое нодой в NATS каждые 5 сек.
|
||||||
|
type NodeMetric struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
TS int64 `json:"ts"` // unix timestamp
|
||||||
|
Status string `json:"status"` // "ok", "degraded", "down"
|
||||||
|
ActiveCalls int `json:"active_calls"`
|
||||||
|
MaxCalls int `json:"max_calls"`
|
||||||
|
IdleCPU float64 `json:"idle_cpu"` // 0..100 %
|
||||||
|
LoadAvg float64 `json:"load_avg"` // system load average (1m)
|
||||||
|
CallFailureRate float64 `json:"call_failure_rate"` // 0.0..100.0 %
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- In-memory состояние ноды в engine ---
|
||||||
|
|
||||||
|
// NodeState — полное состояние ноды в памяти балансировщика.
|
||||||
|
// Содержит данные последней метрики + поля ручного управления.
|
||||||
|
type NodeState struct {
|
||||||
|
NodeMetric
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
DisabledReason string `json:"disabled_reason"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
LethalReason string `json:"lethal_reason,omitempty"` // причина, если score = -100
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Trunk ---
|
||||||
|
|
||||||
|
// TrunkType — тип транка.
|
||||||
|
type TrunkType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TrunkIngress TrunkType = "ingress"
|
||||||
|
TrunkBalance TrunkType = "balance"
|
||||||
|
TrunkFallback TrunkType = "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trunk — модель транка (из trunks.json).
|
||||||
|
type Trunk struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type TrunkType `json:"type"`
|
||||||
|
NodeID string `json:"node_id,omitempty"` // только для balance
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
|
Codecs []string `json:"codecs,omitempty"`
|
||||||
|
Context string `json:"context,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User ---
|
||||||
|
|
||||||
|
// UserRole — роль пользователя.
|
||||||
|
type UserRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleAdmin UserRole = "admin"
|
||||||
|
RoleViewer UserRole = "viewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User — модель пользователя (из users.json).
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
Role UserRole `json:"role"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
|
// LoginRequest — тело запроса на /api/auth/login.
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResponse — тело ответа /api/auth/login.
|
||||||
|
type LoginResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- /api/route ---
|
||||||
|
|
||||||
|
// RouteResponse — ответ на запрос маршрутизации.
|
||||||
|
type RouteResponse struct {
|
||||||
|
// Успех
|
||||||
|
NodeID string `json:"node_id,omitempty"`
|
||||||
|
Score float64 `json:"score,omitempty"`
|
||||||
|
SIPGateway string `json:"sip_gateway,omitempty"`
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
Fallback bool `json:"fallback,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
|
||||||
|
// Общая информация о нодах
|
||||||
|
Nodes []RouteNodeInfo `json:"nodes,omitempty"`
|
||||||
|
|
||||||
|
// Ошибка
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteNodeInfo — информация о ноде в ответе маршрутизации.
|
||||||
|
type RouteNodeInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API: Nodes ---
|
||||||
|
|
||||||
|
// ToggleNodeRequest — запрос на включение/выключение ноды.
|
||||||
|
type ToggleNodeRequest struct {
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeInfo — информация о ноде для фронта.
|
||||||
|
type NodeInfo struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
TS int64 `json:"ts"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ActiveCalls int `json:"active_calls"`
|
||||||
|
MaxCalls int `json:"max_calls"`
|
||||||
|
IdleCPU float64 `json:"idle_cpu"`
|
||||||
|
LoadAvg float64 `json:"load_avg"`
|
||||||
|
CallFailureRate float64 `json:"call_failure_rate"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
DisabledReason string `json:"disabled_reason,omitempty"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
LethalReason string `json:"lethal_reason,omitempty"`
|
||||||
|
IsStale bool `json:"is_stale"`
|
||||||
|
SecondsAgo float64 `json:"seconds_ago"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API: Users ---
|
||||||
|
|
||||||
|
// CreateUserRequest — запрос на создание пользователя.
|
||||||
|
type CreateUserRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role UserRole `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserRequest — запрос на обновление пользователя.
|
||||||
|
type UpdateUserRequest struct {
|
||||||
|
Username *string `json:"username,omitempty"`
|
||||||
|
Password *string `json:"password,omitempty"`
|
||||||
|
Role *UserRole `json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API: Trunks ---
|
||||||
|
|
||||||
|
// CreateTrunkRequest — запрос на создание транка.
|
||||||
|
type CreateTrunkRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type TrunkType `json:"type"`
|
||||||
|
NodeID string `json:"node_id,omitempty"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
|
Codecs []string `json:"codecs,omitempty"`
|
||||||
|
Context string `json:"context,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTrunkRequest — запрос на обновление транка.
|
||||||
|
type UpdateTrunkRequest struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Type *TrunkType `json:"type,omitempty"`
|
||||||
|
NodeID *string `json:"node_id,omitempty"`
|
||||||
|
Gateway *string `json:"gateway,omitempty"`
|
||||||
|
Codecs []string `json:"codecs,omitempty"`
|
||||||
|
Context *string `json:"context,omitempty"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Monitoring ---
|
||||||
|
|
||||||
|
// ZabbixNode — элемент LLD (Low-Level Discovery) для Zabbix.
|
||||||
|
type ZabbixNode struct {
|
||||||
|
NodeID string `json:"{#NODE_ID}"`
|
||||||
|
Status string `json:"{#STATUS}"`
|
||||||
|
ActiveCalls int `json:"{#ACTIVE_CALLS}"`
|
||||||
|
MaxCalls int `json:"{#MAX_CALLS}"`
|
||||||
|
IdleCPU float64 `json:"{#IDLE_CPU}"`
|
||||||
|
LoadAvg float64 `json:"{#LOAD_AVG}"`
|
||||||
|
CallFailureRate float64 `json:"{#CALL_FAILURE_RATE}"`
|
||||||
|
Score float64 `json:"{#SCORE}"`
|
||||||
|
Disabled bool `json:"{#DISABLED}"`
|
||||||
|
IsStale bool `json:"{#IS_STALE}"`
|
||||||
|
SecondsAgo float64 `json:"{#SECONDS_AGO}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZabbixResponse — ответ на запрос Zabbix LLD.
|
||||||
|
type ZabbixResponse struct {
|
||||||
|
Data []ZabbixNode `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WebSocket ---
|
||||||
|
|
||||||
|
// WsMetricsMessage — сообщение, отправляемое по WebSocket.
|
||||||
|
type WsMetricsMessage struct {
|
||||||
|
Type string `json:"type"` // "nodes_update", "node_toggle", etc
|
||||||
|
Payload interface{} `json:"payload"`
|
||||||
|
}
|
||||||
98
internal/nats/subscriber.go
Normal file
98
internal/nats/subscriber.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// Package nats содержит подписчика на NATS-метрики от нижестоящих АТС.
|
||||||
|
package nats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
natsgo "github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
|
"github.com/pulse-lets-go/internal/engine"
|
||||||
|
filelog "github.com/pulse-lets-go/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const subject = "pulse.metrics.>"
|
||||||
|
|
||||||
|
// Subscriber подписывается на NATS и обновляет engine + пишет лог.
|
||||||
|
type Subscriber struct {
|
||||||
|
nc *natsgo.Conn
|
||||||
|
sub *natsgo.Subscription
|
||||||
|
engine *engine.Engine
|
||||||
|
logger *filelog.Logger
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSubscriber подключается к NATS и запускает подписку.
|
||||||
|
func NewSubscriber(natsURL, user, password string, eng *engine.Engine, l *filelog.Logger) (*Subscriber, error) {
|
||||||
|
opts := []natsgo.Option{
|
||||||
|
natsgo.ReconnectWait(natsgo.DefaultReconnectWait),
|
||||||
|
natsgo.MaxReconnects(-1),
|
||||||
|
}
|
||||||
|
if user != "" && password != "" {
|
||||||
|
opts = append(opts, natsgo.UserInfo(user, password))
|
||||||
|
}
|
||||||
|
|
||||||
|
nc, err := natsgo.Connect(natsURL, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("nats connect: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Subscriber{
|
||||||
|
nc: nc,
|
||||||
|
engine: eng,
|
||||||
|
logger: l,
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := nc.Subscribe(subject, s.handleMetric)
|
||||||
|
if err != nil {
|
||||||
|
nc.Close()
|
||||||
|
return nil, fmt.Errorf("nats subscribe: %w", err)
|
||||||
|
}
|
||||||
|
s.sub = sub
|
||||||
|
|
||||||
|
log.Printf("[nats] подписан на %s, подключён к %s", subject, natsURL)
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMetric — обработчик входящих сообщений NATS.
|
||||||
|
func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
|
||||||
|
var metric models.NodeMetric
|
||||||
|
if err := json.Unmarshal(msg.Data, &metric); err != nil {
|
||||||
|
log.Printf("[nats] ошибка парсинга метрики: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация минимальных полей
|
||||||
|
if metric.NodeID == "" {
|
||||||
|
log.Printf("[nats] метрика без node_id, игнорируется")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if metric.MaxCalls <= 0 {
|
||||||
|
metric.MaxCalls = 250 // разумное значение по умолчанию
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := s.engine.UpdateMetric(&metric)
|
||||||
|
s.logger.Log(ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected возвращает true, если NATS-соединение активно.
|
||||||
|
func (s *Subscriber) IsConnected() bool {
|
||||||
|
if s.nc == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.nc.IsConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close останавливает подписку и закрывает соединение.
|
||||||
|
func (s *Subscriber) Close() {
|
||||||
|
if s.sub != nil {
|
||||||
|
s.sub.Unsubscribe()
|
||||||
|
}
|
||||||
|
if s.nc != nil {
|
||||||
|
s.nc.Close()
|
||||||
|
}
|
||||||
|
s.wg.Wait()
|
||||||
|
}
|
||||||
2311
web/package-lock.json
generated
Normal file
2311
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
web/package.json
Normal file
26
web/package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "pulse-lets-go-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev --port 5173",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.0",
|
||||||
|
"@sveltejs/kit": "^2.60.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"svelte": "^5.50.0",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"lucide-svelte": "^1.0.0",
|
||||||
|
"svelte-sonner": "^1.0.0",
|
||||||
|
"tailwind-merge": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
web/src/app.css
Normal file
29
web/src/app.css
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-background: oklch(0.98 0 0);
|
||||||
|
--color-foreground: oklch(0.15 0 0);
|
||||||
|
--color-card: oklch(1 0 0);
|
||||||
|
--color-card-foreground: oklch(0.15 0 0);
|
||||||
|
--color-primary: oklch(0.55 0.2 260);
|
||||||
|
--color-primary-foreground: oklch(0.98 0 0);
|
||||||
|
--color-secondary: oklch(0.96 0 0);
|
||||||
|
--color-secondary-foreground: oklch(0.15 0 0);
|
||||||
|
--color-muted: oklch(0.96 0 0);
|
||||||
|
--color-muted-foreground: oklch(0.5 0 0);
|
||||||
|
--color-destructive: oklch(0.55 0.2 25);
|
||||||
|
--color-destructive-foreground: oklch(0.98 0 0);
|
||||||
|
--color-border: oklch(0.9 0 0);
|
||||||
|
--color-ring: oklch(0.55 0.2 260);
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
web/src/app.html
Normal file
13
web/src/app.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📞</text></svg>" />
|
||||||
|
<title>pulse-lets-go</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-background text-foreground">
|
||||||
|
%sveltekit.body%
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
209
web/src/lib/api.ts
Normal file
209
web/src/lib/api.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
// API client для pulse-lets-go backend.
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
return localStorage.getItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefreshToken(): string | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
return localStorage.getItem('refreshToken');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<string | null> {
|
||||||
|
const refresh = getRefreshToken();
|
||||||
|
if (!refresh) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${refresh}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
localStorage.setItem('token', data.access_token);
|
||||||
|
localStorage.setItem('refreshToken', data.refresh_token);
|
||||||
|
return data.access_token;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
let token = getToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await fetch(`${API_BASE}${url}`, { ...options, headers });
|
||||||
|
|
||||||
|
// Пробуем обновить токен при 401
|
||||||
|
if (res.status === 401 && token) {
|
||||||
|
const newToken = await refreshAccessToken();
|
||||||
|
if (newToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${newToken}`;
|
||||||
|
res = await fetch(`${API_BASE}${url}`, { ...options, headers });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || 'API error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
export async function login(username: string, password: string) {
|
||||||
|
const data = await request<{ access_token: string; refresh_token: string; expires_in: number }>(
|
||||||
|
'/auth/login',
|
||||||
|
{ method: 'POST', body: JSON.stringify({ username, password }) }
|
||||||
|
);
|
||||||
|
localStorage.setItem('token', data.access_token);
|
||||||
|
localStorage.setItem('refreshToken', data.refresh_token);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return !!getToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Nodes ---
|
||||||
|
export interface NodeInfo {
|
||||||
|
node_id: string;
|
||||||
|
ts: number;
|
||||||
|
status: string;
|
||||||
|
active_calls: number;
|
||||||
|
max_calls: number;
|
||||||
|
idle_cpu: number;
|
||||||
|
load_avg: number;
|
||||||
|
call_failure_rate: number;
|
||||||
|
disabled: boolean;
|
||||||
|
disabled_reason: string;
|
||||||
|
score: number;
|
||||||
|
lethal_reason: string;
|
||||||
|
is_stale: boolean;
|
||||||
|
seconds_ago: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNodes(): Promise<NodeInfo[]> {
|
||||||
|
return request<NodeInfo[]>('/nodes');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleNode(nodeId: string, disabled: boolean, reason: string = '') {
|
||||||
|
return request<NodeInfo>(`/nodes/${encodeURIComponent(nodeId)}/toggle`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ disabled, reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeSnapshot {
|
||||||
|
active_calls: number;
|
||||||
|
max_calls: number;
|
||||||
|
idle_cpu: number;
|
||||||
|
load_avg: number;
|
||||||
|
call_failure_rate: number;
|
||||||
|
score: number;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNodeMetrics(nodeId: string): Promise<{ node_id: string; snapshots: NodeSnapshot[]; count: number }> {
|
||||||
|
return request(`/nodes/${encodeURIComponent(nodeId)}/metrics`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Trunks ---
|
||||||
|
export interface Trunk {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'ingress' | 'balance' | 'fallback';
|
||||||
|
node_id: string;
|
||||||
|
gateway: string;
|
||||||
|
codecs: string[];
|
||||||
|
context: string;
|
||||||
|
enabled: boolean;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrunks(typeFilter?: string): Promise<Trunk[]> {
|
||||||
|
const q = typeFilter ? `?type=${typeFilter}` : '';
|
||||||
|
return request<Trunk[]>(`/trunks${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTrunk(data: Partial<Trunk>) {
|
||||||
|
return request<Trunk>('/trunks', { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTrunk(id: string, data: Partial<Trunk>) {
|
||||||
|
return request<Trunk>(`/trunks/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTrunk(id: string) {
|
||||||
|
return request<void>(`/trunks/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Users ---
|
||||||
|
export interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsers(): Promise<UserInfo[]> {
|
||||||
|
return request<UserInfo[]>('/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(data: { username: string; password: string; role: string }) {
|
||||||
|
return request('/users', { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: string, data: { username?: string; password?: string; role?: string }) {
|
||||||
|
return request(`/users/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: string) {
|
||||||
|
return request<void>(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WebSocket ---
|
||||||
|
export function connectWebSocket(onMessage: (data: any) => void): WebSocket {
|
||||||
|
const token = getToken();
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const ws = new WebSocket(`${proto}://${window.location.host}/ws/metrics?token=${token}`);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
onMessage(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WS parse error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
// Переподключение через 5 секунд
|
||||||
|
setTimeout(() => {
|
||||||
|
if (getToken()) connectWebSocket(onMessage);
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
66
web/src/routes/+layout.svelte
Normal file
66
web/src/routes/+layout.svelte
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import '../app.css';
|
||||||
|
import { Toaster } from 'svelte-sonner';
|
||||||
|
import { LogIn, LayoutDashboard, Phone, Cable, Users } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let token: string | null = browser ? localStorage.getItem('token') : null;
|
||||||
|
let user: { username: string; role: string } | null = token
|
||||||
|
? (() => { try { return JSON.parse(atob(token.split('.')[1])); } catch { return null; } })()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
if (browser) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
}
|
||||||
|
token = null;
|
||||||
|
user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: pathname = browser ? window.location.pathname : '';
|
||||||
|
$: isLogin = pathname === '/login';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
|
||||||
|
{#if !isLogin}
|
||||||
|
<div class="flex min-h-screen">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="w-64 border-r bg-card flex flex-col">
|
||||||
|
<div class="p-4 border-b">
|
||||||
|
<h1 class="text-lg font-bold text-primary">pulse-lets-go</h1>
|
||||||
|
<p class="text-xs text-muted-foreground">Telephone Balancer</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex-1 p-3 space-y-1">
|
||||||
|
<a href="/" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
|
||||||
|
<LayoutDashboard class="h-4 w-4" /> Дашборд
|
||||||
|
</a>
|
||||||
|
<a href="/nodes" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/nodes' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
|
||||||
|
<Phone class="h-4 w-4" /> Ноды
|
||||||
|
</a>
|
||||||
|
<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" /> Транки
|
||||||
|
</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'}">
|
||||||
|
<Users class="h-4 w-4" /> Пользователи
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="p-3 border-t text-sm text-muted-foreground">
|
||||||
|
<span>{user?.username ?? '--'}</span>
|
||||||
|
<span class="mx-1">({user?.role ?? '--'})</span>
|
||||||
|
<button on:click={logout} class="ml-2 text-destructive hover:underline text-xs">Выйти</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 p-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<slot />
|
||||||
|
{/if}
|
||||||
188
web/src/routes/+page.svelte
Normal file
188
web/src/routes/+page.svelte
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import {
|
||||||
|
getNodes, connectWebSocket, isAuthenticated,
|
||||||
|
type NodeInfo
|
||||||
|
} from '$lib/api';
|
||||||
|
import { Activity, Phone, Zap, AlertTriangle, Wifi, Server } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let nodes: NodeInfo[] = [];
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
if (browser && !isAuthenticated()) {
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadNodes();
|
||||||
|
ws = connectWebSocket((msg) => {
|
||||||
|
if (msg.type === 'nodes_update') {
|
||||||
|
nodes = msg.payload;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
ws?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadNodes() {
|
||||||
|
try {
|
||||||
|
nodes = await getNodes();
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreColor(score: number): string {
|
||||||
|
if (score >= 70) return 'text-green-600';
|
||||||
|
if (score >= 40) return 'text-yellow-600';
|
||||||
|
if (score >= 0) return 'text-orange-600';
|
||||||
|
return 'text-red-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreBg(score: number): string {
|
||||||
|
if (score >= 70) return 'bg-green-50 border-green-200';
|
||||||
|
if (score >= 40) return 'bg-yellow-50 border-yellow-200';
|
||||||
|
if (score >= 0) return 'bg-orange-50 border-orange-200';
|
||||||
|
return 'bg-red-50 border-red-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeStatus(n: NodeInfo): string {
|
||||||
|
if (n.disabled) return 'disabled';
|
||||||
|
if (n.is_stale) return 'stale';
|
||||||
|
if (n.status !== 'ok') return 'degraded';
|
||||||
|
if (n.score < 0) return 'unhealthy';
|
||||||
|
return 'healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'healthy': return 'bg-green-500';
|
||||||
|
case 'degraded': return 'bg-yellow-500';
|
||||||
|
default: return 'bg-red-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: healthyCount = nodes.filter(n => n.score >= 0 && !n.disabled && !n.is_stale).length;
|
||||||
|
$: 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;
|
||||||
|
$: unhealthyList = nodes.filter(n => n.score < 0 || n.disabled || n.is_stale);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="p-4 border border-red-200 bg-red-50 text-red-700 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<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="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||||
|
<Server class="h-4 w-4" /> Всего нод
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold">{nodes.length}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded-lg p-4 bg-card">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||||
|
<Activity class="h-4 w-4" /> Здоровых
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-green-600">{healthyCount}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded-lg p-4 bg-card">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||||
|
<Phone class="h-4 w-4" /> Активных звонков
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold">{totalCalls}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded-lg p-4 bg-card">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||||
|
<Zap class="h-4 w-4" /> Средний score
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold {scoreColor(avgScore)}">{avgScore.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unhealthy Alerts -->
|
||||||
|
{#if unhealthyList.length > 0}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-destructive mb-2">
|
||||||
|
<AlertTriangle class="h-4 w-4" /> Проблемные ноды ({unhealthyList.length})
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each unhealthyList as n}
|
||||||
|
<div class="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full {statusColor(nodeStatus(n))}"></span>
|
||||||
|
<span class="font-mono">{n.node_id}</span>
|
||||||
|
<span class="text-destructive">score={n.score.toFixed(0)}</span>
|
||||||
|
{#if n.lethal_reason}
|
||||||
|
<span class="text-red-500">— {n.lethal_reason}</span>
|
||||||
|
{/if}
|
||||||
|
{#if n.disabled}
|
||||||
|
<span class="text-orange-500">— disabled: {n.disabled_reason}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Node Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{#each nodes as n (n.node_id)}
|
||||||
|
<div class="border rounded-lg p-4 {scoreBg(n.score)}">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full {statusColor(nodeStatus(n))}"></span>
|
||||||
|
<span class="font-semibold">{n.node_id}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-lg font-bold {scoreColor(n.score)}">
|
||||||
|
{n.score.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground">Звонки:</span>
|
||||||
|
<span class="font-mono ml-1">{n.active_calls}/{n.max_calls}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground">CPU idle:</span>
|
||||||
|
<span class="font-mono ml-1">{n.idle_cpu.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground">Load:</span>
|
||||||
|
<span class="font-mono ml-1">{n.load_avg.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground">Fail:</span>
|
||||||
|
<span class="font-mono ml-1">{n.call_failure_rate.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<span class="text-muted-foreground">Status:</span>
|
||||||
|
<span class="ml-1">{nodeStatus(n)}</span>
|
||||||
|
<span class="text-muted-foreground ml-2">
|
||||||
|
{n.seconds_ago.toFixed(0)}с назад
|
||||||
|
</span>
|
||||||
|
{#if n.is_stale}
|
||||||
|
<span class="text-red-500 ml-1">STALE</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if nodes.length === 0}
|
||||||
|
<div class="col-span-full text-center py-12 text-muted-foreground">
|
||||||
|
<Wifi class="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||||
|
<p>Нет зарегистрированных нод. Ожидание метрик через NATS...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
80
web/src/routes/login/+page.svelte
Normal file
80
web/src/routes/login/+page.svelte
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { login, isAuthenticated } from '$lib/api';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let username = '';
|
||||||
|
let password = '';
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
if (browser && isAuthenticated()) {
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!username || !password) {
|
||||||
|
toast.error('Заполните имя и пароль');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
toast.success('Вход выполнен');
|
||||||
|
goto('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || 'Ошибка входа');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-secondary">
|
||||||
|
<div class="w-full max-w-sm p-8 bg-card rounded-lg border shadow-sm">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<span class="text-4xl">📞</span>
|
||||||
|
<h1 class="text-xl font-bold mt-2">pulse-lets-go</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">Telephone Balancer</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form on:submit={handleLogin} class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium mb-1">Имя пользователя</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
bind:value={username}
|
||||||
|
class="w-full px-3 py-2 border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="admin"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium mb-1">Пароль</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
class="w-full px-3 py-2 border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Вход...' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-4 text-xs text-muted-foreground text-center">
|
||||||
|
По умолчанию: admin / admin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
166
web/src/routes/nodes/+page.svelte
Normal file
166
web/src/routes/nodes/+page.svelte
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { isAuthenticated, getNodes, toggleNode, connectWebSocket, type NodeInfo } from '$lib/api';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Phone, Power, PowerOff } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let nodes: NodeInfo[] = [];
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
|
// Toggle dialog
|
||||||
|
let toggleTarget: string | null = null;
|
||||||
|
let toggleDisabling = false;
|
||||||
|
let toggleReason = '';
|
||||||
|
|
||||||
|
if (browser && !isAuthenticated()) { goto('/login'); }
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadNodes();
|
||||||
|
ws = connectWebSocket((msg) => {
|
||||||
|
if (msg.type === 'nodes_update') {
|
||||||
|
nodes = msg.payload;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
ws?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadNodes() {
|
||||||
|
try {
|
||||||
|
nodes = await getNodes();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openToggle(nodeId: string, currentlyDisabled: boolean) {
|
||||||
|
toggleTarget = nodeId;
|
||||||
|
toggleDisabling = !currentlyDisabled;
|
||||||
|
toggleReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmToggle() {
|
||||||
|
if (!toggleTarget) return;
|
||||||
|
try {
|
||||||
|
const updated = await toggleNode(toggleTarget, toggleDisabling, toggleReason);
|
||||||
|
const idx = nodes.findIndex(n => n.node_id === toggleTarget);
|
||||||
|
if (idx >= 0) nodes[idx] = updated;
|
||||||
|
toast.success(toggleDisabling ? `Нода ${toggleTarget} исключена из распределения` : `Нода ${toggleTarget} возвращена в распределение`);
|
||||||
|
toggleTarget = null;
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreColor(score: number): string {
|
||||||
|
if (score >= 70) return 'text-green-600';
|
||||||
|
if (score >= 40) return 'text-yellow-600';
|
||||||
|
if (score >= 0) return 'text-orange-600';
|
||||||
|
return 'text-red-600';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h1 class="text-xl font-bold flex items-center gap-2">
|
||||||
|
<Phone class="h-5 w-5" /> Ноды
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-muted-foreground text-sm">Загрузка...</p>
|
||||||
|
{:else if nodes.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-4 py-3 text-left font-medium">Node ID</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Score</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Звонки</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">CPU idle</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Load</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Fail %</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Статус</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Управление</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each nodes as n (n.node_id)}
|
||||||
|
<tr class="border-t hover:bg-secondary/50">
|
||||||
|
<td class="px-4 py-3 font-mono font-medium">{n.node_id}</td>
|
||||||
|
<td class="px-4 py-3 font-mono font-bold {scoreColor(n.score)}">{n.score.toFixed(0)}</td>
|
||||||
|
<td class="px-4 py-3 font-mono">{n.active_calls}/{n.max_calls}</td>
|
||||||
|
<td class="px-4 py-3 font-mono">{n.idle_cpu.toFixed(0)}%</td>
|
||||||
|
<td class="px-4 py-3 font-mono">{n.load_avg.toFixed(2)}</td>
|
||||||
|
<td class="px-4 py-3 font-mono">{n.call_failure_rate.toFixed(1)}%</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{#if n.disabled}
|
||||||
|
<span class="text-red-600 font-medium">disabled</span>
|
||||||
|
{:else if n.is_stale}
|
||||||
|
<span class="text-red-600 font-medium">stale</span>
|
||||||
|
{:else if n.score < 0}
|
||||||
|
<span class="text-red-600 font-medium">{n.lethal_reason || 'unhealthy'}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-green-600 font-medium">healthy</span>
|
||||||
|
{/if}
|
||||||
|
<div class="text-xs text-muted-foreground">{n.seconds_ago.toFixed(0)}с назад</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
{#if n.disabled}
|
||||||
|
<button on:click={() => openToggle(n.node_id, true)} class="flex items-center gap-1 px-3 py-1.5 bg-green-600 text-white rounded text-xs hover:opacity-90">
|
||||||
|
<Power class="h-3 w-3" /> Вернуть
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button on:click={() => openToggle(n.node_id, false)} class="flex items-center gap-1 px-3 py-1.5 bg-red-600 text-white rounded text-xs hover:opacity-90">
|
||||||
|
<PowerOff class="h-3 w-3" /> Исключить
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Toggle confirmation dialog -->
|
||||||
|
{#if toggleTarget}
|
||||||
|
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" on:click={() => (toggleTarget = null)} on:keydown={(e) => { if (e.key === 'Escape') toggleTarget = null; }} role="dialog">
|
||||||
|
<div class="bg-card border rounded-lg p-6 w-full max-w-md shadow-lg" on:click|stopPropagation>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">
|
||||||
|
{toggleDisabling ? 'Исключить ноду из распределения?' : 'Вернуть ноду в распределение?'}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
Нода: <span class="font-mono font-medium">{toggleTarget}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if toggleDisabling}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="reason" class="block text-sm font-medium mb-1">Причина исключения</label>
|
||||||
|
<input
|
||||||
|
id="reason"
|
||||||
|
type="text"
|
||||||
|
bind:value={toggleReason}
|
||||||
|
class="w-full px-3 py-2 border rounded-md text-sm bg-background"
|
||||||
|
placeholder="Например: технические работы"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button on:click={() => (toggleTarget = null)} class="px-4 py-2 border rounded-md text-sm">Отмена</button>
|
||||||
|
<button on:click={confirmToggle} class="px-4 py-2 {toggleDisabling ? 'bg-destructive text-destructive-foreground' : 'bg-primary text-primary-foreground'} rounded-md text-sm font-medium">
|
||||||
|
{toggleDisabling ? 'Исключить' : 'Вернуть'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
225
web/src/routes/trunks/+page.svelte
Normal file
225
web/src/routes/trunks/+page.svelte
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { isAuthenticated, getTrunks, createTrunk, updateTrunk, deleteTrunk, type Trunk } from '$lib/api';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Plus, Trash2, Save, X, Cable } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let trunks: Trunk[] = [];
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
|
// Edit mode
|
||||||
|
let editing: string | null = null; // trunk ID being edited
|
||||||
|
let editData: Partial<Trunk> = {};
|
||||||
|
let creating = false;
|
||||||
|
let newData: Partial<Trunk> = {
|
||||||
|
type: 'balance',
|
||||||
|
enabled: true,
|
||||||
|
codecs: ['PCMU', 'PCMA'],
|
||||||
|
context: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
let typeFilter = '';
|
||||||
|
|
||||||
|
if (browser && !isAuthenticated()) { goto('/login'); }
|
||||||
|
|
||||||
|
onMount(loadTrunks);
|
||||||
|
|
||||||
|
async function loadTrunks() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
trunks = await getTrunks(typeFilter || undefined);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(t: Trunk) {
|
||||||
|
editing = t.id;
|
||||||
|
editData = { ...t };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing = null;
|
||||||
|
creating = false;
|
||||||
|
editData = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editing) return;
|
||||||
|
try {
|
||||||
|
const updated = await updateTrunk(editing, editData);
|
||||||
|
const idx = trunks.findIndex(t => t.id === editing);
|
||||||
|
if (idx >= 0) trunks[idx] = updated;
|
||||||
|
editing = null;
|
||||||
|
toast.success('Транк обновлён');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCreate() {
|
||||||
|
try {
|
||||||
|
const created = await createTrunk(newData);
|
||||||
|
trunks = [...trunks, created];
|
||||||
|
creating = false;
|
||||||
|
newData = { type: 'balance', enabled: true, codecs: ['PCMU', 'PCMA'], context: 'default' };
|
||||||
|
toast.success('Транк создан');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTrunk(id: string) {
|
||||||
|
if (!confirm('Удалить транк?')) return;
|
||||||
|
try {
|
||||||
|
await deleteTrunk(id);
|
||||||
|
trunks = trunks.filter(t => t.id !== id);
|
||||||
|
toast.success('Транк удалён');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadge(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'ingress': return 'bg-blue-100 text-blue-700';
|
||||||
|
case 'balance': return 'bg-green-100 text-green-700';
|
||||||
|
case 'fallback': return 'bg-orange-100 text-orange-700';
|
||||||
|
default: return 'bg-gray-100 text-gray-700';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'ingress': return 'Вход';
|
||||||
|
case 'balance': return 'Баланс';
|
||||||
|
case 'fallback': return 'Резерв';
|
||||||
|
default: return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterChange(e: Event) {
|
||||||
|
typeFilter = (e.target as HTMLSelectElement).value;
|
||||||
|
loadTrunks();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-bold flex items-center gap-2">
|
||||||
|
<Cable 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>
|
||||||
|
|
||||||
|
<!-- Filter -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<select value={typeFilter} on:change={onFilterChange} class="px-3 py-2 border rounded-md text-sm bg-background">
|
||||||
|
<option value="">Все типы</option>
|
||||||
|
<option value="ingress">Ingress (входные)</option>
|
||||||
|
<option value="balance">Balance (балансировка)</option>
|
||||||
|
<option value="fallback">Fallback (резерв)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create form -->
|
||||||
|
{#if creating}
|
||||||
|
<div class="border rounded-lg p-4 bg-yellow-50 border-yellow-200">
|
||||||
|
<h3 class="text-sm font-semibold mb-3">Новый транк</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
|
||||||
|
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Название" bind:value={newData.name} />
|
||||||
|
<select class="px-3 py-2 border rounded-md text-sm bg-background" bind:value={newData.type}>
|
||||||
|
<option value="ingress">Ingress</option>
|
||||||
|
<option value="balance">Balance</option>
|
||||||
|
<option value="fallback">Fallback</option>
|
||||||
|
</select>
|
||||||
|
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Gateway (sip:...)" bind:value={newData.gateway} />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
|
||||||
|
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Node ID (для balance)" bind:value={newData.node_id} />
|
||||||
|
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Context" bind:value={newData.context} />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" bind:checked={newData.enabled} class="rounded" /> Включён
|
||||||
|
</label>
|
||||||
|
<input class="flex-1 px-3 py-2 border rounded-md text-sm" placeholder="Описание" bind:value={newData.description} />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-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 trunks.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-4 py-3 text-left font-medium">Название</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Тип</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Gateway</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Node ID</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Статус</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each trunks as t (t.id)}
|
||||||
|
{#if editing === t.id}
|
||||||
|
<!-- Edit row -->
|
||||||
|
<tr class="border-t bg-yellow-50">
|
||||||
|
<td class="px-4 py-2"><input class="w-full px-2 py-1 border rounded text-sm" bind:value={editData.name} /></td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<select class="w-full px-2 py-1 border rounded text-sm" bind:value={editData.type}>
|
||||||
|
<option value="ingress">Ingress</option>
|
||||||
|
<option value="balance">Balance</option>
|
||||||
|
<option value="fallback">Fallback</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2"><input class="w-full px-2 py-1 border rounded text-sm" bind:value={editData.gateway} /></td>
|
||||||
|
<td class="px-4 py-2"><input class="w-full px-2 py-1 border rounded text-sm" bind:value={editData.node_id} /></td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" bind:checked={editData.enabled} />
|
||||||
|
{editData.enabled ? 'Вкл' : 'Выкл'}
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 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}
|
||||||
|
<!-- Normal row -->
|
||||||
|
<tr class="border-t hover:bg-secondary/50">
|
||||||
|
<td class="px-4 py-3 font-medium">{t.name}</td>
|
||||||
|
<td class="px-4 py-3"><span class="px-2 py-1 rounded text-xs font-medium {typeBadge(t.type)}">{typeLabel(t.type)}</span></td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs">{t.gateway}</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs">{t.node_id || '—'}</td>
|
||||||
|
<td class="px-4 py-3">{t.enabled ? '🟢 Вкл' : '🔴 Выкл'}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button on:click={() => startEdit(t)} class="px-2 py-1 text-xs text-primary hover:underline mr-1">Изменить</button>
|
||||||
|
<button on:click={() => removeTrunk(t.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>
|
||||||
375
web/src/routes/users/+page.svelte
Normal file
375
web/src/routes/users/+page.svelte
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { isAuthenticated, getUsers, createUser, updateUser, deleteUser, type UserInfo } from '$lib/api';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Users, Plus, Save, X, Trash2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let users: UserInfo[] = [];
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
|
// Создание
|
||||||
|
let creating = false;
|
||||||
|
let newUsername = '';
|
||||||
|
let newPassword = '';
|
||||||
|
let newRole = 'viewer';
|
||||||
|
|
||||||
|
// Редактирование
|
||||||
|
let editing: string | null = null;
|
||||||
|
let editUsername = '';
|
||||||
|
let editPassword = '';
|
||||||
|
let editRole = 'viewer';
|
||||||
|
|
||||||
|
// Подтверждение удаления
|
||||||
|
let deleteTarget: string | null = null;
|
||||||
|
let deleteUsername = '';
|
||||||
|
let deleteIsLastAdmin = false;
|
||||||
|
|
||||||
|
if (browser && !isAuthenticated()) {
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
users = await getUsers();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Ошибка загрузки');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Создание ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!newUsername || !newPassword) {
|
||||||
|
toast.error('Заполните имя и пароль');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const u = await createUser({ username: newUsername, password: newPassword, role: newRole });
|
||||||
|
users = [...users, u];
|
||||||
|
creating = false;
|
||||||
|
newUsername = ''; newPassword = ''; newRole = 'viewer';
|
||||||
|
toast.success('Пользователь создан');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Ошибка создания');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCreate() {
|
||||||
|
creating = false;
|
||||||
|
newUsername = ''; newPassword = ''; newRole = 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Редактирование ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function startEdit(u: UserInfo) {
|
||||||
|
editing = u.id;
|
||||||
|
editUsername = u.username;
|
||||||
|
editPassword = '';
|
||||||
|
editRole = u.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editing || !editUsername) return;
|
||||||
|
const data: { username?: string; password?: string; role?: string } = {
|
||||||
|
username: editUsername,
|
||||||
|
role: editRole,
|
||||||
|
};
|
||||||
|
if (editPassword) {
|
||||||
|
data.password = editPassword;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await updateUser(editing, data);
|
||||||
|
const idx = users.findIndex(u => u.id === editing);
|
||||||
|
if (idx >= 0) {
|
||||||
|
users = [...users.slice(0, idx), updated, ...users.slice(idx + 1)];
|
||||||
|
}
|
||||||
|
editing = null;
|
||||||
|
toast.success('Пользователь обновлён');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Ошибка обновления');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Удаление ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function confirmDelete(u: UserInfo) {
|
||||||
|
const adminCount = users.filter(x => x.role === 'admin').length;
|
||||||
|
deleteIsLastAdmin = u.role === 'admin' && adminCount <= 1;
|
||||||
|
deleteTarget = u.id;
|
||||||
|
deleteUsername = u.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
deleteTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDelete() {
|
||||||
|
if (!deleteTarget || deleteIsLastAdmin) return;
|
||||||
|
try {
|
||||||
|
await deleteUser(deleteTarget);
|
||||||
|
users = users.filter(u => u.id !== deleteTarget);
|
||||||
|
deleteTarget = null;
|
||||||
|
toast.success('Пользователь удалён');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Ошибка удаления');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Форматирование ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function roleBadge(role: string): string {
|
||||||
|
return role === 'admin'
|
||||||
|
? 'bg-purple-100 text-purple-700'
|
||||||
|
: 'bg-gray-100 text-gray-700';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: string): string {
|
||||||
|
if (!d) return '—';
|
||||||
|
return new Date(d).toLocaleDateString('ru-RU', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка: является ли текущий пользователь владельцем строки
|
||||||
|
function currentUsername(): string {
|
||||||
|
const token = browser ? localStorage.getItem('token') : null;
|
||||||
|
if (!token) return '';
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
return payload?.username || '';
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Заголовок -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-bold flex items-center gap-2">
|
||||||
|
<Users 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">
|
||||||
|
<h3 class="text-sm font-semibold mb-3">Новый пользователь</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
|
||||||
|
<input
|
||||||
|
class="px-3 py-2 border rounded-md text-sm bg-background"
|
||||||
|
placeholder="Имя пользователя"
|
||||||
|
bind:value={newUsername}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="px-3 py-2 border rounded-md text-sm bg-background"
|
||||||
|
type="password"
|
||||||
|
placeholder="Пароль"
|
||||||
|
bind:value={newPassword}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
class="px-3 py-2 border rounded-md text-sm bg-background"
|
||||||
|
bind:value={newRole}
|
||||||
|
>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
on:click={handleCreate}
|
||||||
|
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={cancelCreate}
|
||||||
|
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 users.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-4 py-3 text-left font-medium">Имя пользователя</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Роль</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Создан</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each users as u (u.id)}
|
||||||
|
{#if editing === u.id}
|
||||||
|
<!-- ── Строка редактирования ── -->
|
||||||
|
<tr class="border-t bg-yellow-50">
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<input
|
||||||
|
class="w-full px-2 py-1 border rounded text-sm bg-background"
|
||||||
|
bind:value={editUsername}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{#if u.id === 'user-01'}
|
||||||
|
<span class="px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||||
|
admin 🔒
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted-foreground ml-1">защищён</span>
|
||||||
|
{:else}
|
||||||
|
<select
|
||||||
|
class="w-full px-2 py-1 border rounded text-sm bg-background"
|
||||||
|
bind:value={editRole}
|
||||||
|
>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-muted-foreground text-xs">
|
||||||
|
{formatDate(u.created_at)}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-right">
|
||||||
|
<div class="flex gap-1 justify-end">
|
||||||
|
<input
|
||||||
|
class="w-24 px-2 py-1 border rounded text-xs bg-background"
|
||||||
|
type="password"
|
||||||
|
placeholder="Новый пароль"
|
||||||
|
bind:value={editPassword}
|
||||||
|
/>
|
||||||
|
<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-4 py-3">
|
||||||
|
<span class="font-medium">{u.username}</span>
|
||||||
|
{#if u.id === 'user-01'}
|
||||||
|
<span class="text-xs text-purple-600 ml-1" title="Первичный администратор. Роль защищена от изменения.">основной</span>
|
||||||
|
{/if}
|
||||||
|
{#if u.username === currentUsername()}
|
||||||
|
<span class="text-xs text-muted-foreground ml-2">(вы)</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium {roleBadge(u.role)}">
|
||||||
|
{u.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
{formatDate(u.created_at)}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
on:click={() => startEdit(u)}
|
||||||
|
class="px-2 py-1 text-xs text-primary hover:underline mr-1"
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
{#if u.id !== 'user-01'}
|
||||||
|
<button
|
||||||
|
on:click={() => confirmDelete(u)}
|
||||||
|
class="px-2 py-1 text-xs text-destructive hover:underline"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3 w-3 inline" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Модальное окно удаления ────────────────────────────────── -->
|
||||||
|
|
||||||
|
{#if deleteTarget}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
on:click={cancelDelete}
|
||||||
|
on:keydown={(e) => { if (e.key === 'Escape') cancelDelete(); }}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div class="bg-card border rounded-lg p-6 w-full max-w-md shadow-lg" on:click|stopPropagation>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">
|
||||||
|
{deleteIsLastAdmin ? 'Невозможно удалить' : 'Удалить пользователя?'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if deleteIsLastAdmin}
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
<strong>{deleteUsername}</strong> — последний администратор.
|
||||||
|
Сначала назначьте другого пользователя администратором.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button on:click={cancelDelete} class="px-4 py-2 border rounded-md text-sm">
|
||||||
|
Понятно
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
Вы уверены, что хотите удалить пользователя
|
||||||
|
<strong>{deleteUsername}</strong>?
|
||||||
|
Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button on:click={cancelDelete} class="px-4 py-2 border rounded-md text-sm">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={executeDelete}
|
||||||
|
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-md text-sm font-medium hover:opacity-90"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
22
web/svelte.config.js
Normal file
22
web/svelte.config.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
pages: 'build',
|
||||||
|
assets: 'build',
|
||||||
|
fallback: 'index.html',
|
||||||
|
precompress: false,
|
||||||
|
strict: false
|
||||||
|
}),
|
||||||
|
alias: {
|
||||||
|
'$lib': './src/lib'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
17
web/vite.config.ts
Normal file
17
web/vite.config.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080',
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8080',
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user