Maksim Totmin 938c9e9362 feat: weighted random routing + mode switching + EWMA smoothing + stale eviction
- Weighted random routing (math/rand/v2 per-goroutine ChaCha8)
- PUT /api/route/mode for runtime mode switch (best / weighted_random)
- EWMA score smoothing (configurable smoothing_factor, default 0.3)
- Periodic stale node eviction (5 min threshold, 60s interval)
- Non-blocking WS broadcast (per-client buffered channel + writePump)
- Background rate limiter cleanup goroutine
- In-memory trunk gateway cache (zero disk I/O in hot path)
- Configurable load_avg multiplier (default 50.0, backward compat)
- UI mode indicator + admin Switch button in dashboard
- 42 tests: weighted random, mode switching, EWMA, eviction, API integration
2026-06-25 20:42:46 +07:00

314 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
a.RebuildTrunkCache()
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)
a.RebuildTrunkCache()
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
}
a.RebuildTrunkCache()
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) {
cfg := a.readConfig()
profile = cfg.ESL.SofiaProfile
name = fmt.Sprintf("%s-ingress-%s", cfg.ESL.GatewayPrefix, 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
}