diff --git a/frontend/src/pages/nodes/NodeList.css b/frontend/src/pages/nodes/NodeList.css index c04d90e..e6cbba0 100644 --- a/frontend/src/pages/nodes/NodeList.css +++ b/frontend/src/pages/nodes/NodeList.css @@ -127,6 +127,17 @@ margin: 0; } +/* Category chip hover list (same values as the inbounds page). */ +.client-email-list { + max-height: 200px; + overflow-y: auto; + min-width: 150px; +} + +.client-email-list > div { + padding: 2px 0; +} + .card-history { margin-top: 4px; padding-top: 8px; diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index d44e0db..c50e88c 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -1,6 +1,18 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Badge, Button, Card, Dropdown, Modal, Space, Switch, Table, Tag, Tooltip } from 'antd'; +import { + Badge, + Button, + Card, + Dropdown, + Modal, + Popover, + Space, + Switch, + Table, + Tag, + Tooltip, +} from 'antd'; import type { BadgeProps } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { @@ -48,6 +60,45 @@ function isUpdateEligible(n: NodeRecord): boolean { return !!n.enable && n.status === 'online'; } +// One category chip of the nodes table's clients cell. When the backend +// sent the category's email list, hovering pops the list over (same +// pattern as the inbounds page); without a list it keeps the plain +// category tooltip. Chip styling is identical either way. +function CategoryChip({ + title, + emails, + color, + count, +}: { + title: string; + emails?: string[]; + color?: string; + count: number; +}) { + const tag = ( + + {count} + + ); + if (emails && emails.length > 0) { + return ( + + {emails.map((e) => ( +
{e}
+ ))} + + } + > + {tag} +
+ ); + } + return {tag}; +} + interface NodeRow extends NodeRecord { url: string; key: string | number; @@ -495,44 +546,35 @@ export default function NodeList({ {record.clientCount || 0} {record.activeCount ? ( - - - {record.activeCount} - - + ) : null} {record.disabledCount ? ( - - - {record.disabledCount} - - + ) : null} {record.depletedCount ? ( - - - {record.depletedCount} - - + ) : null} {record.onlineCount ? ( - - - {record.onlineCount} - - + ) : null} ), diff --git a/frontend/src/schemas/node.ts b/frontend/src/schemas/node.ts index 76954cd..96dd52a 100644 --- a/frontend/src/schemas/node.ts +++ b/frontend/src/schemas/node.ts @@ -25,6 +25,12 @@ export const NodeRecordSchema = z activeCount: z.number().optional(), disabledCount: z.number().optional(), depletedCount: z.number().optional(), + // Per-category client email lists behind the counts above; the nodes + // page category chips pop these over on hover. + onlineEmails: z.array(z.string()).optional(), + activeEmails: z.array(z.string()).optional(), + disabledEmails: z.array(z.string()).optional(), + depletedEmails: z.array(z.string()).optional(), lastHeartbeat: z.number().optional(), lastError: z.string().optional(), // Xray state captured from the remote node's own /panel/api/server/status. diff --git a/frontend/src/test/node-list-category-chips.test.tsx b/frontend/src/test/node-list-category-chips.test.tsx new file mode 100644 index 0000000..f718bf0 --- /dev/null +++ b/frontend/src/test/node-list-category-chips.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { fireEvent, waitFor, within } from '@testing-library/react'; + +import NodeList from '@/pages/nodes/NodeList'; +import type { NodeRecord } from '@/schemas/node'; + +import { renderWithProviders } from './test-utils'; + +const noop = () => {}; + +function renderNodes(nodes: NodeRecord[]) { + return renderWithProviders( + , + ); +} + +function rowFor(container: HTMLElement, name: string): HTMLElement { + const cell = within(container).getByText(name); + const row = cell.closest('tr'); + if (!row) throw new Error(`row not found for ${name}`); + return row as HTMLElement; +} + +// The nodes page category chips used to hover to a bare category title. +// When the backend sends the category's email list, hovering a chip must +// pop the list over (same pattern as the inbounds page); without a list +// the chip keeps the plain title tooltip. +describe('NodeList clients-cell category chips', () => { + afterEach(() => vi.restoreAllMocks()); + + it('pops the online and depleted email lists on hover', async () => { + const { container } = renderNodes([ + { + id: 5, + name: 'agent-node', + enable: true, + status: 'online', + nodeType: 'agent', + clientCount: 9, + activeCount: 2, + activeEmails: ['active-a@x', 'active-b@x'], + depletedCount: 3, + depletedEmails: ['dep-a@x', 'dep-b@x', 'dep-c@x'], + onlineCount: 1, + onlineEmails: ['online@x'], + }, + ]); + + const row = rowFor(container, 'agent-node'); + + fireEvent.mouseEnter(within(row).getByText('1')); + await waitFor( + () => { + expect(document.body.textContent).toContain('online@x'); + }, + { timeout: 5000 }, + ); + + fireEvent.mouseEnter(within(row).getByText('3')); + await waitFor( + () => { + expect(document.body.textContent).toContain('dep-b@x'); + }, + { timeout: 5000 }, + ); + }); + + it('keeps the title tooltip when a category has no email list', async () => { + const { container } = renderNodes([ + { + id: 6, + name: 'bare-node', + enable: true, + status: 'online', + clientCount: 4, + onlineCount: 1, + }, + ]); + + const row = rowFor(container, 'bare-node'); + fireEvent.mouseEnter(within(row).getByText('1')); + + await waitFor(() => { + expect(document.body.textContent).toContain('Online'); + }); + expect(document.querySelector('.client-email-list')).toBeNull(); + }); +}); diff --git a/internal/database/model/model.go b/internal/database/model/model.go index f0a0be0..99deb47 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -847,6 +847,15 @@ type Node struct { DisabledCount int `json:"disabledCount" gorm:"-" example:"3"` DepletedCount int `json:"depletedCount" gorm:"-" example:"1"` + // Per-category client email lists backing the counts above, filled by + // the node read paths (GetAll / recountByGuid) from the same bucketing + // so the nodes page can show WHO is in each category on hover, not just + // how many. Read-time only, never persisted. + OnlineEmails []string `json:"-" gorm:"-"` + ActiveEmails []string `json:"-" gorm:"-"` + DisabledEmails []string `json:"-" gorm:"-"` + DepletedEmails []string `json:"-" gorm:"-"` + // ParentGuid + Transitive are set only when a node is surfaced as part of a // node tree (#4983): direct nodes carry the master panel's own GUID, a // transitive sub-node carries its parent node's GUID. Transitive nodes are diff --git a/internal/web/service/node.go b/internal/web/service/node.go index 8c2b506..5320758 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -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 diff --git a/internal/web/service/node_client_lists_test.go b/internal/web/service/node_client_lists_test.go new file mode 100644 index 0000000..e4d23ee --- /dev/null +++ b/internal/web/service/node_client_lists_test.go @@ -0,0 +1,97 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// The nodes page category chips used to hover to a bare category title: +// the API carried only the counts, never WHO is in each category. GetAll +// must now emit the per-category email lists from the exact same bucketing +// pass as the counts (statuses by node id, online set by attribution GUID), +// and the tree view must carry them through to the wire contract. +func TestGetAll_ClientEmailListsMatchCounts(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + nodeSvc := NodeService{} + inboundSvc := InboundService{} + + const nodeID = 8301 + if err := db.Create(&model.Node{Id: nodeID, Name: "agent-node", Address: "10.0.8.3"}).Error; err != nil { + t.Fatalf("create node: %v", err) + } + nid := nodeID + ib := &model.Inbound{Tag: "lists-in", Enable: true, Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid} + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + + mk := func(id int, email string, enable bool) { + if err := db.Create(&model.ClientRecord{Id: id, Email: email, Enable: enable}).Error; err != nil { + t.Fatalf("create client %s: %v", email, err) + } + if !enable { + if err := db.Model(&model.ClientRecord{}).Where("id = ?", id).Update("enable", false).Error; err != nil { + t.Fatalf("disable client %s: %v", email, err) + } + } + if err := db.Create(&model.ClientInbound{ClientId: id, InboundId: ib.Id}).Error; err != nil { + t.Fatalf("attach client %s: %v", email, err) + } + } + mk(8301, "online@x", true) + mk(8302, "active@x", true) + mk(8303, "disabled@x", false) + mk(8304, "depleted@x", true) + if err := db.Create(&xray.ClientTraffic{InboundId: 999, Email: "depleted@x", Enable: true, Total: 100, Up: 60, Down: 60}).Error; err != nil { + t.Fatalf("create traffic: %v", err) + } + + // The agent's online report — same entry the agent controller calls. + inboundSvc.SetAgentOnlineClients(nodeID, []string{"online@x"}) + t.Cleanup(func() { inboundSvc.ClearNodeOnlineClients(nodeID) }) + + nodes, err := nodeSvc.GetAll() + if err != nil { + t.Fatalf("GetAll: %v", err) + } + var n *model.Node + for _, x := range nodes { + if x.Id == nodeID { + n = x + } + } + if n == nil { + t.Fatal("node not found") + } + if n.ClientCount != 4 || n.ActiveCount != 2 || n.DisabledCount != 1 || n.DepletedCount != 1 || n.OnlineCount != 1 { + t.Fatalf("counts = total %d active %d disabled %d depleted %d online %d, want 4/2/1/1/1", + n.ClientCount, n.ActiveCount, n.DisabledCount, n.DepletedCount, n.OnlineCount) + } + assertStringSet(t, "active emails", n.ActiveEmails, []string{"online@x", "active@x"}) + assertStringSet(t, "disabled emails", n.DisabledEmails, []string{"disabled@x"}) + assertStringSet(t, "depleted emails", n.DepletedEmails, []string{"depleted@x"}) + assertStringSet(t, "online emails", n.OnlineEmails, []string{"online@x"}) + + // The wire contract (nodes page) must carry the same lists. + views, err := nodeSvc.GetNodeTreeView() + if err != nil { + t.Fatalf("GetNodeTreeView: %v", err) + } + var v *NodeView + for _, x := range views { + if x.Id == nodeID { + v = x + } + } + if v == nil { + t.Fatal("node view not found") + } + assertStringSet(t, "view online emails", v.OnlineEmails, []string{"online@x"}) + assertStringSet(t, "view active emails", v.ActiveEmails, []string{"online@x", "active@x"}) + assertStringSet(t, "view disabled emails", v.DisabledEmails, []string{"disabled@x"}) + assertStringSet(t, "view depleted emails", v.DepletedEmails, []string{"depleted@x"}) +} diff --git a/internal/web/service/node_contract.go b/internal/web/service/node_contract.go index 60ba883..db06fd0 100644 --- a/internal/web/service/node_contract.go +++ b/internal/web/service/node_contract.go @@ -66,6 +66,13 @@ type NodeView struct { ActiveCount int `json:"activeCount" example:"20"` DisabledCount int `json:"disabledCount" example:"2"` DepletedCount int `json:"depletedCount" example:"1"` + // Per-category client email lists behind the counts above, from the + // same attribution bucketing, so the nodes page category chips can + // pop over the actual usernames. Empty when the category is empty. + OnlineEmails []string `json:"onlineEmails,omitempty" example:"[\"a@example.com\"]"` + ActiveEmails []string `json:"activeEmails,omitempty" example:"[\"a@example.com\"]"` + DisabledEmails []string `json:"disabledEmails,omitempty" example:"[\"b@example.com\"]"` + DepletedEmails []string `json:"depletedEmails,omitempty" example:"[\"c@example.com\"]"` ParentGuid string `json:"parentGuid,omitempty" example:""` Transitive bool `json:"transitive,omitempty" example:"false"` CreatedAt int64 `json:"createdAt" example:"1700000000"` @@ -114,6 +121,10 @@ func toNodeView(n *model.Node) *NodeView { ActiveCount: n.ActiveCount, DisabledCount: n.DisabledCount, DepletedCount: n.DepletedCount, + OnlineEmails: n.OnlineEmails, + ActiveEmails: n.ActiveEmails, + DisabledEmails: n.DisabledEmails, + DepletedEmails: n.DepletedEmails, ParentGuid: n.ParentGuid, Transitive: n.Transitive, CreatedAt: n.CreatedAt, diff --git a/internal/web/service/node_tree.go b/internal/web/service/node_tree.go index 03e9cb8..ad3383f 100644 --- a/internal/web/service/node_tree.go +++ b/internal/web/service/node_tree.go @@ -209,10 +209,15 @@ func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) { // 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. + // 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 { @@ -232,10 +237,19 @@ func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) { 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) + } } } } @@ -245,8 +259,12 @@ func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) { 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] } }