Skip to content

Commit adc64bb

Browse files
authored
fix(nodes): cloned-node attribution, node-hosted client display (online/speed/counts), and sync robustness (#5488)
* fix(nodes): keep cloned nodes (shared panelGuid) in separate attribution buckets #4983 keys online/inbound attribution by panelGuid, assuming it is globally unique. Cloned node servers ship an identical panelGuid in their copied settings, so the master collapsed several physical nodes into one bucket: GetMergedNodeTrees merged their online sets under one key and every inbound on those nodes (same origin_node_guid) read that merged set, so the inbound page showed online cross-attributed and counts inflated. Fall back to the node-unique synthNodeGuid(node.Id) whenever a node's panelGuid is shared by another of the master's direct nodes. Applied consistently at originGuidFor (origin_node_guid write), the online-tree key plus a self-key remap for nodes that report a GUID-keyed tree, effectiveNodeGuid, and recountByGuid's inbound bucketing. sharedNodeGuids computes the collision set. Online now works without node changes; making panelGuids unique restores real-GUID identity and also fixes GUID-keyed IP attribution. * fix(nodes): extend duplicate-GUID hardening to master collisions, IP attribution, and a heartbeat warning Builds on the node-vs-node fix: a node's GUID is now also treated as ambiguous when it equals the master's own panelGuid (a node cloned from the master), so the master's local clients and that node can't merge. Centralized as ambiguousNodeGuids(nodes, selfGuid) + effectiveNodeKey(node). Applied the same node-unique fallback to the GUID-keyed IP attribution that #4983 added but the prior commit left collapsing: MergeClientIpsByGuid remaps a cloned node's own subtree to its node-unique key, nodeGuidNameMap resolves names by that key, and node deletion purges both keys. Added a throttled heartbeat warning so the operator is told to regenerate a duplicate panelGuid. Tests cover master-collision, effectiveNodeKey, and the IP remap. * fix(node-sync): log the client-IP-attribution 404 once per node, not every cycle Old-build nodes lack panel/api/clients/clientIpsByGuid and answer 404 on every IP-sync cycle (~10s), which floods the debug log now that the IP phase actually runs. Note the missing endpoint once per node (re-armed if the node later recovers or is upgraded) and keep logging genuine fetch errors. * fix(nodes): remap a cloned node's own-panelGuid origin so the inbound page shows online These nodes report their OWN inbounds with their own panelGuid as OriginNodeGuid, so originGuidFor returned the shared GUID verbatim and never remapped it. origin_node_guid stayed the shared GUID while online was keyed under the node-unique key, so the inbound page (which reads the stored origin_node_guid) looked up an empty bucket and showed everyone offline — even though the Nodes page (which derives the key live) was correct. Treat an origin equal to the node's own panelGuid as the node's own inbound and resolve it through selfKey; keep only a genuinely different (descendant) origin across hops. * fix(node-sync): don't delete a node's central inbounds when its snapshot is empty The central-inbound sweep deletes any central inbound whose tag is absent from the node's snapshot, with no guard for an empty snapshot. A node mid-restart or with a transient DB error (e.g. Postgres 57P01) can return an empty inbound list with success=true, which wiped all of that node's central inbounds and their clients (and reset traffic history on re-create) — observed on the Germany node: 0 clients but still 44 online (online survives because it comes from the snapshot's online tree, not the central inbound). Skip the sweep entirely when the snapshot reports zero inbounds; a real per-inbound deletion still sweeps via a non-empty snapshot that omits one tag. * fix(email): stay silent when SMTP notifications are disabled The event subscriber is registered unconditionally and only checked the per-event list (smtpEnabledEvents, default login.attempt,cpu.high) — not the smtpEnable master toggle. Login events are always published, so a panel with smtpEnable=false still attempted a send on every login and logged 'email subscriber: send failed: smtp host not configured'. Gate HandleEvent on GetSmtpEnable() so a disabled-SMTP panel does nothing, matching the comment where the subscriber is registered. * fix(nodes): count only expired/exhausted as 'ended', not disabled clients The per-node depleted (ended) count folded disabled clients in with expired/exhausted (expired || exhausted || !Enable), so the Nodes page 'ended' chip was inflated and inconsistent with the inbound page, where disabled and depleted are separate buckets. Count only expired/exhausted in both GetAll and recountByGuid so 'ended' means the same thing on both pages. * feat(nodes): show live speed for node-hosted inbounds Inbound speed is computed on the dashboard from a 'traffics' delta feed, which only the local Xray poll produced — so node-hosted inbounds showed no speed. The node sync now diffs successive per-inbound cumulative totals (it polls @5S, same as the local poll) and broadcasts the byte deltas as a separate 'nodeTraffics' field, keyed by the central tag the dashboard already matches. The frontend applies 'traffics' to local inbounds and 'nodeTraffics' to node inbounds within their own scope, so the two 5s polls don't clobber each other and idle inbounds still clear. Deltas clamp to 0 on a reset; a node that fails to sync keeps a stale total so its delta is 0 (no phantom speed). * fix(nodes): normalize node-inbound speed by elapsed time to avoid recovery spikes Adversarial review found that a node's cumulative inbound counter keeps climbing while the master can't reach it, so the first delta after a gap (node outage, skipped poll, slow node) spans more than one 5s window but was still divided by the dashboard's fixed 5s — rendering an impossible one-tick speed spike on recovery (and a 2x over-report after a skipped poll). Now each delta is normalized to the fixed window using the real elapsed time since the inbound's counter last changed, so a backlog shows the true average rate over the gap. The change timestamp advances only on actual movement, so idle stretches average correctly when traffic resumes; resets rebaseline. Also moves the maybePushGlobals doc comment back onto its function. * fix(inbounds): keep last speed across page navigation instead of blanking Speed is delta-derived, so it can't be recomputed until the first poll after mount. The websocket subscription and speed state are page-scoped (useWebSocket lives in InboundsPage), so leaving to another page and returning blanked the Speed column for up to one 5s poll. Cache the last speed map across mounts (module scope, 15s recency guard) and seed the state from it, so returning shows the last throughput immediately and the next poll refreshes it. Applies to both local and node-hosted inbound speed. * fix(inbounds): rebalance table column widths so it fills width without gaps Inbound list columns had small fixed widths summing far below the table's full width, so AntD spread the leftover space evenly into wide empty gaps. Widen the content-heavy columns (protocol, clients, traffic, node) so the slack lands there, keep the small ones (id, port, enable) tight, and make scroll.x track the visible columns' total so the table never collapses below content and adapts when conditional columns are hidden. * feat(nodes): show active/disabled client counts on the nodes page like inbounds The nodes page only showed total/online/ended, and (since ended now excludes disabled) disabled clients were invisible there. Compute per-node active and disabled counts — in both GetAll and recountByGuid, with the same depleted-wins-over-disabled precedence the inbound page uses so the buckets stay mutually exclusive — and render total/active/disabled/ended/online chips matching the inbound page (table column + mobile stats modal). * fix(nodes): count active/disabled/ended by client email, not stale inbound_id The per-node client breakdown filtered client_traffics by inbound_id, but that column goes stale after an inbound is delete+recreated (e.g. the Germany node), so almost every traffic row pointed at a dead inbound id and the counts collapsed — active showed ~5 instead of ~1100. Classify each node client via client_inbounds -> clients joined to client_traffics by EMAIL (the reliable key), deduped per node/guid, in both GetAll and recountByGuid. Now active/disabled/ended on the nodes page match the inbound page. Added a regression test that proves matching works with a deliberately stale inbound_id. * style(nodes): widen Clients column so the count chips fit one tidy line After adding the active/disabled chips, the 5 chips (total/active/disabled/ended/online) no longer fit the 160px Clients column and wrapped to two lines. Widen it to 220 and drop the Space wrap so they render on a single line like the inbound page, and zero the total tag's margin for even spacing. Same principle as 79ff283 (give the content column enough width). * style(nodes): tighten Clients chip spacing to match the inbound page AntD's default tag side-padding (~8px) put a wide gap between the count chips. Apply the inbound page's compact padding ('0 2px') + client-count-tag (tabular-nums) to each chip and narrow the column to 180 so the numbers sit close together like the inbound list instead of floating apart.
1 parent f07d092 commit adc64bb

21 files changed

Lines changed: 891 additions & 146 deletions

frontend/public/openapi.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1826,6 +1826,10 @@
18261826
"Node": {
18271827
"description": "Node represents a remote 3x-ui panel registered with the central panel.\nThe central panel polls each node's existing /panel/api/server/status\nendpoint over HTTP using the per-node ApiToken to populate the runtime\nstatus fields below.",
18281828
"properties": {
1829+
"activeCount": {
1830+
"example": 23,
1831+
"type": "integer"
1832+
},
18291833
"address": {
18301834
"example": "node1.example.com",
18311835
"type": "string"
@@ -1863,6 +1867,10 @@
18631867
"example": 1,
18641868
"type": "integer"
18651869
},
1870+
"disabledCount": {
1871+
"example": 3,
1872+
"type": "integer"
1873+
},
18661874
"enable": {
18671875
"example": true,
18681876
"type": "boolean"
@@ -1993,6 +2001,7 @@
19932001
}
19942002
},
19952003
"required": [
2004+
"activeCount",
19962005
"address",
19972006
"allowPrivateAddress",
19982007
"apiToken",
@@ -2003,6 +2012,7 @@
20032012
"cpuPct",
20042013
"createdAt",
20052014
"depletedCount",
2015+
"disabledCount",
20062016
"enable",
20072017
"guid",
20082018
"id",
@@ -6683,6 +6693,7 @@
66836693
"success": true,
66846694
"obj": [
66856695
{
6696+
"activeCount": 23,
66866697
"address": "node1.example.com",
66876698
"allowPrivateAddress": false,
66886699
"apiToken": "abcdef0123456789",
@@ -6693,6 +6704,7 @@
66936704
"cpuPct": 23.5,
66946705
"createdAt": 1700000000,
66956706
"depletedCount": 1,
6707+
"disabledCount": 3,
66966708
"enable": true,
66976709
"guid": "",
66986710
"id": 1,

frontend/src/generated/examples.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ export const EXAMPLES: Record<string, unknown> = {
396396
"success": false
397397
},
398398
"Node": {
399+
"activeCount": 23,
399400
"address": "node1.example.com",
400401
"allowPrivateAddress": false,
401402
"apiToken": "abcdef0123456789",
@@ -406,6 +407,7 @@ export const EXAMPLES: Record<string, unknown> = {
406407
"cpuPct": 23.5,
407408
"createdAt": 1700000000,
408409
"depletedCount": 1,
410+
"disabledCount": 3,
409411
"enable": true,
410412
"guid": "",
411413
"id": 1,

frontend/src/generated/schemas.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1800,6 +1800,10 @@ export const SCHEMAS: Record<string, unknown> = {
18001800
"Node": {
18011801
"description": "Node represents a remote 3x-ui panel registered with the central panel.\nThe central panel polls each node's existing /panel/api/server/status\nendpoint over HTTP using the per-node ApiToken to populate the runtime\nstatus fields below.",
18021802
"properties": {
1803+
"activeCount": {
1804+
"example": 23,
1805+
"type": "integer"
1806+
},
18031807
"address": {
18041808
"example": "node1.example.com",
18051809
"type": "string"
@@ -1837,6 +1841,10 @@ export const SCHEMAS: Record<string, unknown> = {
18371841
"example": 1,
18381842
"type": "integer"
18391843
},
1844+
"disabledCount": {
1845+
"example": 3,
1846+
"type": "integer"
1847+
},
18401848
"enable": {
18411849
"example": true,
18421850
"type": "boolean"
@@ -1967,6 +1975,7 @@ export const SCHEMAS: Record<string, unknown> = {
19671975
}
19681976
},
19691977
"required": [
1978+
"activeCount",
19701979
"address",
19711980
"allowPrivateAddress",
19721981
"apiToken",
@@ -1977,6 +1986,7 @@ export const SCHEMAS: Record<string, unknown> = {
19771986
"cpuPct",
19781987
"createdAt",
19791988
"depletedCount",
1989+
"disabledCount",
19801990
"enable",
19811991
"guid",
19821992
"id",

frontend/src/generated/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ export interface Msg {
394394
}
395395

396396
export interface Node {
397+
activeCount: number;
397398
address: string;
398399
allowPrivateAddress: boolean;
399400
apiToken: string;
@@ -404,6 +405,7 @@ export interface Node {
404405
cpuPct: number;
405406
createdAt: number;
406407
depletedCount: number;
408+
disabledCount: number;
407409
enable: boolean;
408410
guid: string;
409411
id: number;

frontend/src/generated/zod.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ export const MsgSchema = z.object({
423423
export type Msg = z.infer<typeof MsgSchema>;
424424

425425
export const NodeSchema = z.object({
426+
activeCount: z.number().int(),
426427
address: z.string(),
427428
allowPrivateAddress: z.boolean(),
428429
apiToken: z.string(),
@@ -433,6 +434,7 @@ export const NodeSchema = z.object({
433434
cpuPct: z.number(),
434435
createdAt: z.number().int(),
435436
depletedCount: z.number().int(),
437+
disabledCount: z.number().int(),
436438
enable: z.boolean(),
437439
guid: z.string(),
438440
id: z.number().int(),

frontend/src/pages/inbounds/list/InboundList.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ export default function InboundList({
133133
onSwitchEnable,
134134
});
135135

136+
const tableScrollX = useMemo(
137+
() => columns.reduce((sum, c) => sum + (typeof c.width === 'number' ? c.width : 0), 0),
138+
[columns],
139+
);
140+
136141
const paginationFor = (rows: DBInboundRecord[]) => {
137142
const size = pageSize > 0 ? pageSize : rows.length || 1;
138143
return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true };
@@ -252,7 +257,7 @@ export default function InboundList({
252257
onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
253258
}}
254259
pagination={paginationFor(visibleInbounds)}
255-
scroll={{ x: 1000 }}
260+
scroll={{ x: tableScrollX }}
256261
style={{ marginTop: 10 }}
257262
size="small"
258263
locale={{

frontend/src/pages/inbounds/list/useInboundColumns.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,13 @@ export function useInboundColumns({
5757
dataIndex: 'id',
5858
key: 'id',
5959
align: 'right',
60-
width: 30,
60+
width: 60,
6161
},
6262
{
6363
title: t('pages.inbounds.operate'),
6464
key: 'action',
6565
align: 'center',
66-
width: 60,
66+
width: 70,
6767
render: (_, record) => (
6868
<RowActionsCell
6969
record={record}
@@ -77,7 +77,7 @@ export function useInboundColumns({
7777
title: t('pages.inbounds.enable'),
7878
key: 'enable',
7979
align: 'center',
80-
width: 35,
80+
width: 80,
8181
render: (_, record) => (
8282
<Switch
8383
checked={record.enable}
@@ -93,7 +93,7 @@ export function useInboundColumns({
9393
dataIndex: 'remark',
9494
key: 'remark',
9595
align: 'center',
96-
width: 60,
96+
width: 90,
9797
});
9898
}
9999

@@ -102,7 +102,7 @@ export function useInboundColumns({
102102
title: t('pages.inbounds.node'),
103103
key: 'node',
104104
align: 'center',
105-
width: 60,
105+
width: 130,
106106
render: (_, record) => {
107107
if (record.nodeId == null) {
108108
return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
@@ -128,7 +128,7 @@ export function useInboundColumns({
128128
dataIndex: 'subSortIndex',
129129
key: 'subSortIndex',
130130
align: 'right',
131-
width: 70,
131+
width: 90,
132132
});
133133
}
134134

@@ -138,13 +138,13 @@ export function useInboundColumns({
138138
dataIndex: 'port',
139139
key: 'port',
140140
align: 'center',
141-
width: 40,
141+
width: 80,
142142
},
143143
{
144144
title: t('pages.inbounds.protocol'),
145145
key: 'protocol',
146146
align: 'left',
147-
width: 130,
147+
width: 190,
148148
render: (_, record) => {
149149
const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
150150
if (record.isWireguard || record.isHysteria) {
@@ -172,7 +172,7 @@ export function useInboundColumns({
172172
title: t('clients'),
173173
key: 'clients',
174174
align: 'left',
175-
width: 110,
175+
width: 200,
176176
render: (_, record) => {
177177
const cc = clientCount[record.id];
178178
if (!cc) return null;
@@ -237,7 +237,7 @@ export function useInboundColumns({
237237
title: t('pages.inbounds.traffic'),
238238
key: 'traffic',
239239
align: 'center',
240-
width: 90,
240+
width: 140,
241241
render: (_, record) => (
242242
<Popover
243243
content={(
@@ -269,7 +269,7 @@ export function useInboundColumns({
269269
title: t('pages.inbounds.speed'),
270270
key: 'speed',
271271
align: 'center',
272-
width: 90,
272+
width: 110,
273273
render: (_, record) => {
274274
const speed = inboundSpeed[record.id];
275275
if (!isActiveSpeed(speed)) {
@@ -282,7 +282,7 @@ export function useInboundColumns({
282282
title: t('pages.inbounds.expireDate'),
283283
key: 'expiryTime',
284284
align: 'center',
285-
width: 40,
285+
width: 100,
286286
render: (_, record) => {
287287
if (record.expiryTime > 0) {
288288
return (

frontend/src/pages/inbounds/useInbounds.ts

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ type DBInboundInstance = InstanceType<typeof DBInbound>;
3232
// deltas accumulated over this window, so dividing by it yields bytes/sec.
3333
const TRAFFIC_POLL_INTERVAL_S = 5;
3434

35+
// Speed is delta-derived, so it can't be recomputed until the first poll after
36+
// mount; navigating away and back would otherwise blank the column for up to one
37+
// poll. Cache the last speed map across mounts (module scope) and reseed from it
38+
// while recent, so returning to the page shows the last throughput immediately
39+
// and the next poll refreshes it.
40+
const SPEED_CACHE_TTL_MS = 15000;
41+
let inboundSpeedCache: { at: number; data: Record<number, InboundSpeedEntry> } = { at: 0, data: {} };
42+
3543
interface TrafficDelta {
3644
Tag: string;
3745
Up: number;
@@ -191,7 +199,12 @@ export function useInbounds() {
191199
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
192200
const [statsVersion, setStatsVersion] = useState(0);
193201

194-
const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>({});
202+
const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>(() =>
203+
Date.now() - inboundSpeedCache.at < SPEED_CACHE_TTL_MS ? inboundSpeedCache.data : {},
204+
);
205+
useEffect(() => {
206+
inboundSpeedCache = { at: Date.now(), data: inboundSpeed };
207+
}, [inboundSpeed]);
195208

196209
const [onlineClients, setOnlineClients] = useState<string[]>([]);
197210
const onlineClientsRef = useRef<string[]>([]);
@@ -399,6 +412,7 @@ export function useInbounds() {
399412
if (!payload || typeof payload !== 'object') return;
400413
const p = payload as {
401414
traffics?: TrafficDelta[];
415+
nodeTraffics?: TrafficDelta[];
402416
onlineClients?: string[];
403417
onlineByGuid?: Record<string, string[]>;
404418
activeInbounds?: Record<string, string[]>;
@@ -417,26 +431,40 @@ export function useInbounds() {
417431
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
418432
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
419433
}
420-
// Full-replace each poll so idle inbounds (and an empty array after an
421-
// Xray stat reset) clear their speed instead of showing a stale value.
422-
if (Array.isArray(p.traffics)) {
434+
// Speed arrives from two independent 5s polls: the local Xray poll sends
435+
// `traffics` (local inbounds) and the node sync sends `nodeTraffics` (node
436+
// inbounds). Each replaces speed only within its own scope so the two don't
437+
// clobber each other; an idle in-scope inbound — absent from its payload —
438+
// clears instead of showing a stale value.
439+
const applyTraffics = (
440+
traffics: TrafficDelta[],
441+
inScope: (ib: DBInboundInstance) => boolean,
442+
) => {
423443
const byTag = new Map<string, TrafficDelta>();
424-
for (const tr of p.traffics) {
444+
for (const tr of traffics) {
425445
if (!tr || typeof tr.Tag !== 'string') continue;
426446
if (tr.IsInbound === false) continue;
427447
byTag.set(tr.Tag, tr);
428448
}
429-
const nextSpeed: Record<number, InboundSpeedEntry> = {};
430-
for (const ib of dbInboundsRef.current) {
431-
const delta = byTag.get(ib.tag);
432-
if (!delta) continue;
433-
nextSpeed[ib.id] = {
434-
up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
435-
down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
436-
};
437-
}
438-
setInboundSpeed(nextSpeed);
439-
}
449+
setInboundSpeed((prev) => {
450+
const next = { ...prev };
451+
for (const ib of dbInboundsRef.current) {
452+
if (!inScope(ib)) continue;
453+
const delta = byTag.get(ib.tag);
454+
if (delta) {
455+
next[ib.id] = {
456+
up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
457+
down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
458+
};
459+
} else {
460+
delete next[ib.id];
461+
}
462+
}
463+
return next;
464+
});
465+
};
466+
if (Array.isArray(p.traffics)) applyTraffics(p.traffics, (ib) => ib.nodeId == null);
467+
if (Array.isArray(p.nodeTraffics)) applyTraffics(p.nodeTraffics, (ib) => ib.nodeId != null);
440468
rebuildClientCount();
441469
},
442470
[rebuildClientCount],

0 commit comments

Comments
 (0)