merge fix/online-lists: agent inbound attribution + node page email lists (V1.0.1)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 = (
|
||||
<Tag color={color} className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
{count}
|
||||
</Tag>
|
||||
);
|
||||
if (emails && emails.length > 0) {
|
||||
return (
|
||||
<Popover
|
||||
title={title}
|
||||
content={
|
||||
<div className="client-email-list">
|
||||
{emails.map((e) => (
|
||||
<div key={e}>{e}</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{tag}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
return <Tooltip title={title}>{tag}</Tooltip>;
|
||||
}
|
||||
|
||||
interface NodeRow extends NodeRecord {
|
||||
url: string;
|
||||
key: string | number;
|
||||
@@ -495,44 +546,35 @@ export default function NodeList({
|
||||
<TeamOutlined /> {record.clientCount || 0}
|
||||
</Tag>
|
||||
{record.activeCount ? (
|
||||
<Tooltip title={t('subscription.active')}>
|
||||
<Tag
|
||||
color="green"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.activeCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('subscription.active')}
|
||||
emails={record.activeEmails}
|
||||
color="green"
|
||||
count={record.activeCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.disabledCount ? (
|
||||
<Tooltip title={t('disabled')}>
|
||||
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
{record.disabledCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('disabled')}
|
||||
emails={record.disabledEmails}
|
||||
count={record.disabledCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.depletedCount ? (
|
||||
<Tooltip title={t('depleted')}>
|
||||
<Tag
|
||||
color="red"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.depletedCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('depleted')}
|
||||
emails={record.depletedEmails}
|
||||
color="red"
|
||||
count={record.depletedCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.onlineCount ? (
|
||||
<Tooltip title={t('online')}>
|
||||
<Tag
|
||||
color="blue"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.onlineCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('online')}
|
||||
emails={record.onlineEmails}
|
||||
color="blue"
|
||||
count={record.onlineCount}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
<NodeList
|
||||
nodes={nodes}
|
||||
isMobile={false}
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
onProbe={noop}
|
||||
onToggleEnable={noop}
|
||||
onUpdateNode={noop}
|
||||
onUpdateSelected={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -196,6 +196,59 @@ func (s *InboundService) annotateLocalOriginGuid(inbounds []*model.Inbound) {
|
||||
}
|
||||
}
|
||||
|
||||
// annotateAgentOriginGuid fills OriginNodeGuid (read-time only, like
|
||||
// annotateLocalOriginGuid) for inbounds hosted on agent-managed nodes with
|
||||
// the node's effective attribution key — the exact key
|
||||
// SetAgentOnlineClients files the node's reported online clients under.
|
||||
//
|
||||
// Agent inbounds are defined centrally on the master, so their stored
|
||||
// OriginNodeGuid is either empty (created straight onto the agent node) or
|
||||
// STALE: an inbound re-homed from a panel node keeps the GUID the snapshot
|
||||
// merge stamped for its previous host, and nothing on the move path rewrites
|
||||
// it. The inbounds page looks the online set up by that stored GUID first,
|
||||
// so a re-homed agent inbound's online list never matched and its clients
|
||||
// never lit up. An agent has no sub-node chain to preserve attribution for —
|
||||
// the hosting node physically terminates every one of its inbounds — so the
|
||||
// node's own key is always the right value, and computing it at read time
|
||||
// also heals rows already carrying a stale GUID without a storage rewrite.
|
||||
func (s *InboundService) annotateAgentOriginGuid(db *gorm.DB, inbounds []*model.Inbound) {
|
||||
nodeIDs := make(map[int]struct{})
|
||||
for _, ib := range inbounds {
|
||||
if ib.NodeID != nil {
|
||||
nodeIDs[*ib.NodeID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(nodeIDs) == 0 {
|
||||
return
|
||||
}
|
||||
agents := agentManagedNodeIDs()
|
||||
keyByNode := make(map[int]string, len(nodeIDs))
|
||||
for id := range nodeIDs {
|
||||
if !agents[id] {
|
||||
continue
|
||||
}
|
||||
var nodeRow model.Node
|
||||
if err := db.Select("guid").Where("id = ?", id).First(&nodeRow).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
nodeRow.Id = id
|
||||
if key := effectiveNodeKey(&nodeRow); key != "" {
|
||||
keyByNode[id] = key
|
||||
}
|
||||
}
|
||||
if len(keyByNode) == 0 {
|
||||
return
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
if ib.NodeID == nil {
|
||||
continue
|
||||
}
|
||||
if key, ok := keyByNode[*ib.NodeID]; ok {
|
||||
ib.OriginNodeGuid = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetInboundsSlim returns the same list of inbounds as GetInbounds but
|
||||
// strips every per-client field other than email / enable / comment from
|
||||
// settings.clients and skips UUID/SubId enrichment on ClientStats. The
|
||||
@@ -215,6 +268,7 @@ func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) {
|
||||
}
|
||||
s.annotateFallbackParents(db, inbounds)
|
||||
s.annotateLocalOriginGuid(inbounds)
|
||||
s.annotateAgentOriginGuid(db, inbounds)
|
||||
// Top up stats rows owned by sibling inbounds (multi-attached clients)
|
||||
// so the list's depleted/expiring badges see every client; the UUID/SubId
|
||||
// enrichment stays skipped. Must run before slimming strips the settings.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// The inbounds page looks a node's online set up under the inbound's
|
||||
// originNodeGuid (falling back to node:{id}). Agent inbounds are defined
|
||||
// centrally: a fresh one stores no origin GUID, and one re-homed from a
|
||||
// panel node keeps the GUID the snapshot merge stamped for its PREVIOUS
|
||||
// host — while the agent's onlines are filed under the agent node's own
|
||||
// effective key. The slim list must therefore stamp agent-hosted inbounds
|
||||
// with the hosting node's effective key at read time, or their clients
|
||||
// never show as online on the inbounds page (production: every converted
|
||||
// agent node was affected). Traditional inbounds' stored origins are
|
||||
// chain attribution (#4983) and must pass through untouched.
|
||||
func TestGetInboundsSlim_AnnotatesAgentOriginGuid(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
svc := InboundService{}
|
||||
|
||||
const agentNodeID = 8201
|
||||
const tradNodeID = 8202
|
||||
const userID = 1
|
||||
|
||||
if err := db.Create(&model.Node{Id: agentNodeID, Name: "agent-node", Address: "10.0.8.1"}).Error; err != nil {
|
||||
t.Fatalf("create agent node: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Node{Id: tradNodeID, Name: "trad-node", Address: "10.0.8.2", Port: 2053, ApiToken: "t", Guid: "trad-guid-8202"}).Error; err != nil {
|
||||
t.Fatalf("create trad node: %v", err)
|
||||
}
|
||||
// The agent binding is what makes the node "agent-managed".
|
||||
agentID := agentNodeID
|
||||
if err := db.Create(&model.AgentNode{
|
||||
TokenHash: fmt.Sprintf("%064d", agentNodeID),
|
||||
Name: "agent-node",
|
||||
NodeID: &agentID,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create agent binding: %v", err)
|
||||
}
|
||||
|
||||
mkInbound := func(tag string, nodeID *int, origin string) {
|
||||
ib := &model.Inbound{
|
||||
UserId: userID,
|
||||
Tag: tag,
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[{"email":"a@example.com","enable":true}]}`,
|
||||
NodeID: nodeID,
|
||||
OriginNodeGuid: origin,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", tag, err)
|
||||
}
|
||||
}
|
||||
agentIDCopy := agentNodeID
|
||||
tradIDCopy := tradNodeID
|
||||
mkInbound("agent-stale", &agentIDCopy, "guid-of-previous-panel-host")
|
||||
mkInbound("agent-fresh", &agentIDCopy, "")
|
||||
mkInbound("trad-synced", &tradIDCopy, "trad-guid-8202")
|
||||
|
||||
slim, err := svc.GetInboundsSlim(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInboundsSlim: %v", err)
|
||||
}
|
||||
byTag := make(map[string]*model.Inbound, len(slim))
|
||||
for _, ib := range slim {
|
||||
byTag[ib.Tag] = ib
|
||||
}
|
||||
|
||||
wantKey := fmt.Sprintf("node:%d", agentNodeID)
|
||||
if got := byTag["agent-stale"].OriginNodeGuid; got != wantKey {
|
||||
t.Errorf("agent-stale originNodeGuid = %q, want %q (stale host GUID healed to the agent's key)", got, wantKey)
|
||||
}
|
||||
if got := byTag["agent-fresh"].OriginNodeGuid; got != wantKey {
|
||||
t.Errorf("agent-fresh originNodeGuid = %q, want %q", got, wantKey)
|
||||
}
|
||||
if got := byTag["trad-synced"].OriginNodeGuid; got != "trad-guid-8202" {
|
||||
t.Errorf("trad-synced originNodeGuid = %q, want stored GUID untouched", got)
|
||||
}
|
||||
|
||||
// Read-time only: the stored value must not be rewritten.
|
||||
var stored model.Inbound
|
||||
if err := db.Where("tag = ?", "agent-stale").First(&stored).Error; err != nil {
|
||||
t.Fatalf("reload agent-stale: %v", err)
|
||||
}
|
||||
if stored.OriginNodeGuid != "guid-of-previous-panel-host" {
|
||||
t.Errorf("stored origin_node_guid = %q, want the stale value preserved (annotation is read-time only)", stored.OriginNodeGuid)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user