feat: auto-trunk creation, SIP gateway in metrics, gateway WS broadcast
- SIPGateway field in NodeMetric model - HasNode() engine method - OnNewNode callback in NATS subscriber - Auto-create balance trunk from new node metric - BroadcastGatewayEvent on WS hub - Gateway status indicators in trunks UI (green/red/gray) - SPA fallback for frontend routing
This commit is contained in:
parent
533a1fba49
commit
f25f8e4d01
@ -5,6 +5,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -20,6 +22,7 @@ import (
|
|||||||
"github.com/pulse-lets-go/internal/engine"
|
"github.com/pulse-lets-go/internal/engine"
|
||||||
"github.com/pulse-lets-go/internal/esl"
|
"github.com/pulse-lets-go/internal/esl"
|
||||||
filelog "github.com/pulse-lets-go/internal/log"
|
filelog "github.com/pulse-lets-go/internal/log"
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
"github.com/pulse-lets-go/internal/nats"
|
"github.com/pulse-lets-go/internal/nats"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -73,6 +76,42 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer sub.Close()
|
defer sub.Close()
|
||||||
|
|
||||||
|
// Авто-создание balance-транков при первой метрике от новой ноды
|
||||||
|
sub.SetOnNewNode(func(nodeID, sipGateway string) {
|
||||||
|
trunks, err := cfgMgr.ReadTrunks()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[main] ошибка чтения транков для авто-создания: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Проверяем идемпотентность: транк уже существует?
|
||||||
|
for _, t := range trunks {
|
||||||
|
if t.Type == "balance" && t.NodeID == nodeID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Создаём новый balance-транк
|
||||||
|
now := time.Now().UTC()
|
||||||
|
trunkID := "trk-" + randomHex(6)
|
||||||
|
trunk := models.Trunk{
|
||||||
|
ID: trunkID,
|
||||||
|
Name: fmt.Sprintf("%s баланс", nodeID),
|
||||||
|
Type: "balance",
|
||||||
|
NodeID: nodeID,
|
||||||
|
Gateway: sipGateway,
|
||||||
|
Codecs: []string{"PCMU", "PCMA"},
|
||||||
|
Context: "default",
|
||||||
|
Enabled: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
trunks = append(trunks, trunk)
|
||||||
|
if err := cfgMgr.SaveTrunks(trunks); err != nil {
|
||||||
|
log.Printf("[main] ошибка сохранения авто-созданного транка: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[nats] авто-создан balance-транк: %s → %s (узел: %s)", trunkID, sipGateway, nodeID)
|
||||||
|
})
|
||||||
|
|
||||||
// 6. HTTP API
|
// 6. HTTP API
|
||||||
apiHandler := api.NewAPI(eng, cfgMgr, cfg.JWTSecret, cfg.MonitoringAPIKey, sub.IsConnected, cfg)
|
apiHandler := api.NewAPI(eng, cfgMgr, cfg.JWTSecret, cfg.MonitoringAPIKey, sub.IsConnected, cfg)
|
||||||
handler := apiHandler.Handler()
|
handler := apiHandler.Handler()
|
||||||
@ -105,7 +144,26 @@ func main() {
|
|||||||
eslClient.GatewaySyncAll(cfg.ESL.SofiaProfile, specs)
|
eslClient.GatewaySyncAll(cfg.ESL.SofiaProfile, specs)
|
||||||
},
|
},
|
||||||
func(eventName string, headers map[string]string, body string) {
|
func(eventName string, headers map[string]string, body string) {
|
||||||
log.Printf("[esl] событие: %s", eventName)
|
gwName := headers["Gateway-Name"]
|
||||||
|
if gwName == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Извлекаем trunk_id из имени gateway: "pulse-ingress-trk-X" → "trk-X"
|
||||||
|
prefix := cfg.ESL.GatewayPrefix + "-ingress-"
|
||||||
|
trunkID := strings.TrimPrefix(gwName, prefix)
|
||||||
|
if trunkID == gwName {
|
||||||
|
return // префикс не найден, не наш gateway
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "unknown"
|
||||||
|
switch eventName {
|
||||||
|
case "SOFIA::gateway_register":
|
||||||
|
status = "up"
|
||||||
|
case "SOFIA::gateway_unregister", "SOFIA::gateway_expire":
|
||||||
|
status = "down"
|
||||||
|
}
|
||||||
|
|
||||||
|
apiHandler.BroadcastGatewayEvent(gwName, trunkID, status)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cancel()
|
cancel()
|
||||||
@ -236,6 +294,15 @@ func withSPA(webDir string, apiHandler http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// randomHex генерирует случайную hex-строку заданной длины.
|
||||||
|
func randomHex(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return fmt.Sprintf("%x", time.Now().UnixNano())[:n]
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)[:n]
|
||||||
|
}
|
||||||
|
|
||||||
// extractHostFromGateway извлекает хост из SIP URI.
|
// extractHostFromGateway извлекает хост из SIP URI.
|
||||||
// "sip:mts-gw.lan:5060" → "mts-gw.lan"
|
// "sip:mts-gw.lan:5060" → "mts-gw.lan"
|
||||||
func extractHostFromGateway(gateway string) string {
|
func extractHostFromGateway(gateway string) string {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/pulse-lets-go/internal/config"
|
"github.com/pulse-lets-go/internal/config"
|
||||||
"github.com/pulse-lets-go/internal/engine"
|
"github.com/pulse-lets-go/internal/engine"
|
||||||
"github.com/pulse-lets-go/internal/esl"
|
"github.com/pulse-lets-go/internal/esl"
|
||||||
|
"github.com/pulse-lets-go/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// API — главная структура HTTP API, агрегирует все зависимости.
|
// API — главная структура HTTP API, агрегирует все зависимости.
|
||||||
@ -127,6 +128,19 @@ func corsMiddleware(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BroadcastGatewayEvent транслирует статус gateway всем WS-клиентам.
|
||||||
|
func (a *API) BroadcastGatewayEvent(gatewayName, trunkID, status string) {
|
||||||
|
msg := &models.WsMetricsMessage{
|
||||||
|
Type: "gateway_update",
|
||||||
|
Payload: map[string]string{
|
||||||
|
"gateway": gatewayName,
|
||||||
|
"trunk_id": trunkID,
|
||||||
|
"status": status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a.wsHub.broadcast(msg)
|
||||||
|
}
|
||||||
|
|
||||||
// decodeJSON декодирует тело запроса в структуру v.
|
// decodeJSON декодирует тело запроса в структуру v.
|
||||||
func decodeJSON(r *http.Request, v interface{}) error {
|
func decodeJSON(r *http.Request, v interface{}) error {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|||||||
@ -144,6 +144,14 @@ func (e *Engine) GetNodeHistory(nodeID string) []NodeSnapshot {
|
|||||||
return ring.snapshot()
|
return ring.snapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasNode возвращает true, если нода уже зарегистрирована в engine.
|
||||||
|
func (e *Engine) HasNode(nodeID string) bool {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
_, exists := e.nodes[nodeID]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
// ToggleNode включает/выключает ноду из распределения.
|
// ToggleNode включает/выключает ноду из распределения.
|
||||||
func (e *Engine) ToggleNode(nodeID string, disabled bool, reason string) error {
|
func (e *Engine) ToggleNode(nodeID string, disabled bool, reason string) error {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
|
|||||||
@ -15,6 +15,7 @@ type NodeMetric struct {
|
|||||||
IdleCPU float64 `json:"idle_cpu"` // 0..100 %
|
IdleCPU float64 `json:"idle_cpu"` // 0..100 %
|
||||||
LoadAvg float64 `json:"load_avg"` // system load average (1m)
|
LoadAvg float64 `json:"load_avg"` // system load average (1m)
|
||||||
CallFailureRate float64 `json:"call_failure_rate"` // 0.0..100.0 %
|
CallFailureRate float64 `json:"call_failure_rate"` // 0.0..100.0 %
|
||||||
|
SIPGateway string `json:"sip_gateway,omitempty"` // SIP-адрес ноды для авто-создания транка
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- In-memory состояние ноды в engine ---
|
// --- In-memory состояние ноды в engine ---
|
||||||
|
|||||||
@ -18,11 +18,12 @@ const subject = "pulse.metrics.>"
|
|||||||
|
|
||||||
// Subscriber подписывается на NATS и обновляет engine + пишет лог.
|
// Subscriber подписывается на NATS и обновляет engine + пишет лог.
|
||||||
type Subscriber struct {
|
type Subscriber struct {
|
||||||
nc *natsgo.Conn
|
nc *natsgo.Conn
|
||||||
sub *natsgo.Subscription
|
sub *natsgo.Subscription
|
||||||
engine *engine.Engine
|
engine *engine.Engine
|
||||||
logger *filelog.Logger
|
logger *filelog.Logger
|
||||||
wg sync.WaitGroup
|
onNewNode func(nodeID, sipGateway string) // вызывается при первой метрике от новой ноды
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSubscriber подключается к NATS и запускает подписку.
|
// NewSubscriber подключается к NATS и запускает подписку.
|
||||||
@ -57,6 +58,11 @@ func NewSubscriber(natsURL, user, password string, eng *engine.Engine, l *filelo
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetOnNewNode устанавливает callback, вызываемый при первой метрике от новой ноды.
|
||||||
|
func (s *Subscriber) SetOnNewNode(fn func(nodeID, sipGateway string)) {
|
||||||
|
s.onNewNode = fn
|
||||||
|
}
|
||||||
|
|
||||||
// handleMetric — обработчик входящих сообщений NATS.
|
// handleMetric — обработчик входящих сообщений NATS.
|
||||||
func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
|
func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
|
||||||
var metric models.NodeMetric
|
var metric models.NodeMetric
|
||||||
@ -74,8 +80,16 @@ func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
|
|||||||
metric.MaxCalls = 250 // разумное значение по умолчанию
|
metric.MaxCalls = 250 // разумное значение по умолчанию
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверка: это новая нода с sip_gateway?
|
||||||
|
isNew := !s.engine.HasNode(metric.NodeID)
|
||||||
|
|
||||||
ns := s.engine.UpdateMetric(&metric)
|
ns := s.engine.UpdateMetric(&metric)
|
||||||
s.logger.Log(ns)
|
s.logger.Log(ns)
|
||||||
|
|
||||||
|
// Авто-создание balance-транка для новой ноды
|
||||||
|
if isNew && metric.SIPGateway != "" && s.onNewNode != nil {
|
||||||
|
s.onNewNode(metric.NodeID, metric.SIPGateway)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsConnected возвращает true, если NATS-соединение активно.
|
// IsConnected возвращает true, если NATS-соединение активно.
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { isAuthenticated, getTrunks, createTrunk, updateTrunk, deleteTrunk, type Trunk } from '$lib/api';
|
import { isAuthenticated, getTrunks, createTrunk, updateTrunk, deleteTrunk, connectWebSocket, type Trunk } from '$lib/api';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { Plus, Trash2, Save, X, Cable } from 'lucide-svelte';
|
import { Plus, Trash2, Save, X, Cable } from 'lucide-svelte';
|
||||||
|
|
||||||
let trunks: Trunk[] = [];
|
let trunks: Trunk[] = [];
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
||||||
|
// Gateway статусы с ESLa (up/down/unknown)
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let gatewayStatuses: Record<string, string> = {};
|
||||||
|
|
||||||
// Edit mode
|
// Edit mode
|
||||||
let editing: string | null = null; // trunk ID being edited
|
let editing: string | null = null; // trunk ID being edited
|
||||||
let editData: Partial<Trunk> = {};
|
let editData: Partial<Trunk> = {};
|
||||||
@ -24,7 +28,19 @@
|
|||||||
|
|
||||||
if (browser && !isAuthenticated()) { goto('/login'); }
|
if (browser && !isAuthenticated()) { goto('/login'); }
|
||||||
|
|
||||||
onMount(loadTrunks);
|
onMount(() => {
|
||||||
|
loadTrunks();
|
||||||
|
ws = connectWebSocket((msg) => {
|
||||||
|
if (msg.type === 'gateway_update') {
|
||||||
|
const { trunk_id, status } = msg.payload;
|
||||||
|
gatewayStatuses = { ...gatewayStatuses, [trunk_id]: status };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
ws?.close();
|
||||||
|
});
|
||||||
|
|
||||||
async function loadTrunks() {
|
async function loadTrunks() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@ -102,6 +118,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gatewayIndicator(trunkID: string): string {
|
||||||
|
const s = gatewayStatuses[trunkID] || 'unknown';
|
||||||
|
if (s === 'up') return '🟢';
|
||||||
|
if (s === 'down') return '🔴';
|
||||||
|
return '⚪';
|
||||||
|
}
|
||||||
|
|
||||||
function onFilterChange(e: Event) {
|
function onFilterChange(e: Event) {
|
||||||
typeFilter = (e.target as HTMLSelectElement).value;
|
typeFilter = (e.target as HTMLSelectElement).value;
|
||||||
loadTrunks();
|
loadTrunks();
|
||||||
@ -208,7 +231,11 @@
|
|||||||
<tr class="border-t hover:bg-secondary/50">
|
<tr class="border-t hover:bg-secondary/50">
|
||||||
<td class="px-4 py-3 font-medium">{t.name}</td>
|
<td class="px-4 py-3 font-medium">{t.name}</td>
|
||||||
<td class="px-4 py-3"><span class="px-2 py-1 rounded text-xs font-medium {typeBadge(t.type)}">{typeLabel(t.type)}</span></td>
|
<td class="px-4 py-3"><span class="px-2 py-1 rounded text-xs font-medium {typeBadge(t.type)}">{typeLabel(t.type)}</span></td>
|
||||||
<td class="px-4 py-3 font-mono text-xs">{t.gateway}</td>
|
<td class="px-4 py-3 font-mono text-xs">
|
||||||
|
{t.gateway}
|
||||||
|
{#if t.type === 'ingress'}
|
||||||
|
<span class="ml-2" title="Gateway: {gatewayStatuses[t.id] || 'unknown'}">{gatewayIndicator(t.id)}</span>
|
||||||
|
{/if}
|
||||||
<td class="px-4 py-3 font-mono text-xs">{t.node_id || '—'}</td>
|
<td class="px-4 py-3 font-mono text-xs">{t.node_id || '—'}</td>
|
||||||
<td class="px-4 py-3">{t.enabled ? '🟢 Вкл' : '🔴 Выкл'}</td>
|
<td class="px-4 py-3">{t.enabled ? '🟢 Вкл' : '🔴 Выкл'}</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user