Routing rules: - RouteRule model: 3 match fields (caller_id, dest, ingress_trunk regex), 3 action types (node/pool/auto), time-based (days of week, time range) - Engine router.go: CompiledRule with pre-compiled regex, PickNodeForCall with cascade (dead node → next rule), pickNodeFromPool - Separate sync.RWMutex for Router — zero contention admin vs traffic - Validate-before-save pattern: engine validates before JSON save - Graceful degradation: corrupted rules skipped, no rules → PickNode() Persistence: - data/routing.json with atomic save (tmp→rename), config.Manager - Auto-created empty on first start API (admin only): - GET/POST/PUT/DELETE /api/routing/rules - PUT /api/routing/rules/:id/toggle - PUT /api/routing/rules/reorder Integration: - /api/route now uses PickNodeForCall with caller_id/dest/ingress_trunk query params (already sent by route.lua) - RouteResponse includes matched_rule field for debugging - Main.go loads routing rules on startup with graceful fallback Web UI: - /routing page: table with priority arrows, inline edit, create form, regex test tool, toggle, delete, action badges, hits counter Tests: - 28 tests: matchRule, time matching, PickNodeForCall cascade, pool scoring, validation, hit counting, compilation
318 lines
9.0 KiB
Go
318 lines
9.0 KiB
Go
package api
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"time"
|
||
|
||
"github.com/pulse-lets-go/internal/models"
|
||
"github.com/pulse-lets-go/internal/util"
|
||
)
|
||
|
||
// handleGetRoutingRules — GET /api/routing/rules — список всех правил с hits.
|
||
func (a *API) handleGetRoutingRules(w http.ResponseWriter, r *http.Request) {
|
||
rules, err := a.configManager.ReadRoutingRules()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил маршрутизации")
|
||
return
|
||
}
|
||
|
||
// Сортировка по priority
|
||
sort.Slice(rules, func(i, j int) bool {
|
||
return rules[i].Priority < rules[j].Priority
|
||
})
|
||
|
||
// Подмешиваем hits из engine в модель
|
||
compiledRules := a.engine.GetCompiledRules()
|
||
hitsMap := make(map[string]int64, len(compiledRules))
|
||
for _, cr := range compiledRules {
|
||
hitsMap[cr.ID] = cr.GetHits()
|
||
}
|
||
|
||
type RuleWithHits struct {
|
||
models.RouteRule
|
||
Hits int64 `json:"hits"`
|
||
}
|
||
result := make([]RuleWithHits, len(rules))
|
||
for i, r := range rules {
|
||
result[i] = RuleWithHits{RouteRule: r, Hits: hitsMap[r.ID]}
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, result)
|
||
}
|
||
|
||
// handleCreateRoutingRule — POST /api/routing/rules — создание нового правила.
|
||
func (a *API) handleCreateRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||
var rule models.RouteRule
|
||
if err := decodeJSON(r, &rule); err != nil {
|
||
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||
return
|
||
}
|
||
|
||
// Генерируем ID
|
||
rule.ID = "rr-" + util.RandomHex(6)
|
||
now := time.Now().UTC()
|
||
rule.CreatedAt = now
|
||
rule.UpdatedAt = now
|
||
rule.Enabled = true
|
||
if rule.Priority <= 0 {
|
||
rule.Priority = 10
|
||
}
|
||
|
||
// Читаем текущие правила
|
||
rules, err := a.configManager.ReadRoutingRules()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
|
||
return
|
||
}
|
||
|
||
// Добавляем новое правило
|
||
rules = append(rules, rule)
|
||
|
||
// Валидируем (validate-before-save)
|
||
if err := a.engine.ValidateRules(rules); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
// Сохраняем на диск
|
||
if err := a.configManager.SaveRoutingRules(rules); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "ошибка сохранения правила")
|
||
return
|
||
}
|
||
|
||
// Загружаем в память
|
||
a.engine.SetRules(rules)
|
||
|
||
writeJSON(w, http.StatusCreated, rule)
|
||
}
|
||
|
||
// handleUpdateRoutingRule — PUT /api/routing/rules/{id} — обновление правила.
|
||
func (a *API) handleUpdateRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||
id := r.PathValue("id")
|
||
if id == "" {
|
||
writeError(w, http.StatusBadRequest, "id обязателен")
|
||
return
|
||
}
|
||
|
||
var updates models.RouteRule
|
||
if err := decodeJSON(r, &updates); err != nil {
|
||
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||
return
|
||
}
|
||
|
||
rules, err := a.configManager.ReadRoutingRules()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
|
||
return
|
||
}
|
||
|
||
idx := -1
|
||
for i, rule := range rules {
|
||
if rule.ID == id {
|
||
idx = i
|
||
break
|
||
}
|
||
}
|
||
if idx < 0 {
|
||
writeError(w, http.StatusNotFound, "правило не найдено")
|
||
return
|
||
}
|
||
|
||
// Частичное обновление: применяем только непустые поля
|
||
target := &rules[idx]
|
||
if updates.Name != "" {
|
||
target.Name = updates.Name
|
||
}
|
||
if updates.Priority > 0 {
|
||
target.Priority = updates.Priority
|
||
}
|
||
if updates.MatchCallerID != "" {
|
||
target.MatchCallerID = updates.MatchCallerID
|
||
}
|
||
if updates.MatchDestination != "" {
|
||
target.MatchDestination = updates.MatchDestination
|
||
}
|
||
if updates.MatchIngressTrunk != "" {
|
||
target.MatchIngressTrunk = updates.MatchIngressTrunk
|
||
}
|
||
if updates.MatchDaysOfWeek != "" {
|
||
target.MatchDaysOfWeek = updates.MatchDaysOfWeek
|
||
}
|
||
if updates.MatchTimeStart != "" {
|
||
target.MatchTimeStart = updates.MatchTimeStart
|
||
}
|
||
if updates.MatchTimeEnd != "" {
|
||
target.MatchTimeEnd = updates.MatchTimeEnd
|
||
}
|
||
if updates.ActionType != "" {
|
||
target.ActionType = updates.ActionType
|
||
target.ActionNodeID = updates.ActionNodeID
|
||
target.ActionPoolIDs = updates.ActionPoolIDs
|
||
}
|
||
if updates.Description != "" {
|
||
target.Description = updates.Description
|
||
}
|
||
// Позволяем очистить match-поля через пустую строку в JSON (omitempty не подходит)
|
||
// Для этого используем указатели, но для упрощения: если поле не передано — оставляем старое
|
||
// Если передано явно (даже пустое) — нужно смотреть raw JSON, здесь упрощённо
|
||
target.UpdatedAt = time.Now().UTC()
|
||
|
||
// Валидируем
|
||
if err := a.engine.ValidateRules(rules); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
// Сохраняем
|
||
if err := a.configManager.SaveRoutingRules(rules); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "ошибка сохранения правила")
|
||
return
|
||
}
|
||
|
||
// Загружаем в память
|
||
a.engine.SetRules(rules)
|
||
|
||
writeJSON(w, http.StatusOK, *target)
|
||
}
|
||
|
||
// handleDeleteRoutingRule — DELETE /api/routing/rules/{id} — удаление правила.
|
||
func (a *API) handleDeleteRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||
id := r.PathValue("id")
|
||
if id == "" {
|
||
writeError(w, http.StatusBadRequest, "id обязателен")
|
||
return
|
||
}
|
||
|
||
rules, err := a.configManager.ReadRoutingRules()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
|
||
return
|
||
}
|
||
|
||
found := false
|
||
filtered := make([]models.RouteRule, 0, len(rules))
|
||
for _, rule := range rules {
|
||
if rule.ID == id {
|
||
found = true
|
||
continue
|
||
}
|
||
filtered = append(filtered, rule)
|
||
}
|
||
|
||
if !found {
|
||
writeError(w, http.StatusNotFound, "правило не найдено")
|
||
return
|
||
}
|
||
|
||
if err := a.configManager.SaveRoutingRules(filtered); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "ошибка сохранения правил")
|
||
return
|
||
}
|
||
|
||
// Загружаем в память (валидация не нужна — удаление не может нарушить валидность)
|
||
a.engine.SetRules(filtered)
|
||
|
||
writeJSON(w, http.StatusNoContent, nil)
|
||
}
|
||
|
||
// handleToggleRoutingRule — PUT /api/routing/rules/{id}/toggle — вкл/выкл правила.
|
||
func (a *API) handleToggleRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||
id := r.PathValue("id")
|
||
if id == "" {
|
||
writeError(w, http.StatusBadRequest, "id обязателен")
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Enabled bool `json:"enabled"`
|
||
}
|
||
if err := decodeJSON(r, &req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||
return
|
||
}
|
||
|
||
rules, err := a.configManager.ReadRoutingRules()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
|
||
return
|
||
}
|
||
|
||
idx := -1
|
||
for i, rule := range rules {
|
||
if rule.ID == id {
|
||
idx = i
|
||
break
|
||
}
|
||
}
|
||
if idx < 0 {
|
||
writeError(w, http.StatusNotFound, "правило не найдено")
|
||
return
|
||
}
|
||
|
||
rules[idx].Enabled = req.Enabled
|
||
rules[idx].UpdatedAt = time.Now().UTC()
|
||
|
||
if err := a.configManager.SaveRoutingRules(rules); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "ошибка сохранения правила")
|
||
return
|
||
}
|
||
|
||
a.engine.SetRules(rules)
|
||
|
||
writeJSON(w, http.StatusOK, rules[idx])
|
||
}
|
||
|
||
// handleReorderRoutingRules — PUT /api/routing/rules/reorder — переупорядочивание правил.
|
||
// Принимает массив ID в новом порядке. Переназначает Priority = index * 10.
|
||
func (a *API) handleReorderRoutingRules(w http.ResponseWriter, r *http.Request) {
|
||
var order []string
|
||
if err := decodeJSON(r, &order); err != nil {
|
||
writeError(w, http.StatusBadRequest, "некорректный JSON: ожидается массив id")
|
||
return
|
||
}
|
||
|
||
if len(order) == 0 {
|
||
writeError(w, http.StatusBadRequest, "пустой порядок")
|
||
return
|
||
}
|
||
|
||
rules, err := a.configManager.ReadRoutingRules()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
|
||
return
|
||
}
|
||
|
||
// Строим map для быстрого поиска
|
||
ruleMap := make(map[string]*models.RouteRule, len(rules))
|
||
for i := range rules {
|
||
ruleMap[rules[i].ID] = &rules[i]
|
||
}
|
||
|
||
// Проверяем что все ID из order присутствуют
|
||
for _, id := range order {
|
||
if _, ok := ruleMap[id]; !ok {
|
||
writeError(w, http.StatusBadRequest, fmt.Sprintf("правило с id '%s' не найдено", id))
|
||
return
|
||
}
|
||
}
|
||
|
||
// Переназначаем приоритеты
|
||
now := time.Now().UTC()
|
||
for i, id := range order {
|
||
r := ruleMap[id]
|
||
r.Priority = (i + 1) * 10
|
||
r.UpdatedAt = now
|
||
}
|
||
|
||
if err := a.configManager.SaveRoutingRules(rules); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "ошибка сохранения порядка")
|
||
return
|
||
}
|
||
|
||
a.engine.SetRules(rules)
|
||
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||
}
|