Files
reflector-dashboard/svxdr/nodelist/monitor_nodes.go
T

446 lines
15 KiB
Go

// svxdr — live-data daemon for SvxPortal (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
package nodelist
// Monitor Nodes, the simple way: read ONE reflector /status endpoint that
// already carries the whole picture (local nodes + every trunk with its nodes)
// and reshape it into {local, trunks:[{name,host,nodes}]}. No recursive crawl.
//
// Which URL is read is per-box config MONITOR_STATUS_URL (falls back to
// REFLECTOR_STATUS_URL):
// hub box → https://<master-reflector>/geu/status/ (the master's full view)
// bridge box → http://127.0.0.1:8080/status (its own reflector)
import (
"encoding/json"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"svxportal/svxdr/statusnorm"
)
var monitorStatusURL = env("MONITOR_STATUS_URL", reflectorURL)
// monitorInterval is how often REMOTE status reads (a Monitor->Nodes URL or a
// trunk_status topology URL that points at another server) run. Gentle by default
// so a shared hub is never hammered; override via svxdr.json nodelist.monitor_interval.
// A LOCAL read (127.0.0.1) is free and refreshes fast — see localReadInterval.
var monitorInterval = 60 * time.Second
// localReadInterval: reading our OWN reflector costs nothing, so do it often.
var localReadInterval = 5 * time.Second
// isLocalURL reports whether a status URL points at this host (loopback). Local
// reads may be frequent; remote reads must be throttled to monitorInterval.
func isLocalURL(u string) bool {
return strings.Contains(u, "127.0.0.1") || strings.Contains(u, "localhost") || strings.Contains(u, "[::1]")
}
// readInterval picks the fast (local) or gentle (remote) cadence for a URL.
func readInterval(u string) time.Duration {
if isLocalURL(u) {
return localReadInterval
}
return monitorInterval
}
type monNode struct {
Callsign string `json:"callsign"`
TG string `json:"tg,omitempty"`
NodeClass string `json:"nodeClass,omitempty"`
Mode string `json:"mode,omitempty"`
Talker bool `json:"talker,omitempty"`
}
// uniquifyNames makes every node name in the view unique WITHOUT ever touching the
// Local (top-level) nodes — those are the reflector's own map keys, exactly what the
// Reflector clients view shows, so renaming them would make the two views disagree.
// Only trunk/satellite array nodes (which have no key and never appear in Reflector
// clients) are numbered, and only enough to stay clear of every name already taken
// (Local keys + earlier array nodes): SA7AUX -> SA7AUX-1, SA7AUX-2, …
func uniquifyNames(out *monStatus) {
taken := map[string]bool{}
for _, n := range out.Local { // canonical, never renamed
taken[n.Callsign] = true
}
next := map[string]int{}
for i := range out.Trunks {
for j := range out.Trunks[i].Nodes {
base := out.Trunks[i].Nodes[j].Callsign
if !taken[base] {
taken[base] = true
continue
}
for {
next[base]++
cand := base + "-" + strconv.Itoa(next[base])
if !taken[cand] {
taken[cand] = true
out.Trunks[i].Nodes[j].Callsign = cand
break
}
}
}
}
}
type monTrunk struct {
Name string `json:"name"`
Host string `json:"host,omitempty"`
Connected bool `json:"connected"`
Nodes []monNode `json:"nodes"`
}
type monStatus struct {
Local []monNode `json:"local"`
Trunks []monTrunk `json:"trunks"`
Source string `json:"source"`
UpdatedAt string `json:"updated_at"`
}
var monState = struct {
mu sync.RWMutex
data monStatus
}{}
// rawNode is one node as it appears either as a value in the top-level "nodes"
// object (keyed by callsign) or as an item in a trunk's "nodes" array.
type rawNode struct {
Callsign string `json:"callsign"`
SW string `json:"sw"`
TG json.Number `json:"tg"`
NodeClass string `json:"nodeClass"`
RestrictedTG bool `json:"restrictedTG"`
IsTalker bool `json:"isTalker"`
System string `json:"system"` // per-node link/type from the data (e.g. "DMR", "DMR/YSF", "DMR/Hytera")
Reflector string `json:"Reflector"` // which reflector this node is on (master status carries it)
MonitoredTGs []json.Number `json:"monitoredTGs"`
Links []struct {
LocalTg json.Number `json:"localTg"`
} `json:"links"`
}
// masterReflectorName picks a label for the monitored master's own nodes purely
// from the DATA (the Reflector field the nodes report) — no dependency on the
// local conf format, so every box derives it the same way.
func masterReflectorName(nodes map[string]json.RawMessage) string {
count := map[string]int{}
for _, raw := range nodes {
var r struct {
Reflector string `json:"Reflector"`
}
if json.Unmarshal(raw, &r) == nil && strings.TrimSpace(r.Reflector) != "" {
count[r.Reflector]++
}
}
best, n := "", 0
for k, c := range count {
if c > n {
best, n = k, c
}
}
return best
}
// monModeOf: real type from the software string (svx nodes carry no "mode").
func monModeOf(sw string) string {
s := strings.ToLower(sw)
switch {
case strings.Contains(s, "chan_pbx"):
return "PBX"
case strings.Contains(s, "chan_svx_sat"):
return "AllStar"
case strings.Contains(s, "chan_svx"):
return "AllStar"
case strings.Contains(s, "svxlink"):
return "SVX"
case strings.Contains(s, "mmdvm"), strings.Contains(s, "brandmeister"), strings.Contains(s, "dmr"):
return "DMR"
case strings.Contains(s, "ysf"):
return "YSF"
case strings.Contains(s, "nxdn"):
return "NXDN"
case strings.Contains(s, "m17"):
return "M17"
case sw != "":
return sw
default:
return ""
}
}
// A bridge node just links systems together (chan_svx asterisk, a DMR/trunk
// bridge, or an -AC/-DMR/-T suffix) — not a real user/radio client, so we never
// show it. chan_svx_sat IS a real AllStar node over the satellite trunk and is
// kept. Mirrors the TG Monitor's isBridge so both views show only real users.
var bridgeSuffix = regexp.MustCompile(`(?i)-(AC|DMR|T)$`)
func isBridgeNode(callsign, sw, nodeClass string) bool {
s := strings.ToLower(sw)
// AllStar radio-network peers ARE shown — the links to svx/dmr/ysf/nxdn/m17
// are exactly what the user wants visible under their node/trunk. chan_svx and
// chan_svx_sat both count. Only pure non-radio infrastructure stays hidden.
if strings.Contains(s, "chan_svx") || strings.Contains(s, "chan_pbx") { // chan_svx + chan_svx_sat + chan_pbx_svx (PBX)
return false
}
nc := strings.ToLower(nodeClass)
switch nc {
case "dmr", "ysf", "nxdn", "m17":
return false // radio-network peer — show
case "xlx", "mumble", "stream", "echolink", "sip", "zello":
return true // non-radio / D-Star infra — hide
}
// sw-based radio bridges (DMR_/YSF_/NXDN_/M17_Bridge) → show; the remaining
// "*bridge" (XLX_/Mumble_/Janus Bridge) is non-radio infra → hide.
if strings.Contains(s, "dmr") || strings.Contains(s, "ysf") ||
strings.Contains(s, "nxdn") || strings.Contains(s, "m17") {
return false
}
if strings.Contains(s, "bridge") {
return true
}
// pure infrastructure gateway callsigns are never real peers.
u := strings.ToUpper(callsign)
if strings.HasPrefix(u, "SIP") || strings.HasPrefix(u, "DCS") ||
strings.HasPrefix(u, "XLX") || strings.Contains(u, "MUMBLE") {
return true
}
return false // default: show (real users + radio peers)
}
func toMonNode(callsign string, raw json.RawMessage) (monNode, bool) {
var r rawNode
_ = json.Unmarshal(raw, &r)
// Display name = the node's KEY (its registered identity — exactly what the
// Reflector clients view shows via the /status map key), so the two views can
// never disagree on a name. Trunk/satellite array nodes have no key -> use the
// callsign field (which statusnorm may have set to the per-TG group name).
cs := callsign
if cs == "" {
cs = r.Callsign
}
// A configured node (in nodes[]) is an intentional own node — always show it,
// even if its nodeClass would otherwise read as infra (e.g. a DMR gateway as
// "Stream"). Everything else keeps the bridge/infra filter.
if !isConfiguredName(cs) && isBridgeNode(cs, r.SW, r.NodeClass) {
return monNode{}, false // bridge, not a real user — hide it
}
// TG: the node's own tg, else its first monitored/linked TG.
tg := ""
if s := r.TG.String(); s != "" && s != "0" {
tg = s
} else if len(r.MonitoredTGs) > 0 {
tg = r.MonitoredTGs[0].String()
} else if len(r.Links) > 0 {
tg = r.Links[0].LocalTg.String()
}
if r.RestrictedTG && tg != "" {
tg = "*" + tg
}
// Per-TG name: several sat nodes share ONE reflector login (KT4ROY-ASL), so
// under a trunk they'd all show that same callsign. If svxdr.json configures a
// per-TG via for this node's TG, show THAT (KT4ROY-ASL1..4) — the trunk's own
// name is unaffected. One place (svxdr.json) drives it.
// Type/mode, in priority order (no hardcoding of any mode):
// 1) config nodes[].mode (explicit override, keyed by display name)
// 2) the node's own "system" field from the data (DMR, DMR/YSF, DMR/Hytera…)
// 3) type derived from the software string
mode := modeForName(cs)
if mode == "" {
mode = r.System
}
if mode == "" {
mode = monModeOf(r.SW)
}
return monNode{Callsign: cs, TG: tg, NodeClass: r.NodeClass, Mode: mode, Talker: r.IsTalker}, true
}
// ownLocalNodes reads OUR OWN reflector (reflectorURL) and returns its top-level
// nodes — the ones genuinely local to THIS server. Used to keep Local accurate
// when MONITOR_STATUS_URL points at a foreign master.
func ownLocalNodes(client *http.Client) ([]monNode, bool) {
resp, err := client.Get(reflectorURL)
if err != nil {
return nil, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, false
}
var raw struct {
Nodes map[string]json.RawMessage `json:"nodes"`
}
if json.NewDecoder(resp.Body).Decode(&raw) != nil {
return nil, false
}
var out []monNode
for cs, rn := range raw.Nodes {
if mn, ok := toMonNode(cs, rn); ok {
out = append(out, mn)
}
}
return out, true
}
func buildMonitor(client *http.Client, generated string) (monStatus, bool) {
resp, err := client.Get(monitorStatusURL)
if err != nil {
return monStatus{}, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return monStatus{}, false
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return monStatus{}, false
}
// Normalize at ingest: sat-login node callsigns -> per-TG name. Covers the
// case where monitorStatusURL is a foreign master (e.g. a hub box reads a foreign master
// where its satellite nodes federate in). A local read via the proxy is already
// normalized; re-normalizing is a harmless no-op.
body = statusnorm.Normalize(body, renameSatNode)
var raw struct {
Nodes map[string]json.RawMessage `json:"nodes"`
Trunks map[string]struct {
Host string `json:"host"`
Connected bool `json:"connected"`
Nodes []json.RawMessage `json:"nodes"`
} `json:"trunks"`
// Satellite trunks (chan_svx_sat): each id (e.g. KT4ROY-ASL) carries N
// AllStar nodes. They live here, NOT in top-level nodes, so Monitor→Nodes
// must read them too or the sat nodes are invisible.
Satellites map[string]struct {
Authenticated bool `json:"authenticated"`
Nodes []json.RawMessage `json:"nodes"`
} `json:"satellites"`
}
if json.Unmarshal(body, &raw) != nil {
return monStatus{}, false
}
out := monStatus{Source: monitorStatusURL, UpdatedAt: generated}
// Local = only OUR reflector's own nodes. Whenever MONITOR_STATUS_URL is a
// REMOTE master (not our own reflector), ALWAYS split: read our OWN reflector for
// the local set and put the master's own top-level nodes under a section instead.
// This must behave the SAME on every box regardless of its svxreflector.conf
// dialect — so the split is decided purely by "is the master remote", and the
// section label is derived from the DATA (masterReflectorName) when the conf
// doesn't name the uplink trunk. No per-box special cases.
doSplit := reflectorURL != monitorStatusURL
masterLabel := ""
if doSplit {
if masterLabel = masterReflectorName(raw.Nodes); masterLabel == "" {
masterLabel = "uplink"
}
}
selfCalls := map[string]bool{}
if doSplit {
if selfNodes, ok := ownLocalNodes(client); ok {
for _, mn := range selfNodes {
out.Local = append(out.Local, mn)
selfCalls[strings.ToUpper(mn.Callsign)] = true
}
}
}
var foreignTop []monNode
for cs, rn := range raw.Nodes {
mn, ok := toMonNode(cs, rn)
if !ok {
continue
}
if !doSplit {
out.Local = append(out.Local, mn)
} else if !selfCalls[strings.ToUpper(mn.Callsign)] {
foreignTop = append(foreignTop, mn) // the master's own node → its trunk section
}
}
sort.Slice(out.Local, func(i, j int) bool { return out.Local[i].Callsign < out.Local[j].Callsign })
// The master's own nodes become one trunk section (we reach it over a trunk).
if len(foreignTop) > 0 {
sort.Slice(foreignTop, func(i, j int) bool { return foreignTop[i].Callsign < foreignTop[j].Callsign })
out.Trunks = append(out.Trunks, monTrunk{Name: masterLabel, Connected: true, Nodes: foreignTop})
}
for name, t := range raw.Trunks {
mt := monTrunk{Name: name, Host: t.Host, Connected: t.Connected}
for _, rn := range t.Nodes {
if mn, ok := toMonNode("", rn); ok {
mt.Nodes = append(mt.Nodes, mn)
}
}
sort.Slice(mt.Nodes, func(i, j int) bool { return mt.Nodes[i].Callsign < mt.Nodes[j].Callsign })
out.Trunks = append(out.Trunks, mt)
}
// Satellite trunks: one section per satellite id (KT4ROY-ASL), its AllStar
// nodes (KT4ROY-ASL1..4 via toMonNode's per-TG rename) shown under it.
for id, s := range raw.Satellites {
mt := monTrunk{Name: id, Connected: s.Authenticated}
for _, rn := range s.Nodes {
if mn, ok := toMonNode("", rn); ok {
mt.Nodes = append(mt.Nodes, mn)
}
}
sort.Slice(mt.Nodes, func(i, j int) bool { return mt.Nodes[i].Callsign < mt.Nodes[j].Callsign })
out.Trunks = append(out.Trunks, mt)
}
// Connected trunks first, then by name — stable, tidy sections.
sort.Slice(out.Trunks, func(i, j int) bool {
if out.Trunks[i].Connected != out.Trunks[j].Connected {
return out.Trunks[i].Connected
}
return out.Trunks[i].Name < out.Trunks[j].Name
})
// Make every node name unique across the whole view (backend, before the web).
uniquifyNames(&out)
return out, true
}
func monitorNodesStart(mux *http.ServeMux) {
client := &http.Client{Timeout: 12 * time.Second}
refresh := func() {
// stamp with a wall-clock time computed off the request (Date.now is
// unavailable in workflow scripts, but this is normal server code).
if next, ok := buildMonitor(client, time.Now().UTC().Format(time.RFC3339)); ok {
monState.mu.Lock()
monState.data = next
monState.mu.Unlock()
}
}
refresh()
go func() {
// Local reflector reads are free (fast); a remote master URL is throttled.
t := time.NewTicker(readInterval(monitorStatusURL))
defer t.Stop()
for range t.C {
refresh()
}
}()
mux.HandleFunc("/api/monitor-nodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
monState.mu.RLock()
b, _ := json.Marshal(monState.data)
monState.mu.RUnlock()
writeMaybeGzip(w, r, b)
})
}