fix(nodes): per-category client email lists behind the node count chips

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.
This commit is contained in:
Muse
2026-10-22 15:33:46 +00:00
parent e310abcba0
commit c91fb454a8
9 changed files with 376 additions and 45 deletions
+47 -11
View File
@@ -173,6 +173,12 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
depletedByNode := make(map[int]int)
disabledByNode := make(map[int]int)
activeByNode := make(map[int]int)
// Email lists ride along with the counts, appended in the same pass and
// under the same dedupe, so a category's hover list always names exactly
// the clients its count tallied.
depletedEmailsByNode := make(map[int][]string)
disabledEmailsByNode := make(map[int][]string)
activeEmailsByNode := make(map[int][]string)
statuses, _ := s.nodeClientStatuses()
seen := make(map[int]map[int]struct{}, len(nodes))
for _, st := range statuses {
@@ -190,10 +196,19 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
switch {
case st.Depleted:
depletedByNode[st.NodeID]++
if st.Email != "" {
depletedEmailsByNode[st.NodeID] = append(depletedEmailsByNode[st.NodeID], st.Email)
}
case st.Disabled:
disabledByNode[st.NodeID]++
if st.Email != "" {
disabledEmailsByNode[st.NodeID] = append(disabledEmailsByNode[st.NodeID], st.Email)
}
default:
activeByNode[st.NodeID]++
if st.Email != "" {
activeEmailsByNode[st.NodeID] = append(activeEmailsByNode[st.NodeID], st.Email)
}
}
}
onlineByGuid := s.onlineEmailsByGuid()
@@ -204,10 +219,15 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
n.DepletedCount = depletedByNode[n.Id]
n.DisabledCount = disabledByNode[n.Id]
n.ActiveCount = activeByNode[n.Id]
n.DepletedEmails = depletedEmailsByNode[n.Id]
n.DisabledEmails = disabledEmailsByNode[n.Id]
n.ActiveEmails = activeEmailsByNode[n.Id]
// Online is attributed to the node that physically hosts the client
// (by GUID): a client on a sub-node counts under the sub-node, not
// the intermediate node it syncs through (#4983).
n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n, ambiguous)])
onlineSet := onlineByGuid[effectiveNodeGuid(n, ambiguous)]
n.OnlineCount = len(onlineSet)
n.OnlineEmails = sortedEmailSet(onlineSet)
}
return nodes, nil
@@ -219,6 +239,7 @@ type nodeClientStatus struct {
InboundID int
NodeID int
ClientID int
Email string
Depleted bool
Disabled bool
}
@@ -232,19 +253,20 @@ type nodeClientStatus struct {
// depleted (expired/exhausted) wins over disabled.
func (s *NodeService) nodeClientStatuses() ([]nodeClientStatus, error) {
type row struct {
InboundID int `gorm:"column:inbound_id"`
NodeID int `gorm:"column:node_id"`
ClientID int `gorm:"column:client_id"`
Enable bool `gorm:"column:enable"`
Total int64 `gorm:"column:total"`
Up int64 `gorm:"column:up"`
Down int64 `gorm:"column:down"`
ExpiryTime int64 `gorm:"column:expiry_time"`
InboundID int `gorm:"column:inbound_id"`
NodeID int `gorm:"column:node_id"`
ClientID int `gorm:"column:client_id"`
Email string `gorm:"column:email"`
Enable bool `gorm:"column:enable"`
Total int64 `gorm:"column:total"`
Up int64 `gorm:"column:up"`
Down int64 `gorm:"column:down"`
ExpiryTime int64 `gorm:"column:expiry_time"`
}
var rows []row
if err := database.GetDB().Table("inbounds").
Select("inbounds.id AS inbound_id, inbounds.node_id AS node_id, clients.id AS client_id, " +
"clients.enable AS enable, ct.total AS total, ct.up AS up, ct.down AS down, ct.expiry_time AS expiry_time").
"clients.email AS email, clients.enable AS enable, ct.total AS total, ct.up AS up, ct.down AS down, ct.expiry_time AS expiry_time").
Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Joins("LEFT JOIN client_traffics ct ON ct.email = clients.email").
@@ -255,7 +277,7 @@ func (s *NodeService) nodeClientStatuses() ([]nodeClientStatus, error) {
now := time.Now().UnixMilli()
out := make([]nodeClientStatus, 0, len(rows))
for _, r := range rows {
st := nodeClientStatus{InboundID: r.InboundID, NodeID: r.NodeID, ClientID: r.ClientID}
st := nodeClientStatus{InboundID: r.InboundID, NodeID: r.NodeID, ClientID: r.ClientID, Email: r.Email}
expired := r.ExpiryTime > 0 && r.ExpiryTime <= now
exhausted := r.Total > 0 && r.Up+r.Down >= r.Total
switch {
@@ -283,6 +305,20 @@ func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
return out
}
// sortedEmailSet flattens an email set into a deterministic list for the
// node views' hover popovers. Nil for an empty set.
func sortedEmailSet(set map[string]struct{}) []string {
if len(set) == 0 {
return nil
}
out := make([]string, 0, len(set))
for email := range set {
out = append(out, email)
}
slices.Sort(out)
return out
}
// effectiveNodeGuid is a node's stable online/inbound attribution key: its
// reported panelGuid, or a master-local synthetic node-id fallback when the node
// has no GUID yet (old build) or its GUID is ambiguous. ambiguous comes from