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]
}
}