commit 44f5fa88e5162a710ceec1e147fb232b8708fd2e Author: Maksim Totmin Date: Thu Jun 25 14:27:41 2026 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d74f7ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +data/ +web/build/ +web/.svelte-kit/ +web/node_modules/ +bin/ +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..252790c --- /dev/null +++ b/AGENTS.md @@ -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.` | +| Хранилище | 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 ` +- `/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. + +``` + + + + + +``` + +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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..49763a7 --- /dev/null +++ b/Makefile @@ -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 ./... diff --git a/cmd/emulator/main.go b/cmd/emulator/main.go new file mode 100644 index 0000000..df0f3ad --- /dev/null +++ b/cmd/emulator/main.go @@ -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) +} diff --git a/cmd/pulse-lets-go/main.go b/cmd/pulse-lets-go/main.go new file mode 100644 index 0000000..aeda2df --- /dev/null +++ b/cmd/pulse-lets-go/main.go @@ -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 +} diff --git a/contrib/route.lua b/contrib/route.lua new file mode 100644 index 0000000..925d76e --- /dev/null +++ b/contrib/route.lua @@ -0,0 +1,156 @@ +--[[ + route.lua — запрашивает pulse-lets-go для маршрутизации вызова. + Телефонный балансировщик нагрузки на базе FreeSWITCH + NATS. + + ┌─ Размещение ─────────────────────────────────────────────────────┐ + │ /etc/freeswitch/scripts/route.lua │ + │ │ + │ ┌─ Использование в dialplan ────────────────────────────────────┐│ + │ │ ││ + │ │ ││ + │ │ ││ + │ │ ││ + │ │ ││ + │ └───────────────────────────────────────────────────────────────┘│ + │ │ + │ ┌─ Переменные канала (опционально) ─────────────────────────────┐│ + │ │ balancer_url — URL pulse-lets-go (default: localhost:8080) ││ + │ │ ingress_trunk — ID входящего транка ││ + │ └───────────────────────────────────────────────────────────────┘│ + │ │ + │ ┌─ Требования FreeSWITCH ───────────────────────────────────────┐│ + │ │ 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() diff --git a/deploy/pulse-lets-go.service b/deploy/pulse-lets-go.service new file mode 100644 index 0000000..35fd6c5 --- /dev/null +++ b/deploy/pulse-lets-go.service @@ -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 diff --git a/emulator b/emulator new file mode 100755 index 0000000..5d6d1d2 Binary files /dev/null and b/emulator differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..473be1c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f840197 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..754ceab --- /dev/null +++ b/internal/api/auth.go @@ -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 . +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}) +} diff --git a/internal/api/health.go b/internal/api/health.go new file mode 100644 index 0000000..295f898 --- /dev/null +++ b/internal/api/health.go @@ -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"}) +} diff --git a/internal/api/logging.go b/internal/api/logging.go new file mode 100644 index 0000000..2506a7b --- /dev/null +++ b/internal/api/logging.go @@ -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) +} diff --git a/internal/api/monitoring.go b/internal/api/monitoring.go new file mode 100644 index 0000000..74e6850 --- /dev/null +++ b/internal/api/monitoring.go @@ -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, ".", "_"), "-", "_") +} diff --git a/internal/api/nodes.go b/internal/api/nodes.go new file mode 100644 index 0000000..61acf32 --- /dev/null +++ b/internal/api/nodes.go @@ -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) +} diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go new file mode 100644 index 0000000..5e3afca --- /dev/null +++ b/internal/api/ratelimit.go @@ -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 +} diff --git a/internal/api/route.go b/internal/api/route.go new file mode 100644 index 0000000..aa68280 --- /dev/null +++ b/internal/api/route.go @@ -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 +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..64b7e2a --- /dev/null +++ b/internal/api/router.go @@ -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 +} diff --git a/internal/api/trunks.go b/internal/api/trunks.go new file mode 100644 index 0000000..b516ab7 --- /dev/null +++ b/internal/api/trunks.go @@ -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 +} diff --git a/internal/api/users.go b/internal/api/users.go new file mode 100644 index 0000000..759d067 --- /dev/null +++ b/internal/api/users.go @@ -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] +} diff --git a/internal/api/ws.go b/internal/api/ws.go new file mode 100644 index 0000000..e467066 --- /dev/null +++ b/internal/api/ws.go @@ -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 +} diff --git a/internal/config/manager.go b/internal/config/manager.go new file mode 100644 index 0000000..083e5dc --- /dev/null +++ b/internal/config/manager.go @@ -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 +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..fa0d05e --- /dev/null +++ b/internal/engine/engine.go @@ -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 +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..d9bb31b --- /dev/null +++ b/internal/engine/engine_test.go @@ -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) + } + } +} diff --git a/internal/engine/ring.go b/internal/engine/ring.go new file mode 100644 index 0000000..2b61b2f --- /dev/null +++ b/internal/engine/ring.go @@ -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 +} diff --git a/internal/esl/client.go b/internal/esl/client.go new file mode 100644 index 0000000..d93b6b3 --- /dev/null +++ b/internal/esl/client.go @@ -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) +} diff --git a/internal/esl/events.go b/internal/esl/events.go new file mode 100644 index 0000000..401aa62 --- /dev/null +++ b/internal/esl/events.go @@ -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) + } + }() +} diff --git a/internal/esl/gateway.go b/internal/esl/gateway.go new file mode 100644 index 0000000..eb4fd0b --- /dev/null +++ b/internal/esl/gateway.go @@ -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 +} diff --git a/internal/log/logger.go b/internal/log/logger.go new file mode 100644 index 0000000..4f4b441 --- /dev/null +++ b/internal/log/logger.go @@ -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.") +} diff --git a/internal/models/types.go b/internal/models/types.go new file mode 100644 index 0000000..d7ae6cd --- /dev/null +++ b/internal/models/types.go @@ -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"` +} diff --git a/internal/nats/subscriber.go b/internal/nats/subscriber.go new file mode 100644 index 0000000..11f76f5 --- /dev/null +++ b/internal/nats/subscriber.go @@ -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() +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..0cefcf2 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2311 @@ +{ + "name": "pulse-lets-go-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pulse-lets-go-web", + "version": "1.0.0", + "dependencies": { + "clsx": "^2.1.0", + "lucide-svelte": "^1.0.0", + "svelte-sonner": "^1.0.0", + "tailwind-merge": "^3.0.0" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.0", + "@sveltejs/kit": "^2.60.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.3.0", + "svelte": "^5.50.0", + "tailwindcss": "^4.3.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.68.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.68.0.tgz", + "integrity": "sha512-PdKiWsqinAoubVsSiRgVFkg3MHzGhQPnwQ8VxnGQKpZYijpapZ3UHHBje0GeByt2TvfjHPw+kxV+dNK2RIZg9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.12.tgz", + "integrity": "sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/lucide-svelte": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz", + "integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==", + "deprecated": "Package deprecated. Please use @lucide/svelte instead.", + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/runed": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", + "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", + "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-sonner": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.1.tgz", + "integrity": "sha512-5cd3p7wa4cq0NsqslMwdlPb7x1JglEZ/GKrLePWNr5bCxR1nagAVrY01FRFrXfUGs41miLt3C327+8XJo5BzZw==", + "license": "MIT", + "dependencies": { + "runed": "^0.28.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b013ed4 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..2f39de9 --- /dev/null +++ b/web/src/app.css @@ -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); + } +} diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..7a9c1bf --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,13 @@ + + + + + + + pulse-lets-go + %sveltekit.head% + + + %sveltekit.body% + + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..03bb51d --- /dev/null +++ b/web/src/lib/api.ts @@ -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 { + 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(url: string, options: RequestInit = {}): Promise { + let token = getToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + 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 { + return request('/nodes'); +} + +export async function toggleNode(nodeId: string, disabled: boolean, reason: string = '') { + return request(`/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 { + const q = typeFilter ? `?type=${typeFilter}` : ''; + return request(`/trunks${q}`); +} + +export async function createTrunk(data: Partial) { + return request('/trunks', { method: 'POST', body: JSON.stringify(data) }); +} + +export async function updateTrunk(id: string, data: Partial) { + return request(`/trunks/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(data) }); +} + +export async function deleteTrunk(id: string) { + return request(`/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 { + return request('/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(`/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; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..fb8662e --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,66 @@ + + + + +{#if !isLogin} +
+ + + + +
+ +
+
+{:else} + +{/if} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..5dc5db1 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,188 @@ + + +{#if error} +
+ {error} +
+{/if} + + +
+
+
+ Всего нод +
+
{nodes.length}
+
+ +
+
+ Здоровых +
+
{healthyCount}
+
+ +
+
+ Активных звонков +
+
{totalCalls}
+
+ +
+
+ Средний score +
+
{avgScore.toFixed(1)}
+
+
+ + +{#if unhealthyList.length > 0} +
+

+ Проблемные ноды ({unhealthyList.length}) +

+
+ {#each unhealthyList as n} +
+ + {n.node_id} + score={n.score.toFixed(0)} + {#if n.lethal_reason} + — {n.lethal_reason} + {/if} + {#if n.disabled} + — disabled: {n.disabled_reason} + {/if} +
+ {/each} +
+
+{/if} + + +
+ {#each nodes as n (n.node_id)} +
+
+
+ + {n.node_id} +
+ + {n.score.toFixed(0)} + +
+ +
+
+ Звонки: + {n.active_calls}/{n.max_calls} +
+
+ CPU idle: + {n.idle_cpu.toFixed(0)}% +
+
+ Load: + {n.load_avg.toFixed(2)} +
+
+ Fail: + {n.call_failure_rate.toFixed(1)}% +
+
+ Status: + {nodeStatus(n)} + + {n.seconds_ago.toFixed(0)}с назад + + {#if n.is_stale} + STALE + {/if} +
+
+
+ {/each} + + {#if nodes.length === 0} +
+ +

Нет зарегистрированных нод. Ожидание метрик через NATS...

+
+ {/if} +
diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte new file mode 100644 index 0000000..7bd17af --- /dev/null +++ b/web/src/routes/login/+page.svelte @@ -0,0 +1,80 @@ + + +
+
+
+ 📞 +

pulse-lets-go

+

Telephone Balancer

+
+ +
+
+ + +
+ +
+ + +
+ + +
+ +

+ По умолчанию: admin / admin +

+
+
diff --git a/web/src/routes/nodes/+page.svelte b/web/src/routes/nodes/+page.svelte new file mode 100644 index 0000000..e8d91da --- /dev/null +++ b/web/src/routes/nodes/+page.svelte @@ -0,0 +1,166 @@ + + +
+

+ Ноды +

+ + {#if loading} +

Загрузка...

+ {:else if nodes.length === 0} +

Нет зарегистрированных нод.

+ {:else} +
+ + + + + + + + + + + + + + + {#each nodes as n (n.node_id)} + + + + + + + + + + + {/each} + +
Node IDScoreЗвонкиCPU idleLoadFail %СтатусУправление
{n.node_id}{n.score.toFixed(0)}{n.active_calls}/{n.max_calls}{n.idle_cpu.toFixed(0)}%{n.load_avg.toFixed(2)}{n.call_failure_rate.toFixed(1)}% + {#if n.disabled} + disabled + {:else if n.is_stale} + stale + {:else if n.score < 0} + {n.lethal_reason || 'unhealthy'} + {:else} + healthy + {/if} +
{n.seconds_ago.toFixed(0)}с назад
+
+ {#if n.disabled} + + {:else} + + {/if} +
+
+ {/if} + + + {#if toggleTarget} +
(toggleTarget = null)} on:keydown={(e) => { if (e.key === 'Escape') toggleTarget = null; }} role="dialog"> +
+

+ {toggleDisabling ? 'Исключить ноду из распределения?' : 'Вернуть ноду в распределение?'} +

+

+ Нода: {toggleTarget} +

+ + {#if toggleDisabling} +
+ + +
+ {/if} + +
+ + +
+
+
+ {/if} +
diff --git a/web/src/routes/trunks/+page.svelte b/web/src/routes/trunks/+page.svelte new file mode 100644 index 0000000..8270c29 --- /dev/null +++ b/web/src/routes/trunks/+page.svelte @@ -0,0 +1,225 @@ + + +
+
+

+ Транки +

+ +
+ + +
+ +
+ + + {#if creating} +
+

Новый транк

+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + + {#if loading} +

Загрузка...

+ {:else if trunks.length === 0} +

Нет транков. Создайте первый.

+ {:else} +
+ + + + + + + + + + + + + {#each trunks as t (t.id)} + {#if editing === t.id} + + + + + + + + + + {:else} + + + + + + + + + + {/if} + {/each} + +
НазваниеТипGatewayNode IDСтатусДействия
+ + + + +
+ + +
+
{t.name}{typeLabel(t.type)}{t.gateway}{t.node_id || '—'}{t.enabled ? '🟢 Вкл' : '🔴 Выкл'} + + +
+
+ {/if} +
diff --git a/web/src/routes/users/+page.svelte b/web/src/routes/users/+page.svelte new file mode 100644 index 0000000..56e3d7d --- /dev/null +++ b/web/src/routes/users/+page.svelte @@ -0,0 +1,375 @@ + + +
+ +
+

+ Пользователи +

+ +
+ + + + {#if creating} +
+

Новый пользователь

+
+ + + +
+
+ + +
+
+ {/if} + + + + {#if loading} +

Загрузка...

+ {:else if users.length === 0} +

+ Нет пользователей. Создайте первого. +

+ {:else} +
+ + + + + + + + + + + {#each users as u (u.id)} + {#if editing === u.id} + + + + + + + + {:else} + + + + + + + + {/if} + {/each} + +
Имя пользователяРольСозданДействия
+ + + {#if u.id === 'user-01'} + + admin 🔒 + + защищён + {:else} + + {/if} + + {formatDate(u.created_at)} + +
+ + + +
+
+ {u.username} + {#if u.id === 'user-01'} + основной + {/if} + {#if u.username === currentUsername()} + (вы) + {/if} + + + {u.role} + + + {formatDate(u.created_at)} + + + {#if u.id !== 'user-01'} + + {/if} +
+
+ {/if} + + + + {#if deleteTarget} +
{ if (e.key === 'Escape') cancelDelete(); }} + role="dialog" + > +
+

+ {deleteIsLastAdmin ? 'Невозможно удалить' : 'Удалить пользователя?'} +

+ + {#if deleteIsLastAdmin} +

+ {deleteUsername} — последний администратор. + Сначала назначьте другого пользователя администратором. +

+
+ +
+ {:else} +

+ Вы уверены, что хотите удалить пользователя + {deleteUsername}? + Это действие нельзя отменить. +

+
+ + +
+ {/if} +
+
+ {/if} +
diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..22fed80 --- /dev/null +++ b/web/svelte.config.js @@ -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; diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..af95d42 --- /dev/null +++ b/web/vite.config.ts @@ -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 + } + } + } +});