NodeView carried only the category counts, so the nodes page chips could hover to nothing but the category title. GetAll/recountByGuid now emit online/active/disabled/depleted email lists from the same bucketing pass as the counts (statuses gain the client email; the online list is the counted set), and the chips pop the list over with the inbounds page's popover pattern.
271 lines
8.1 KiB
Go
271 lines
8.1 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
|
)
|
|
|
|
// LocalDescendants returns this panel's read-only summaries of the nodes it
|
|
// directly manages, so a parent panel can surface them as transitive sub-nodes
|
|
// (#4983). Only nodes with a known GUID are included — a stable identity is
|
|
// required to attribute them one hop up. Not recursive: each panel reports its
|
|
// own direct nodes, and a master walks one level via each direct node's
|
|
// endpoint, which covers the Node1 -> Node2 -> Node3 case.
|
|
func (s *NodeService) LocalDescendants() ([]model.NodeSummary, error) {
|
|
selfGuid, _ := (&SettingService{}).GetPanelGuid()
|
|
db := database.GetDB()
|
|
var nodes []*model.Node
|
|
if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.NodeSummary, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
if n.Guid == "" {
|
|
continue
|
|
}
|
|
out = append(out, model.NodeSummary{
|
|
Guid: n.Guid,
|
|
ParentGuid: selfGuid,
|
|
Name: n.Name,
|
|
Address: n.Address,
|
|
Scheme: n.Scheme,
|
|
Port: n.Port,
|
|
Status: n.Status,
|
|
LastHeartbeat: n.LastHeartbeat,
|
|
LatencyMs: n.LatencyMs,
|
|
PanelVersion: n.PanelVersion,
|
|
XrayVersion: n.XrayVersion,
|
|
XrayState: n.XrayState,
|
|
XrayError: n.XrayError,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
var (
|
|
nodeDescendantsMu sync.RWMutex
|
|
nodeDescendantsCache = map[int][]model.NodeSummary{}
|
|
)
|
|
|
|
// RefreshDescendants pulls a direct node's published sub-node summaries and
|
|
// caches them keyed by node id. Best-effort: a fetch error keeps the last good
|
|
// set (the node may be briefly unreachable). Called from the heartbeat job.
|
|
func (s *NodeService) RefreshDescendants(ctx context.Context, n *model.Node) {
|
|
if n == nil {
|
|
return
|
|
}
|
|
mgr := runtime.GetManager()
|
|
if mgr == nil {
|
|
return
|
|
}
|
|
rt, err := mgr.RemoteFor(n)
|
|
if err != nil {
|
|
return
|
|
}
|
|
summaries, err := rt.GetDescendants(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
nodeDescendantsMu.Lock()
|
|
if len(summaries) == 0 {
|
|
delete(nodeDescendantsCache, n.Id)
|
|
} else {
|
|
nodeDescendantsCache[n.Id] = summaries
|
|
}
|
|
nodeDescendantsMu.Unlock()
|
|
}
|
|
|
|
// ClearDescendants drops a node's cached sub-node summaries (its probe failed).
|
|
func (s *NodeService) ClearDescendants(nodeID int) {
|
|
nodeDescendantsMu.Lock()
|
|
delete(nodeDescendantsCache, nodeID)
|
|
nodeDescendantsMu.Unlock()
|
|
}
|
|
|
|
func cachedDescendants() []model.NodeSummary {
|
|
nodeDescendantsMu.RLock()
|
|
defer nodeDescendantsMu.RUnlock()
|
|
out := make([]model.NodeSummary, 0)
|
|
for _, list := range nodeDescendantsCache {
|
|
out = append(out, list...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetNodeTree returns the direct nodes plus any transitive sub-nodes learned
|
|
// from them, with per-GUID counts so each node shows only the inbounds/online
|
|
// it physically hosts (#4983). Direct nodes carry the master's own GUID as
|
|
// ParentGuid; a transitive node carries its parent node's GUID. Transitive
|
|
// nodes are read-only projections (Id == 0). Used by the Nodes page and the
|
|
// heartbeat broadcast — never for probing/syncing, which stay on GetAll.
|
|
func (s *NodeService) GetNodeTree() ([]*model.Node, error) {
|
|
nodes, err := s.GetAll()
|
|
if err != nil {
|
|
return nodes, err
|
|
}
|
|
selfGuid, _ := (&SettingService{}).GetPanelGuid()
|
|
directGuids := make(map[string]struct{}, len(nodes))
|
|
for _, n := range nodes {
|
|
n.ParentGuid = selfGuid
|
|
if n.Guid != "" {
|
|
directGuids[n.Guid] = struct{}{}
|
|
}
|
|
}
|
|
|
|
seen := make(map[string]struct{})
|
|
var transitive []*model.Node
|
|
for _, sum := range cachedDescendants() {
|
|
if sum.Guid == "" {
|
|
continue
|
|
}
|
|
if _, ok := directGuids[sum.Guid]; ok {
|
|
continue // already shown as a direct node
|
|
}
|
|
if _, ok := seen[sum.Guid]; ok {
|
|
continue
|
|
}
|
|
seen[sum.Guid] = struct{}{}
|
|
transitive = append(transitive, &model.Node{
|
|
Guid: sum.Guid,
|
|
ParentGuid: sum.ParentGuid,
|
|
Name: sum.Name,
|
|
Address: sum.Address,
|
|
Scheme: sum.Scheme,
|
|
Port: sum.Port,
|
|
Status: sum.Status,
|
|
LastHeartbeat: sum.LastHeartbeat,
|
|
LatencyMs: sum.LatencyMs,
|
|
PanelVersion: sum.PanelVersion,
|
|
XrayVersion: sum.XrayVersion,
|
|
XrayState: sum.XrayState,
|
|
XrayError: sum.XrayError,
|
|
Transitive: true,
|
|
})
|
|
}
|
|
if len(transitive) == 0 {
|
|
return nodes, nil
|
|
}
|
|
|
|
all := make([]*model.Node, 0, len(nodes)+len(transitive))
|
|
all = append(all, nodes...)
|
|
all = append(all, transitive...)
|
|
s.recountByGuid(all, selfGuid)
|
|
return all, nil
|
|
}
|
|
|
|
func (s *NodeService) GetNodeTreeView() ([]*NodeView, error) {
|
|
nodes, err := s.GetNodeTree()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
views := toNodeViews(nodes)
|
|
annotateNodeTypes(views)
|
|
annotateAgentTelemetry(views)
|
|
return views, nil
|
|
}
|
|
|
|
// recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node
|
|
// in the tree, keyed by the GUID that physically hosts each inbound, so a direct
|
|
// node shows only its own inbounds and each transitive node shows its own
|
|
// (#4983). In a flat topology the per-GUID and per-node-id counts coincide, so
|
|
// this only changes behaviour once a transitive node exists.
|
|
func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
|
|
db := database.GetDB()
|
|
type ibRow struct {
|
|
Id int
|
|
NodeID *int `gorm:"column:node_id"`
|
|
OriginNodeGuid string `gorm:"column:origin_node_guid"`
|
|
}
|
|
var ibRows []ibRow
|
|
if err := db.Table("inbounds").Select("id, node_id, origin_node_guid").Scan(&ibRows).Error; err != nil {
|
|
return
|
|
}
|
|
ambiguous := ambiguousNodeGuids(nodes, selfGuid)
|
|
effByInbound := make(map[int]string, len(ibRows))
|
|
inboundCountByGuid := make(map[string]int)
|
|
for _, r := range ibRows {
|
|
guid := r.OriginNodeGuid
|
|
if guid == "" {
|
|
if r.NodeID != nil {
|
|
guid = synthNodeGuid(*r.NodeID)
|
|
} else {
|
|
guid = selfGuid
|
|
}
|
|
} else if r.NodeID != nil {
|
|
// Origin still holds an ambiguous GUID (cloned server / master-shared,
|
|
// not yet re-attributed): bucket under the hosting node's unique id so
|
|
// the clones don't merge.
|
|
if _, bad := ambiguous[guid]; bad {
|
|
guid = synthNodeGuid(*r.NodeID)
|
|
}
|
|
}
|
|
effByInbound[r.Id] = guid
|
|
inboundCountByGuid[guid]++
|
|
}
|
|
|
|
// Classify by EMAIL (not the stale client_traffics.inbound_id) and bucket
|
|
// each client under its inbound's effective attribution GUID, deduping a
|
|
// client attached to several inbounds under the same GUID. The email
|
|
// lists are appended in the same pass so they stay consistent with the
|
|
// recounted tallies they annotate.
|
|
depletedByGuid := make(map[string]int)
|
|
disabledByGuid := make(map[string]int)
|
|
activeByGuid := make(map[string]int)
|
|
depletedEmailsByGuid := make(map[string][]string)
|
|
disabledEmailsByGuid := make(map[string][]string)
|
|
activeEmailsByGuid := make(map[string][]string)
|
|
if statuses, err := s.nodeClientStatuses(); err == nil {
|
|
seen := make(map[string]map[int]struct{})
|
|
for _, st := range statuses {
|
|
guid, ok := effByInbound[st.InboundID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
clientsSeen := seen[guid]
|
|
if clientsSeen == nil {
|
|
clientsSeen = make(map[int]struct{})
|
|
seen[guid] = clientsSeen
|
|
}
|
|
if _, dup := clientsSeen[st.ClientID]; dup {
|
|
continue
|
|
}
|
|
clientsSeen[st.ClientID] = struct{}{}
|
|
switch {
|
|
case st.Depleted:
|
|
depletedByGuid[guid]++
|
|
if st.Email != "" {
|
|
depletedEmailsByGuid[guid] = append(depletedEmailsByGuid[guid], st.Email)
|
|
}
|
|
case st.Disabled:
|
|
disabledByGuid[guid]++
|
|
if st.Email != "" {
|
|
disabledEmailsByGuid[guid] = append(disabledEmailsByGuid[guid], st.Email)
|
|
}
|
|
default:
|
|
activeByGuid[guid]++
|
|
if st.Email != "" {
|
|
activeEmailsByGuid[guid] = append(activeEmailsByGuid[guid], st.Email)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
onlineByGuid := s.onlineEmailsByGuid()
|
|
for _, n := range nodes {
|
|
guid := effectiveNodeGuid(n, ambiguous)
|
|
n.InboundCount = inboundCountByGuid[guid]
|
|
n.OnlineCount = len(onlineByGuid[guid])
|
|
n.OnlineEmails = sortedEmailSet(onlineByGuid[guid])
|
|
n.DepletedCount = depletedByGuid[guid]
|
|
n.DisabledCount = disabledByGuid[guid]
|
|
n.ActiveCount = activeByGuid[guid]
|
|
n.DepletedEmails = depletedEmailsByGuid[guid]
|
|
n.DisabledEmails = disabledEmailsByGuid[guid]
|
|
n.ActiveEmails = activeEmailsByGuid[guid]
|
|
}
|
|
}
|