Преглед изворни кода

feat(nodes): multi-hop node attribution for chained sub-nodes (#4983) (#5005)

* feat(nodes): add stable panel GUID identity (multi-hop phase 0)

Per-panel autoincrement node ids are meaningless one hop away, so in a chained topology (Node1 -> Node2 -> Node3) the master cannot attribute online clients or inbounds to the physical node that hosts them (#4983).

Introduce a stable self-identifier: each panel generates and persists a panelGuid (settings table, mirroring GetSecret), returns it in panel/api/server/status, and the master learns it per node via the heartbeat into a new Node.Guid column. Guarded so an old-build node or a failed probe never clears a known GUID. No behavior change yet - this is the identity foundation Phases 1-2 key on.

Refs #4983

* feat(nodes): attribute inbounds to their origin node by GUID (multi-hop phase 1)

Add Inbound.OriginNodeGuid: the GUID of the panel that physically hosts an inbound. Empty means this panel's own xray; set means it was synced from a node. SetRemoteTraffic now fills it per synced inbound - keeping a non-empty value the node forwarded from its own sub-node (so a transitive inbound stays attributed to the deepest node across hops), and otherwise attributing the node's own local inbounds to that node's GUID. Empty (old-build node without a GUID) leaves the existing node_id-based attribution untouched.

The field rides the existing inbound JSON, so /list propagates it up the chain with no serve-side change. Phase 2 will key per-node online off this instead of the panel-local node_id.

Refs #4983

* feat(nodes): key online status by node GUID end-to-end (multi-hop phase 2)

Replace the panel-local node-id keying of per-node online status with the stable panelGuid, so a client several hops down a node chain is attributed to the node that physically hosts it instead of the intermediate node it syncs through (#4983).

xray/process.go stores each direct node's reported GUID-keyed subtree and merges them (correct at any depth); the service assembles GetOnlineClientsByGuid (own clients under this panel's GUID + every node under its GUID). FetchTrafficSnapshot fetches the new /clients/onlinesByGuid, falling back to the flat /onlines for old-build nodes (keyed under the node's GUID or a master-local synthetic id). The node rollup, the WS onlineByGuid/activeInbounds fields, and the inbounds-page rollup all scope by GUID; local inbounds get their OriginNodeGuid filled with the panel's GUID at serve time so the frontend keys uniformly.

Old-build nodes degrade to the prior flat behaviour via the synthetic node:<id> key. Refs #4983

Refs #4983

* feat(nodes): surface transitive sub-nodes on the master (multi-hop phase 3a)

Each panel publishes read-only summaries of the nodes it manages via GET /panel/api/server/descendants (node API token). The heartbeat job caches each direct node's summaries; GetNodeTree merges them as transitive model.Node projections (Id 0, Transitive=true, ParentGuid = their parent node's GUID) and recomputes InboundCount/OnlineCount/DepletedCount per origin GUID so a direct node shows only its own inbounds and each sub-node shows its own (#4983).

The Nodes-page list endpoint and the heartbeat broadcast now return the tree; GetAll stays direct-only for probing/syncing. One transitive level is surfaced (covers Node1->Node2->Node3); deeper recursion is a follow-up. Backend only - the Nodes-page nested UI lands next.

Refs #4983

* feat(nodes): render transitive sub-nodes nested + read-only on the Nodes page (multi-hop phase 3b)

The Nodes page now shows a node's downstream sub-nodes (learned via the descendants tree) as indented, read-only rows ordered right under their parent: no enable toggle, probe, edit, delete, update, selection, or history expander - just a 'Sub-node' tag whose tooltip names the parent it is reached through. Desktop table and mobile cards both handle it. Transitive rows are keyed by GUID (their Id is 0) so they don't collide with real nodes (#4983).

Rows nest by parentGuid rather than AntD tree-children to avoid clashing with the existing per-row history expander. New labels added to en-US (other locales fall back until translated). Refs #4983

Refs #4983

* i18n(nodes): translate subNode/subNodeTip across all locales

Phase 3b added these two Nodes-page keys (read-only sub-node tag + tooltip) only to en-US; fill in the other 12 locales so the multi-hop sub-node UI is fully localized. The {parent} placeholder is preserved in every translation.

Refs #4983
Sanaei пре 19 часа
родитељ
комит
e6c1ce9aa9

+ 41 - 0
database/model/model.go

@@ -63,6 +63,14 @@ type Inbound struct {
 	Sniffing       string   `json:"sniffing" form:"sniffing"`
 	NodeID         *int     `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
 
+	// OriginNodeGuid is the panelGuid of the node that physically hosts this
+	// inbound, propagated up across hops (#4983). Empty for an inbound that
+	// lives on this panel's own xray; set to the originating node's GUID when
+	// the inbound was synced from a node (kept as-is across further hops). Lets
+	// the master attribute a deeply nested inbound to the real node instead of
+	// the intermediate one it was fetched through.
+	OriginNodeGuid string `json:"originNodeGuid,omitempty" form:"originNodeGuid" gorm:"column:origin_node_guid;index"`
+
 	// FallbackParent is populated by the API layer when this inbound is
 	// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
 	// The frontend uses it to rewrite client-share links so they advertise
@@ -383,6 +391,13 @@ type Node struct {
 	TlsVerifyMode       string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
 	PinnedCertSha256    string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
 
+	// Guid is the remote panel's stable self-identifier (its panelGuid),
+	// learned from each heartbeat. It is the globally stable node identity used
+	// to attribute online clients/inbounds to the physical node across a chain
+	// of nodes (#4983); panel-local autoincrement ids don't survive a hop.
+	// Observed-state only — never user-edited.
+	Guid string `json:"guid" gorm:"column:guid;index"`
+
 	// Heartbeat-updated fields. UpdatedAt advances on every probe even when
 	// the row is otherwise unchanged so the UI's "last seen" tooltip is
 	// truthful without us having to read LastHeartbeat separately.
@@ -404,10 +419,36 @@ type Node struct {
 	OnlineCount   int `json:"onlineCount" gorm:"-"`
 	DepletedCount int `json:"depletedCount" 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
+	// read-only projections (Id == 0, not persisted) — never edited or deployed.
+	ParentGuid string `json:"parentGuid,omitempty" gorm:"-"`
+	Transitive bool   `json:"transitive,omitempty" gorm:"-"`
+
 	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
 	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
 }
 
+// NodeSummary is the read-only identity of a node as published one hop up: the
+// view a panel exposes about the nodes it directly manages, so a master can
+// surface transitive sub-nodes in a chained topology (#4983). Counts are
+// computed by the consuming master from its own per-GUID data, never trusted
+// from the child, so this carries identity/health only.
+type NodeSummary struct {
+	Guid          string `json:"guid"`
+	ParentGuid    string `json:"parentGuid"`
+	Name          string `json:"name"`
+	Address       string `json:"address"`
+	Scheme        string `json:"scheme"`
+	Port          int    `json:"port"`
+	Status        string `json:"status"`
+	LastHeartbeat int64  `json:"lastHeartbeat"`
+	LatencyMs     int    `json:"latencyMs"`
+	PanelVersion  string `json:"panelVersion"`
+	XrayVersion   string `json:"xrayVersion"`
+}
+
 type CustomGeoResource struct {
 	Id            int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	Type          string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`

+ 49 - 7
frontend/public/openapi.json

@@ -1596,6 +1596,48 @@
         }
       }
     },
+    "/panel/api/server/descendants": {
+      "get": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Read-only summaries (guid, parentGuid, name, address, status, versions) of the nodes this panel manages. A parent panel calls it on a node (via the node API token) to surface transitive sub-nodes in a chained topology. Counts are computed by the parent, not returned here.",
+        "operationId": "get_panel_api_server_descendants",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "guid": "c3d4-...",
+                      "parentGuid": "a1b2-...",
+                      "name": "Node3",
+                      "address": "10.0.0.3",
+                      "status": "online"
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/server/getNewX25519Cert": {
       "get": {
         "tags": [
@@ -3716,13 +3758,13 @@
         }
       }
     },
-    "/panel/api/clients/onlinesByNode": {
+    "/panel/api/clients/onlinesByGuid": {
       "post": {
         "tags": [
           "Clients"
         ],
-        "summary": "Online client emails grouped by the node that reported them. The local panel uses key \"0\"; each remote node uses its node id. Lets the inbounds page show online status per node instead of merging every node together.",
-        "operationId": "post_panel_api_clients_onlinesByNode",
+        "summary": "Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.",
+        "operationId": "post_panel_api_clients_onlinesByGuid",
         "responses": {
           "200": {
             "description": "Successful response",
@@ -3743,10 +3785,10 @@
                 "example": {
                   "success": true,
                   "obj": {
-                    "0": [
+                    "a1b2-...": [
                       "user1"
                     ],
-                    "3": [
+                    "c3d4-...": [
                       "user1",
                       "user2"
                     ]
@@ -3763,7 +3805,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Inbound tags that carried traffic within the heartbeat window, grouped by node (local panel uses key \"0\"). Pairs with onlinesByNode so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.",
+        "summary": "Inbound tags that carried traffic within the heartbeat window, grouped by the hosting node's panelGuid. Pairs with onlinesByGuid so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.",
         "operationId": "post_panel_api_clients_activeInbounds",
         "responses": {
           "200": {
@@ -3785,7 +3827,7 @@
                 "example": {
                   "success": true,
                   "obj": {
-                    "0": [
+                    "a1b2-...": [
                       "inbound-443",
                       "inbound-8443"
                     ]

+ 1 - 1
frontend/src/api/queryKeys.ts

@@ -21,7 +21,7 @@ export const keys = {
     list: (params: unknown) => ['clients', 'list', params] as const,
     all: () => ['clients', 'all'] as const,
     onlines: () => ['clients', 'onlines'] as const,
-    onlinesByNode: () => ['clients', 'onlinesByNode'] as const,
+    onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
     activeInbounds: () => ['clients', 'activeInbounds'] as const,
     lastOnline: () => ['clients', 'lastOnline'] as const,
     groups: () => ['clients', 'groups'] as const,

+ 3 - 0
frontend/src/models/dbinbound.ts

@@ -40,6 +40,7 @@ export type DBInboundInit = Partial<{
     sniffing: RawJsonField;
     clientStats: ClientStats[];
     nodeId: number | null;
+    originNodeGuid: string;
     fallbackParent: FallbackParentRef | null;
 }>;
 
@@ -83,6 +84,7 @@ export class DBInbound {
     sniffing: RawJsonField;
     clientStats: ClientStats[];
     nodeId: number | null;
+    originNodeGuid: string;
     fallbackParent: FallbackParentRef | null;
 
     private _clientStatsMap: Map<string, ClientStats> | null = null;
@@ -108,6 +110,7 @@ export class DBInbound {
         this.sniffing = "";
         this.clientStats = [];
         this.nodeId = null;
+        this.originNodeGuid = "";
         this.fallbackParent = null;
         if (data == null) {
             return;

+ 11 - 5
frontend/src/pages/api-docs/endpoints.ts

@@ -324,6 +324,12 @@ export const sections: readonly Section[] = [
         summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.',
         response: '{\n  "success": true,\n  "obj": {\n    "webCertFile": "/root/cert/example.com/fullchain.pem",\n    "webKeyFile": "/root/cert/example.com/privkey.pem"\n  }\n}',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/server/descendants',
+        summary: 'Read-only summaries (guid, parentGuid, name, address, status, versions) of the nodes this panel manages. A parent panel calls it on a node (via the node API token) to surface transitive sub-nodes in a chained topology. Counts are computed by the parent, not returned here.',
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "guid": "c3d4-...",\n      "parentGuid": "a1b2-...",\n      "name": "Node3",\n      "address": "10.0.0.3",\n      "status": "online"\n    }\n  ]\n}',
+      },
       {
         method: 'GET',
         path: '/panel/api/server/getNewX25519Cert',
@@ -682,15 +688,15 @@ export const sections: readonly Section[] = [
       },
       {
         method: 'POST',
-        path: '/panel/api/clients/onlinesByNode',
-        summary: 'Online client emails grouped by the node that reported them. The local panel uses key "0"; each remote node uses its node id. Lets the inbounds page show online status per node instead of merging every node together.',
-        response: '{\n  "success": true,\n  "obj": {\n    "0": ["user1"],\n    "3": ["user1", "user2"]\n  }\n}',
+        path: '/panel/api/clients/onlinesByGuid',
+        summary: 'Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.',
+        response: '{\n  "success": true,\n  "obj": {\n    "a1b2-...": ["user1"],\n    "c3d4-...": ["user1", "user2"]\n  }\n}',
       },
       {
         method: 'POST',
         path: '/panel/api/clients/activeInbounds',
-        summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by node (local panel uses key "0"). Pairs with onlinesByNode so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
-        response: '{\n  "success": true,\n  "obj": {\n    "0": ["inbound-443", "inbound-8443"]\n  }\n}',
+        summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by the hosting node\'s panelGuid. Pairs with onlinesByGuid so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
+        response: '{\n  "success": true,\n  "obj": {\n    "a1b2-...": ["inbound-443", "inbound-8443"]\n  }\n}',
       },
       {
         method: 'POST',

+ 40 - 34
frontend/src/pages/inbounds/useInbounds.ts

@@ -58,13 +58,14 @@ async function fetchOnlineClients(): Promise<string[]> {
   return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
-// Online emails grouped by node id (local panel = key 0), used to scope the
-// per-inbound online rollup so a client online on one node is not shown
-// online on every node's inbounds.
-async function fetchOnlineClientsByNode(): Promise<Record<string, string[]>> {
-  const msg = await HttpUtil.post('/panel/api/clients/onlinesByNode', undefined, { silent: true });
-  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByNode');
-  const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByNode');
+// Online emails grouped by the panelGuid of the node that physically hosts each
+// client, used to scope the per-inbound online rollup so a client online on one
+// node is not shown online on every node's inbounds — and a client on a
+// sub-node is attributed to that sub-node, not the node it syncs through (#4983).
+async function fetchOnlineClientsByGuid(): Promise<Record<string, string[]>> {
+  const msg = await HttpUtil.post('/panel/api/clients/onlinesByGuid', undefined, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByGuid');
+  const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByGuid');
   return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
 }
 
@@ -79,11 +80,11 @@ async function fetchActiveInboundsByNode(): Promise<Record<string, string[]>> {
   return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
 }
 
-function toNodeOnlineMap(data: Record<string, string[]>): Map<number, Set<string>> {
-  const map = new Map<number, Set<string>>();
+function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string>> {
+  const map = new Map<string, Set<string>>();
   for (const [key, emails] of Object.entries(data)) {
     if (!Array.isArray(emails)) continue;
-    map.set(Number(key), new Set(emails));
+    map.set(key, new Set(emails));
   }
   return map;
 }
@@ -117,9 +118,9 @@ export function useInbounds() {
     staleTime: Infinity,
   });
 
-  const onlinesByNodeQuery = useQuery({
-    queryKey: keys.clients.onlinesByNode(),
-    queryFn: fetchOnlineClientsByNode,
+  const onlinesByGuidQuery = useQuery({
+    queryKey: keys.clients.onlinesByGuid(),
+    queryFn: fetchOnlineClientsByGuid,
     staleTime: Infinity,
   });
 
@@ -182,16 +183,17 @@ export function useInbounds() {
   const onlineClientsRef = useRef<string[]>([]);
   onlineClientsRef.current = onlineClients;
 
-  // Online emails keyed by node id (local inbounds = key 0). The rollup
-  // reads this so each inbound only counts clients online on its own node.
-  const onlineByNodeRef = useRef<Map<number, Set<string>>>(new Map());
+  // Online emails keyed by the hosting node's panelGuid. The rollup reads this
+  // so each inbound only counts clients online on the node that physically
+  // hosts it, attributing a sub-node's clients to that sub-node (#4983).
+  const onlineByGuidRef = useRef<Map<string, Set<string>>>(new Map());
 
-  // Recently-active inbound tags keyed by node id. A node missing from this
-  // map means "no per-inbound activity reported" (e.g. remote nodes), so the
-  // rollup leaves that node's inbounds ungated and falls back to the email
-  // signal. A present node gates: a client only counts online on an inbound
-  // whose tag carried traffic this window.
-  const activeByNodeRef = useRef<Map<number, Set<string>>>(new Map());
+  // Recently-active inbound tags keyed by the hosting node's panelGuid. A GUID
+  // missing from this map means "no per-inbound activity reported" (e.g. remote
+  // nodes), so the rollup leaves that node's inbounds ungated and falls back to
+  // the email signal. A present GUID gates: a client only counts online on an
+  // inbound whose tag carried traffic this window.
+  const activeByGuidRef = useRef<Map<string, Set<string>>>(new Map());
 
   const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
 
@@ -209,13 +211,17 @@ export function useInbounds() {
       const comments = new Map<string, string>();
       const now = Date.now();
 
-      const nodeId = dbInbound.nodeId ?? 0;
-      const nodeOnline = onlineByNodeRef.current.get(nodeId);
+      // Attribution key: the GUID of the node that physically hosts this
+      // inbound. Local inbounds carry the panel's own GUID (filled server-side);
+      // a node-managed inbound carries its origin node's GUID, or falls back to
+      // the master-local synthetic id for an old-build node without one (#4983).
+      const guid = dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
+      const nodeOnline = onlineByGuidRef.current.get(guid);
       // A node absent from the active map reports no per-inbound activity, so
       // leave its inbounds ungated. When present, only mark a client online on
       // this inbound if its tag actually carried traffic — that's what stops a
       // multi-inbound client lighting up every inbound it's attached to.
-      const activeForNode = activeByNodeRef.current.get(nodeId);
+      const activeForNode = activeByGuidRef.current.get(guid);
       const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
 
       if (dbInbound.enable) {
@@ -305,15 +311,15 @@ export function useInbounds() {
   }, [onlinesQuery.data]);
 
   useEffect(() => {
-    if (onlinesByNodeQuery.data) {
-      onlineByNodeRef.current = toNodeOnlineMap(onlinesByNodeQuery.data);
+    if (onlinesByGuidQuery.data) {
+      onlineByGuidRef.current = toGuidOnlineMap(onlinesByGuidQuery.data);
       rebuildClientCount();
     }
-  }, [onlinesByNodeQuery.data, rebuildClientCount]);
+  }, [onlinesByGuidQuery.data, rebuildClientCount]);
 
   useEffect(() => {
     if (activeInboundsQuery.data) {
-      activeByNodeRef.current = toNodeOnlineMap(activeInboundsQuery.data);
+      activeByGuidRef.current = toGuidOnlineMap(activeInboundsQuery.data);
       rebuildClientCount();
     }
   }, [activeInboundsQuery.data, rebuildClientCount]);
@@ -336,7 +342,7 @@ export function useInbounds() {
     await Promise.all([
       queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
-      queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByNode() }),
+      queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
       queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
@@ -367,16 +373,16 @@ export function useInbounds() {
   const applyTrafficEvent = useCallback(
     (payload: unknown) => {
       if (!payload || typeof payload !== 'object') return;
-      const p = payload as { onlineClients?: string[]; onlineByNode?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
+      const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
       if (Array.isArray(p.onlineClients)) {
         onlineClientsRef.current = p.onlineClients;
         setOnlineClients(p.onlineClients);
       }
-      if (p.onlineByNode && typeof p.onlineByNode === 'object') {
-        onlineByNodeRef.current = toNodeOnlineMap(p.onlineByNode);
+      if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
+        onlineByGuidRef.current = toGuidOnlineMap(p.onlineByGuid);
       }
       if (p.activeInbounds && typeof p.activeInbounds === 'object') {
-        activeByNodeRef.current = toNodeOnlineMap(p.activeInbounds);
+        activeByGuidRef.current = toGuidOnlineMap(p.activeInbounds);
       }
       if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
         setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));

+ 72 - 15
frontend/src/pages/nodes/NodeList.tsx

@@ -15,6 +15,7 @@ import {
 import type { BadgeProps } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import {
+  ApartmentOutlined,
   ClusterOutlined,
   CloudDownloadOutlined,
   DeleteOutlined,
@@ -56,7 +57,7 @@ function isUpdateEligible(n: NodeRecord): boolean {
 
 interface NodeRow extends NodeRecord {
   url: string;
-  key: number;
+  key: string | number;
 }
 
 function badgeStatus(status?: string): BadgeProps['status'] {
@@ -131,14 +132,49 @@ export default function NodeList({
   const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
   const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
 
-  const dataSource = useMemo<NodeRow[]>(
-    () => nodes.map((n) => ({
+  // Map a node GUID to its display name so a transitive sub-node can show which
+  // parent it is reached through (#4983).
+  const nameByGuid = useMemo(() => {
+    const m = new Map<string, string>();
+    for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
+    return m;
+  }, [nodes]);
+
+  // Order direct nodes first, each immediately followed by its transitive
+  // sub-nodes, so the table reads as a parent -> child tree without colliding
+  // with the per-row history expander (transitive nodes carry id 0).
+  const dataSource = useMemo<NodeRow[]>(() => {
+    const toRow = (n: NodeRecord): NodeRow => ({
       ...n,
       url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
-      key: n.id,
-    })),
-    [nodes],
-  );
+      key: n.transitive ? `t-${n.guid || ''}` : n.id,
+    });
+    const childrenByParent = new Map<string, NodeRecord[]>();
+    for (const n of nodes) {
+      if (n.transitive && n.parentGuid) {
+        const arr = childrenByParent.get(n.parentGuid) || [];
+        arr.push(n);
+        childrenByParent.set(n.parentGuid, arr);
+      }
+    }
+    const ordered: NodeRow[] = [];
+    const added = new Set<string>();
+    const push = (n: NodeRecord) => {
+      const row = toRow(n);
+      ordered.push(row);
+      added.add(String(row.key));
+    };
+    for (const n of nodes) {
+      if (n.transitive) continue;
+      push(n);
+      if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
+    }
+    // Transitive nodes whose parent isn't in the list still get shown.
+    for (const n of nodes) {
+      if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
+    }
+    return ordered;
+  }, [nodes]);
 
   function toggleExpanded(id: number) {
     setExpandedIds((prev) => {
@@ -153,7 +189,11 @@ export default function NodeList({
       title: t('pages.nodes.actions'),
       align: 'center',
       width: 190,
-      render: (_value, record) => (
+      render: (_value, record) => record.transitive ? (
+        <Tooltip title={t('pages.nodes.subNodeTip', { parent: record.parentGuid ? (nameByGuid.get(record.parentGuid) || '-') : '-' })}>
+          <Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
+        </Tooltip>
+      ) : (
         <Space>
           <Tooltip title={t('pages.nodes.probe')}>
             <Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
@@ -177,7 +217,9 @@ export default function NodeList({
       dataIndex: 'enable',
       align: 'center',
       width: 80,
-      render: (_value, record) => (
+      render: (_value, record) => record.transitive ? (
+        <span style={{ opacity: 0.4 }}>—</span>
+      ) : (
         <Switch
           checked={!!record.enable}
           size="small"
@@ -190,8 +232,11 @@ export default function NodeList({
       dataIndex: 'name',
       ellipsis: true,
       render: (_value, record) => (
-        <div className="name-cell">
-          <span className="name">{record.name}</span>
+        <div className="name-cell" style={record.transitive ? { paddingInlineStart: 20 } : undefined}>
+          <span className="name">
+            {record.transitive && <ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />}
+            {record.name}
+          </span>
           {record.remark && <span className="remark">{record.remark}</span>}
         </div>
       ),
@@ -316,7 +361,7 @@ export default function NodeList({
       width: 120,
       render: (_value, record) => relativeTime(record.lastHeartbeat),
     },
-  ], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode]);
+  ], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode, nameByGuid]);
 
   return (
     <Card size="small" hoverable>
@@ -340,7 +385,18 @@ export default function NodeList({
                 <div>{t('noData')}</div>
               </div>
             ) : (
-              dataSource.map((record) => (
+              dataSource.map((record) => record.transitive ? (
+                <div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
+                  <div className="card-head">
+                    <ApartmentOutlined style={{ opacity: 0.6 }} />
+                    <StatusDot status={record.status} />
+                    <span className="node-name">{record.name}</span>
+                    <div className="card-actions">
+                      <Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
+                    </div>
+                  </div>
+                </div>
+              ) : (
                 <div key={record.id} className="node-card">
                   <div className="card-head" onClick={() => toggleExpanded(record.id)}>
                     <RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
@@ -501,8 +557,8 @@ export default function NodeList({
           rowKey="id"
           rowSelection={dataSource.length > 1 ? {
             selectedRowKeys: selectedIds,
-            onChange: (keys) => onSelectionChange(keys as number[]),
-            getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }),
+            onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
+            getCheckboxProps: (record) => ({ disabled: !!record.transitive || !isUpdateEligible(record) }),
           } : undefined}
           locale={{
             emptyText: (
@@ -514,6 +570,7 @@ export default function NodeList({
           }}
           expandable={{
             expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
+            rowExpandable: (record) => !record.transitive,
           }}
         />
       )}

+ 5 - 0
frontend/src/schemas/node.ts

@@ -26,6 +26,11 @@ export const NodeRecordSchema = z.object({
   allowPrivateAddress: z.boolean().optional(),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
   pinnedCertSha256: z.string().optional(),
+  // Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
+  // whether it's a read-only transitive sub-node surfaced from a downstream node.
+  guid: z.string().optional(),
+  parentGuid: z.string().optional(),
+  transitive: z.boolean().optional(),
 }).loose();
 
 export const NodeListSchema = z.array(NodeRecordSchema);

+ 4 - 4
web/controller/client.go

@@ -72,7 +72,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/ips/:email", a.getIps)
 	g.POST("/clearIps/:email", a.clearIps)
 	g.POST("/onlines", a.onlines)
-	g.POST("/onlinesByNode", a.onlinesByNode)
+	g.POST("/onlinesByGuid", a.onlinesByGuid)
 	g.POST("/activeInbounds", a.activeInbounds)
 	g.POST("/lastOnline", a.lastOnline)
 }
@@ -417,12 +417,12 @@ func (a *ClientController) onlines(c *gin.Context) {
 	jsonObj(c, a.inboundService.GetOnlineClients(), nil)
 }
 
-func (a *ClientController) onlinesByNode(c *gin.Context) {
-	jsonObj(c, a.inboundService.GetOnlineClientsByNode(), nil)
+func (a *ClientController) onlinesByGuid(c *gin.Context) {
+	jsonObj(c, a.inboundService.GetOnlineClientsByGuid(), nil)
 }
 
 func (a *ClientController) activeInbounds(c *gin.Context) {
-	jsonObj(c, a.inboundService.GetActiveInboundsByNode(), nil)
+	jsonObj(c, a.inboundService.GetActiveInboundsByGuid(), nil)
 }
 
 func (a *ClientController) lastOnline(c *gin.Context) {

+ 1 - 1
web/controller/node.go

@@ -43,7 +43,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
 }
 
 func (a *NodeController) list(c *gin.Context) {
-	nodes, err := a.nodeService.GetAll()
+	nodes, err := a.nodeService.GetNodeTree()
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
 		return

+ 9 - 0
web/controller/server.go

@@ -56,6 +56,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.GET("/getMigration", a.getMigration)
 	g.GET("/getNewUUID", a.getNewUUID)
 	g.GET("/getWebCertFiles", a.getWebCertFiles)
+	g.GET("/descendants", a.descendants)
 	g.GET("/getNewX25519Cert", a.getNewX25519Cert)
 	g.GET("/getNewmldsa65", a.getNewmldsa65)
 	g.GET("/getNewmlkem768", a.getNewmlkem768)
@@ -334,6 +335,14 @@ func (a *ServerController) importDB(c *gin.Context) {
 	jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
 }
 
+// descendants publishes read-only summaries of the nodes this panel manages so
+// a parent panel can surface them as transitive sub-nodes in a chained
+// topology. Called by the parent via the node's API token (#4983).
+func (a *ServerController) descendants(c *gin.Context) {
+	data, err := (&service.NodeService{}).LocalDescendants()
+	jsonObj(c, data, err)
+}
+
 // getWebCertFiles returns this panel's own web TLS certificate and key file
 // paths. The central panel calls it on a node (via the node's API token) so
 // "Set Cert from Panel" can fill a node-assigned inbound with paths that exist

+ 11 - 1
web/job/node_heartbeat_job.go

@@ -59,7 +59,7 @@ func (j *NodeHeartbeatJob) Run() {
 	if !websocket.HasClients() {
 		return
 	}
-	updated, err := j.nodeService.GetAll()
+	updated, err := j.nodeService.GetNodeTree()
 	if err != nil {
 		logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
 		return
@@ -79,4 +79,14 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
 	if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
 		logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
 	}
+	// Learn the nodes this node manages so the panel can surface them as
+	// transitive sub-nodes (#4983). Fresh context — the probe budget above may
+	// be spent. Drop them when the node is unreachable.
+	if patch.Status == "online" {
+		dctx, dcancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
+		j.nodeService.RefreshDescendants(dctx, n)
+		dcancel()
+	} else {
+		j.nodeService.ClearDescendants(n.Id)
+	}
 }

+ 2 - 2
web/job/node_traffic_sync_job.go

@@ -125,8 +125,8 @@ func (j *NodeTrafficSyncJob) Run() {
 	}
 	websocket.BroadcastTraffic(map[string]any{
 		"onlineClients":  online,
-		"onlineByNode":   j.inboundService.GetOnlineClientsByNode(),
-		"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
+		"onlineByGuid":   j.inboundService.GetOnlineClientsByGuid(),
+		"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
 		"lastOnlineMap":  lastOnline,
 	})
 

+ 2 - 2
web/job/xray_traffic_job.go

@@ -107,8 +107,8 @@ func (j *XrayTrafficJob) Run() {
 		"traffics":       traffics,
 		"clientTraffics": clientTraffics,
 		"onlineClients":  onlineClients,
-		"onlineByNode":   j.inboundService.GetOnlineClientsByNode(),
-		"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
+		"onlineByGuid":   j.inboundService.GetOnlineClientsByGuid(),
+		"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
 		"lastOnlineMap":  lastOnlineMap,
 	})
 

+ 39 - 7
web/runtime/remote.go

@@ -370,6 +370,24 @@ func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) {
 	return &files, nil
 }
 
+// GetDescendants fetches the node's read-only summaries of the nodes IT
+// manages, so this panel can surface them as transitive sub-nodes in a chained
+// topology (#4983). Best-effort: an old-build node without the endpoint returns
+// an error the caller ignores.
+func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error) {
+	env, err := r.do(ctx, http.MethodGet, "panel/api/server/descendants", nil)
+	if err != nil {
+		return nil, err
+	}
+	var out []model.NodeSummary
+	if len(env.Obj) > 0 {
+		if err := json.Unmarshal(env.Obj, &out); err != nil {
+			return nil, fmt.Errorf("decode descendants: %w", err)
+		}
+	}
+	return out, nil
+}
+
 func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
 	_, err := r.do(ctx, http.MethodPost,
 		"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
@@ -382,8 +400,14 @@ func (r *Remote) ResetAllTraffics(ctx context.Context) error {
 }
 
 type TrafficSnapshot struct {
-	Inbounds      []*model.Inbound
-	OnlineEmails  []string
+	Inbounds     []*model.Inbound
+	OnlineEmails []string
+	// OnlineTree is the node's GUID-keyed online subtree (its own clients under
+	// its panelGuid plus every descendant under theirs). Preferred over the flat
+	// OnlineEmails so the master can attribute deeply nested clients to the real
+	// node across a chain (#4983). Empty when the node is an old build without
+	// the per-GUID endpoint — OnlineEmails is the fallback then.
+	OnlineTree    map[string][]string
 	LastOnlineMap map[string]int64
 }
 
@@ -398,11 +422,19 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
 		return nil, fmt.Errorf("decode inbound list: %w", err)
 	}
 
-	envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
-	if err != nil {
-		logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
-	} else if len(envOnlines.Obj) > 0 {
-		_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
+	// Prefer the GUID-keyed subtree; fall back to the flat list only when the
+	// node is an old build without the per-GUID endpoint (#4983).
+	envTree, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlinesByGuid", nil)
+	if err == nil && len(envTree.Obj) > 0 {
+		_ = json.Unmarshal(envTree.Obj, &snap.OnlineTree)
+	}
+	if len(snap.OnlineTree) == 0 {
+		envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
+		if err != nil {
+			logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
+		} else if len(envOnlines.Obj) > 0 {
+			_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
+		}
 	}
 
 	envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)

+ 123 - 9
web/service/inbound.go

@@ -231,9 +231,30 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
 	}
 	s.enrichClientStats(db, inbounds)
 	s.annotateFallbackParents(db, inbounds)
+	s.annotateLocalOriginGuid(inbounds)
 	return inbounds, nil
 }
 
+// annotateLocalOriginGuid fills OriginNodeGuid for this panel's OWN inbounds
+// (NodeID == nil) with the panel's stable GUID; inbounds synced from a node
+// already carry the originating node's GUID. Read-time only (not persisted) so
+// the per-inbound online view can scope by GUID uniformly across a chain of
+// nodes (#4983).
+func (s *InboundService) annotateLocalOriginGuid(inbounds []*model.Inbound) {
+	if len(inbounds) == 0 {
+		return
+	}
+	guid := s.panelGuid()
+	if guid == "" {
+		return
+	}
+	for _, ib := range inbounds {
+		if ib.OriginNodeGuid == "" && ib.NodeID == nil {
+			ib.OriginNodeGuid = guid
+		}
+	}
+}
+
 // 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
@@ -252,6 +273,7 @@ func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) {
 		return nil, err
 	}
 	s.annotateFallbackParents(db, inbounds)
+	s.annotateLocalOriginGuid(inbounds)
 	for _, ib := range inbounds {
 		ib.Settings = slimSettingsClients(ib.Settings)
 	}
@@ -1453,6 +1475,21 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 	db := database.GetDB()
 	now := time.Now().UnixMilli()
 
+	// originGuidFor attributes a synced inbound to the panel that physically
+	// hosts it: inbounds the node forwards from its own sub-nodes already carry
+	// a non-empty OriginNodeGuid (kept as-is across hops); the node's own local
+	// inbounds report empty, so they are attributed to the node's own GUID. An
+	// empty result (old-build node with no GUID yet) leaves attribution to the
+	// node_id fallback downstream (#4983).
+	var nodeRow model.Node
+	db.Select("guid").Where("id = ?", nodeID).First(&nodeRow)
+	originGuidFor := func(snapIb *model.Inbound) string {
+		if snapIb.OriginNodeGuid != "" {
+			return snapIb.OriginNodeGuid
+		}
+		return nodeRow.Guid
+	}
+
 	var central []model.Inbound
 	if err := db.Model(model.Inbound{}).
 		Where("node_id = ?", nodeID).
@@ -1598,6 +1635,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			newIb := model.Inbound{
 				UserId:         defaultUserId,
 				NodeID:         &nodeID,
+				OriginNodeGuid: originGuidFor(snapIb),
 				Tag:            chosenTag,
 				Listen:         snapIb.Listen,
 				Port:           snapIb.Port,
@@ -1645,6 +1683,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			updates["up"] = snapIb.Up
 			updates["down"] = snapIb.Down
 		}
+		// Physical-home attribution is independent of config-dirty state, so
+		// keep it current even while the node has pending offline edits. Writes
+		// once to backfill an existing row, then stays equal (#4983).
+		if og := originGuidFor(snapIb); c.OriginNodeGuid != og {
+			updates["origin_node_guid"] = og
+		}
 
 		if !dirty && (c.Settings != snapIb.Settings ||
 			c.Remark != snapIb.Remark ||
@@ -1933,7 +1977,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 	committed = true
 
 	if p != nil {
-		p.SetNodeOnlineClients(nodeID, snap.OnlineEmails)
+		tree := snap.OnlineTree
+		if len(tree) == 0 && len(snap.OnlineEmails) > 0 {
+			// Old-build node (no GUID tree): key its flat online list under its
+			// own effective identity so attribution still works for that branch.
+			effectiveGuid := nodeRow.Guid
+			if effectiveGuid == "" {
+				effectiveGuid = synthNodeGuid(nodeID)
+			}
+			tree = map[string][]string{effectiveGuid: snap.OnlineEmails}
+		}
+		p.SetNodeOnlineTree(nodeID, tree)
 	}
 
 	return structuralChange, nil
@@ -3634,23 +3688,46 @@ func (s *InboundService) GetOnlineClients() []string {
 	return p.GetOnlineClients()
 }
 
-func (s *InboundService) GetOnlineClientsByNode() map[int][]string {
+// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
+// node that physically hosts each set: this panel's own clients under its own
+// GUID, plus every node in the tree under its GUID (#4983). Replaces the old
+// node-id keying so a client three hops down is attributed to its real node,
+// not the intermediate one it was synced through.
+func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
 	if p == nil {
-		return map[int][]string{}
+		return map[string][]string{}
 	}
-	return p.GetOnlineClientsByNode()
+	out := p.GetMergedNodeTrees()
+	if local := p.GetLocalOnlineClients(); len(local) > 0 {
+		if guid := s.panelGuid(); guid != "" {
+			out[guid] = mergeEmails(out[guid], local)
+		}
+	}
+	return out
 }
 
-func (s *InboundService) GetActiveInboundsByNode() map[int][]string {
+// GetActiveInboundsByGuid returns the inbound tags that carried traffic within
+// the grace window for THIS panel, under its own GUID. Remote nodes don't
+// report per-inbound activity, so a GUID missing from the map means "don't
+// gate" for that node's inbounds.
+func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
 	if p == nil {
-		return map[int][]string{}
+		return map[string][]string{}
+	}
+	active := p.GetLocalActiveInbounds()
+	if len(active) == 0 {
+		return map[string][]string{}
+	}
+	guid := s.panelGuid()
+	if guid == "" {
+		return map[string][]string{}
 	}
-	return p.GetActiveInboundsByNode()
+	return map[string][]string{guid: active}
 }
 
-func (s *InboundService) SetNodeOnlineClients(nodeID int, emails []string) {
+func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
 	if p != nil {
-		p.SetNodeOnlineClients(nodeID, emails)
+		p.SetNodeOnlineTree(nodeID, tree)
 	}
 }
 
@@ -3660,6 +3737,43 @@ func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
 	}
 }
 
+// panelGuid returns this panel's stable self-identifier, used to key the local
+// panel's own clients in the per-node online maps (#4983).
+func (s *InboundService) panelGuid() string {
+	guid, _ := (&SettingService{}).GetPanelGuid()
+	return guid
+}
+
+// synthNodeGuid is the stable per-node fallback identity for a directly-attached
+// node whose panel hasn't reported a panelGuid yet (old build). Node ids are
+// master-local, so this only composes for direct nodes — exactly the pre-#4983
+// flat-topology case where an old-build node appears.
+func synthNodeGuid(nodeID int) string {
+	return fmt.Sprintf("node:%d", nodeID)
+}
+
+// mergeEmails returns the deduped union of two email slices.
+func mergeEmails(a, b []string) []string {
+	if len(a) == 0 {
+		return b
+	}
+	seen := make(map[string]struct{}, len(a)+len(b))
+	out := make([]string, 0, len(a)+len(b))
+	for _, e := range a {
+		if _, ok := seen[e]; !ok {
+			seen[e] = struct{}{}
+			out = append(out, e)
+		}
+	}
+	for _, e := range b {
+		if _, ok := seen[e]; !ok {
+			seen[e] = struct{}{}
+			out = append(out, e)
+		}
+	}
+	return out
+}
+
 func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
 	db := database.GetDB()
 	var rows []xray.ClientTraffic

+ 28 - 15
web/service/node.go

@@ -30,6 +30,7 @@ type HeartbeatPatch struct {
 	LatencyMs     int
 	XrayVersion   string
 	PanelVersion  string
+	Guid          string
 	CpuPct        float64
 	MemPct        float64
 	UptimeSecs    uint64
@@ -224,9 +225,7 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 		Select("inbound_id, email, enable, total, up, down, expiry_time").
 		Where("inbound_id IN ?", inboundIDs).
 		Scan(&trafficRows).Error; err == nil {
-		onlineByNodeSet := s.onlineEmailsByNode()
 		depletedByNode := make(map[int]int)
-		onlineByNode := make(map[int]int)
 		for _, row := range trafficRows {
 			nodeID, ok := nodeByInbound[row.InboundID]
 			if !ok {
@@ -237,38 +236,45 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 			if expired || exhausted || !row.Enable {
 				depletedByNode[nodeID]++
 			}
-			// Scope online by the node the inbound lives on: a client online
-			// on one node must not count as online on another.
-			if set, ok := onlineByNodeSet[nodeID]; ok {
-				if _, isOnline := set[row.Email]; isOnline {
-					onlineByNode[nodeID]++
-				}
-			}
 		}
+		onlineByGuid := s.onlineEmailsByGuid()
 		for _, n := range nodes {
 			n.InboundCount = len(inboundsByNode[n.Id])
 			n.DepletedCount = depletedByNode[n.Id]
-			n.OnlineCount = onlineByNode[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)])
 		}
 	}
 
 	return nodes, nil
 }
 
-func (s *NodeService) onlineEmailsByNode() map[int]map[string]struct{} {
+func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
 	svc := InboundService{}
-	byNode := svc.GetOnlineClientsByNode()
-	out := make(map[int]map[string]struct{}, len(byNode))
-	for nodeID, emails := range byNode {
+	byGuid := svc.GetOnlineClientsByGuid()
+	out := make(map[string]map[string]struct{}, len(byGuid))
+	for guid, emails := range byGuid {
 		set := make(map[string]struct{}, len(emails))
 		for _, email := range emails {
 			set[email] = struct{}{}
 		}
-		out[nodeID] = set
+		out[guid] = set
 	}
 	return out
 }
 
+// effectiveNodeGuid is a node's stable online-attribution key: its reported
+// panelGuid, or a master-local synthetic id when the node is an old build that
+// hasn't reported one yet (#4983).
+func effectiveNodeGuid(n *model.Node) string {
+	if n.Guid != "" {
+		return n.Guid
+	}
+	return synthNodeGuid(n.Id)
+}
+
 func (s *NodeService) GetById(id int) (*model.Node, error) {
 	db := database.GetDB()
 	n := &model.Node{}
@@ -469,6 +475,11 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 		"uptime_secs":    p.UptimeSecs,
 		"last_error":     p.LastError,
 	}
+	// Only learn the GUID; never clear a known one if an old-build node (or a
+	// failed probe) reports none, so the stable identity survives blips.
+	if p.Guid != "" {
+		updates["guid"] = p.Guid
+	}
 	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		return err
 	}
@@ -599,6 +610,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 				Version string `json:"version"`
 			} `json:"xray"`
 			PanelVersion string `json:"panelVersion"`
+			PanelGuid    string `json:"panelGuid"`
 			Uptime       uint64 `json:"uptime"`
 		} `json:"obj"`
 	}
@@ -617,6 +629,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 	}
 	patch.XrayVersion = o.Xray.Version
 	patch.PanelVersion = o.PanelVersion
+	patch.Guid = o.PanelGuid
 	patch.UptimeSecs = o.Uptime
 	return patch, nil
 }

+ 71 - 0
web/service/node_origin_guid_test.go

@@ -0,0 +1,71 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/runtime"
+)
+
+// #4983: a synced inbound's OriginNodeGuid must point at the panel that
+// physically hosts it. A node's own local inbound (empty origin in its
+// snapshot) is attributed to the node's own GUID; an inbound the node forwards
+// from its own sub-node (non-empty origin) keeps that deeper GUID across the
+// hop — so a chained Node1->Node2->Node3 attributes Node3's inbounds to Node3.
+func TestSetRemoteTraffic_AttributesOriginNodeGuid(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	const nodeID = 1
+	if err := db.Create(&model.Node{
+		Id:       nodeID,
+		Name:     "node2",
+		Address:  "10.0.0.2",
+		Port:     2053,
+		ApiToken: "t",
+		Guid:     "node2-guid",
+	}).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{
+			{ // node2's own local inbound — reports no origin
+				Tag:      "in-443-tcp",
+				Enable:   true,
+				Port:     443,
+				Protocol: model.VLESS,
+				Settings: `{"clients":[]}`,
+			},
+			{ // forwarded from node2's sub-node (node3) — carries node3's guid
+				Tag:            "in-8443-tcp",
+				Enable:         true,
+				Port:           8443,
+				Protocol:       model.VLESS,
+				Settings:       `{"clients":[]}`,
+				OriginNodeGuid: "node3-guid",
+			},
+		},
+	}
+
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	origin := func(tag string) string {
+		var ib model.Inbound
+		if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil {
+			t.Fatalf("load inbound %q: %v", tag, err)
+		}
+		return ib.OriginNodeGuid
+	}
+
+	if og := origin("in-443-tcp"); og != "node2-guid" {
+		t.Fatalf("local inbound origin = %q, want node2-guid (the node's own GUID)", og)
+	}
+	if og := origin("in-8443-tcp"); og != "node3-guid" {
+		t.Fatalf("forwarded inbound origin = %q, want node3-guid (kept across the hop)", og)
+	}
+}

+ 226 - 0
web/service/node_tree.go

@@ -0,0 +1,226 @@
+package service
+
+import (
+	"context"
+	"sync"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/runtime"
+)
+
+// LocalDescendants returns this panel's read-only summaries of the nodes it
+// directly manages, so a parent panel can surface them as transitive sub-nodes
+// (#4983). Only nodes with a known GUID are included — a stable identity is
+// required to attribute them one hop up. Not recursive: each panel reports its
+// own direct nodes, and a master walks one level via each direct node's
+// endpoint, which covers the Node1 -> Node2 -> Node3 case.
+func (s *NodeService) LocalDescendants() ([]model.NodeSummary, error) {
+	selfGuid, _ := (&SettingService{}).GetPanelGuid()
+	db := database.GetDB()
+	var nodes []*model.Node
+	if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil {
+		return nil, err
+	}
+	out := make([]model.NodeSummary, 0, len(nodes))
+	for _, n := range nodes {
+		if n.Guid == "" {
+			continue
+		}
+		out = append(out, model.NodeSummary{
+			Guid:          n.Guid,
+			ParentGuid:    selfGuid,
+			Name:          n.Name,
+			Address:       n.Address,
+			Scheme:        n.Scheme,
+			Port:          n.Port,
+			Status:        n.Status,
+			LastHeartbeat: n.LastHeartbeat,
+			LatencyMs:     n.LatencyMs,
+			PanelVersion:  n.PanelVersion,
+			XrayVersion:   n.XrayVersion,
+		})
+	}
+	return out, nil
+}
+
+var (
+	nodeDescendantsMu    sync.RWMutex
+	nodeDescendantsCache = map[int][]model.NodeSummary{}
+)
+
+// RefreshDescendants pulls a direct node's published sub-node summaries and
+// caches them keyed by node id. Best-effort: a fetch error keeps the last good
+// set (the node may be briefly unreachable). Called from the heartbeat job.
+func (s *NodeService) RefreshDescendants(ctx context.Context, n *model.Node) {
+	if n == nil {
+		return
+	}
+	mgr := runtime.GetManager()
+	if mgr == nil {
+		return
+	}
+	rt, err := mgr.RemoteFor(n)
+	if err != nil {
+		return
+	}
+	summaries, err := rt.GetDescendants(ctx)
+	if err != nil {
+		return
+	}
+	nodeDescendantsMu.Lock()
+	if len(summaries) == 0 {
+		delete(nodeDescendantsCache, n.Id)
+	} else {
+		nodeDescendantsCache[n.Id] = summaries
+	}
+	nodeDescendantsMu.Unlock()
+}
+
+// ClearDescendants drops a node's cached sub-node summaries (its probe failed).
+func (s *NodeService) ClearDescendants(nodeID int) {
+	nodeDescendantsMu.Lock()
+	delete(nodeDescendantsCache, nodeID)
+	nodeDescendantsMu.Unlock()
+}
+
+func cachedDescendants() []model.NodeSummary {
+	nodeDescendantsMu.RLock()
+	defer nodeDescendantsMu.RUnlock()
+	out := make([]model.NodeSummary, 0)
+	for _, list := range nodeDescendantsCache {
+		out = append(out, list...)
+	}
+	return out
+}
+
+// GetNodeTree returns the direct nodes plus any transitive sub-nodes learned
+// from them, with per-GUID counts so each node shows only the inbounds/online
+// it physically hosts (#4983). Direct nodes carry the master's own GUID as
+// ParentGuid; a transitive node carries its parent node's GUID. Transitive
+// nodes are read-only projections (Id == 0). Used by the Nodes page and the
+// heartbeat broadcast — never for probing/syncing, which stay on GetAll.
+func (s *NodeService) GetNodeTree() ([]*model.Node, error) {
+	nodes, err := s.GetAll()
+	if err != nil {
+		return nodes, err
+	}
+	selfGuid, _ := (&SettingService{}).GetPanelGuid()
+	directGuids := make(map[string]struct{}, len(nodes))
+	for _, n := range nodes {
+		n.ParentGuid = selfGuid
+		if n.Guid != "" {
+			directGuids[n.Guid] = struct{}{}
+		}
+	}
+
+	seen := make(map[string]struct{})
+	var transitive []*model.Node
+	for _, sum := range cachedDescendants() {
+		if sum.Guid == "" {
+			continue
+		}
+		if _, ok := directGuids[sum.Guid]; ok {
+			continue // already shown as a direct node
+		}
+		if _, ok := seen[sum.Guid]; ok {
+			continue
+		}
+		seen[sum.Guid] = struct{}{}
+		transitive = append(transitive, &model.Node{
+			Guid:          sum.Guid,
+			ParentGuid:    sum.ParentGuid,
+			Name:          sum.Name,
+			Address:       sum.Address,
+			Scheme:        sum.Scheme,
+			Port:          sum.Port,
+			Status:        sum.Status,
+			LastHeartbeat: sum.LastHeartbeat,
+			LatencyMs:     sum.LatencyMs,
+			PanelVersion:  sum.PanelVersion,
+			XrayVersion:   sum.XrayVersion,
+			Transitive:    true,
+		})
+	}
+	if len(transitive) == 0 {
+		return nodes, nil
+	}
+
+	all := make([]*model.Node, 0, len(nodes)+len(transitive))
+	all = append(all, nodes...)
+	all = append(all, transitive...)
+	s.recountByGuid(all, selfGuid)
+	return all, nil
+}
+
+// recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node
+// in the tree, keyed by the GUID that physically hosts each inbound, so a direct
+// node shows only its own inbounds and each transitive node shows its own
+// (#4983). In a flat topology the per-GUID and per-node-id counts coincide, so
+// this only changes behaviour once a transitive node exists.
+func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
+	db := database.GetDB()
+	type ibRow struct {
+		Id             int
+		NodeID         *int   `gorm:"column:node_id"`
+		OriginNodeGuid string `gorm:"column:origin_node_guid"`
+	}
+	var ibRows []ibRow
+	if err := db.Table("inbounds").Select("id, node_id, origin_node_guid").Scan(&ibRows).Error; err != nil {
+		return
+	}
+	effByInbound := make(map[int]string, len(ibRows))
+	inboundCountByGuid := make(map[string]int)
+	ids := make([]int, 0, len(ibRows))
+	for _, r := range ibRows {
+		guid := r.OriginNodeGuid
+		if guid == "" {
+			if r.NodeID != nil {
+				guid = synthNodeGuid(*r.NodeID)
+			} else {
+				guid = selfGuid
+			}
+		}
+		effByInbound[r.Id] = guid
+		inboundCountByGuid[guid]++
+		ids = append(ids, r.Id)
+	}
+
+	now := time.Now().UnixMilli()
+	depletedByGuid := make(map[string]int)
+	if len(ids) > 0 {
+		type tRow struct {
+			InboundID  int `gorm:"column:inbound_id"`
+			Enable     bool
+			Total      int64
+			Up         int64
+			Down       int64
+			ExpiryTime int64 `gorm:"column:expiry_time"`
+		}
+		var tRows []tRow
+		if err := db.Table("client_traffics").
+			Select("inbound_id, enable, total, up, down, expiry_time").
+			Where("inbound_id IN ?", ids).Scan(&tRows).Error; err == nil {
+			for _, row := range tRows {
+				guid, ok := effByInbound[row.InboundID]
+				if !ok {
+					continue
+				}
+				expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
+				exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
+				if expired || exhausted || !row.Enable {
+					depletedByGuid[guid]++
+				}
+			}
+		}
+	}
+
+	onlineByGuid := s.onlineEmailsByGuid()
+	for _, n := range nodes {
+		guid := effectiveNodeGuid(n)
+		n.InboundCount = inboundCountByGuid[guid]
+		n.OnlineCount = len(onlineByGuid[guid])
+		n.DepletedCount = depletedByGuid[guid]
+	}
+}

+ 81 - 0
web/service/node_tree_test.go

@@ -0,0 +1,81 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+// #4983: a transitive sub-node learned from a direct node must surface as its
+// own read-only entry nested under its parent, and per-GUID counts must split a
+// direct node's own inbounds from its sub-nodes'.
+func TestGetNodeTree_SurfacesTransitiveNodeNestedUnderParent(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	svc := NodeService{}
+	selfGuid, _ := (&SettingService{}).GetPanelGuid()
+
+	if err := db.Create(&model.Node{
+		Id: 1, Name: "Node2", Address: "10.0.0.2", Port: 2053,
+		ApiToken: "t", Guid: "node2-guid", Status: "online",
+	}).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	// Node2's own inbound and a transitive inbound physically on Node3
+	// (managed through Node2, so node_id = Node2 but origin = Node3).
+	nid := 1
+	if err := db.Create(&model.Inbound{Tag: "n1-own", Enable: true, Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid, OriginNodeGuid: "node2-guid"}).Error; err != nil {
+		t.Fatalf("create own inbound: %v", err)
+	}
+	if err := db.Create(&model.Inbound{Tag: "n1-via", Enable: true, Port: 8443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid, OriginNodeGuid: "node3-guid"}).Error; err != nil {
+		t.Fatalf("create transitive inbound: %v", err)
+	}
+
+	// The heartbeat learned that Node2 manages Node3.
+	nodeDescendantsMu.Lock()
+	nodeDescendantsCache[1] = []model.NodeSummary{{
+		Guid: "node3-guid", ParentGuid: "node2-guid", Name: "Node3", Address: "10.0.0.3", Status: "online",
+	}}
+	nodeDescendantsMu.Unlock()
+	t.Cleanup(func() {
+		nodeDescendantsMu.Lock()
+		nodeDescendantsCache = map[int][]model.NodeSummary{}
+		nodeDescendantsMu.Unlock()
+	})
+
+	tree, err := svc.GetNodeTree()
+	if err != nil {
+		t.Fatalf("GetNodeTree: %v", err)
+	}
+
+	var node2, node3 *model.Node
+	for _, n := range tree {
+		switch n.Guid {
+		case "node2-guid":
+			node2 = n
+		case "node3-guid":
+			node3 = n
+		}
+	}
+	if node2 == nil || node3 == nil {
+		t.Fatalf("expected Node2 + transitive Node3, got %d nodes", len(tree))
+	}
+	if node2.ParentGuid != selfGuid {
+		t.Errorf("Node2 parent = %q, want this panel's GUID %q", node2.ParentGuid, selfGuid)
+	}
+	if !node3.Transitive || node3.ParentGuid != "node2-guid" {
+		t.Errorf("Node3 should be transitive under node2-guid, got transitive=%v parent=%q", node3.Transitive, node3.ParentGuid)
+	}
+	if node3.Id != 0 {
+		t.Errorf("transitive node must be a read-only projection (Id 0), got Id=%d", node3.Id)
+	}
+	if node2.InboundCount != 1 {
+		t.Errorf("Node2 should host only its own inbound, got InboundCount=%d", node2.InboundCount)
+	}
+	if node3.InboundCount != 1 {
+		t.Errorf("transitive Node3 should host its 1 inbound, got %d", node3.InboundCount)
+	}
+}

+ 4 - 0
web/service/server.go

@@ -81,6 +81,7 @@ type Status struct {
 		Version  string       `json:"version"`
 	} `json:"xray"`
 	PanelVersion string    `json:"panelVersion"`
+	PanelGuid    string    `json:"panelGuid"`
 	Uptime       uint64    `json:"uptime"`
 	Loads        []float64 `json:"loads"`
 	TcpCount     int       `json:"tcpCount"`
@@ -532,6 +533,9 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 	}
 	status.Xray.Version = s.xrayService.GetXrayVersion()
 	status.PanelVersion = config.GetVersion()
+	if guid, err := s.settingService.GetPanelGuid(); err == nil {
+		status.PanelGuid = guid
+	}
 
 	// Application stats
 	var rtm runtime.MemStats

+ 20 - 0
web/service/setting.go

@@ -12,6 +12,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/google/uuid"
 	"github.com/mhsanaei/3x-ui/v3/database"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
@@ -34,6 +35,7 @@ var defaultValueMap = map[string]string{
 	"webCertFile":                 "",
 	"webKeyFile":                  "",
 	"secret":                      random.Seq(32),
+	"panelGuid":                   uuid.NewString(),
 	"apiToken":                    "",
 	"webBasePath":                 "/",
 	"sessionMaxAge":               "360",
@@ -508,6 +510,24 @@ func (s *SettingService) GetSecret() ([]byte, error) {
 	return []byte(secret), err
 }
 
+// GetPanelGuid returns this panel's stable self-identifier, persisting a
+// freshly generated UUID on first read. It is the globally stable node
+// identity used to attribute online clients and inbounds to the physical
+// node that hosts them across a chain of nodes (#4983), where per-panel
+// autoincrement node ids are meaningless one hop away.
+func (s *SettingService) GetPanelGuid() (string, error) {
+	guid, err := s.getString("panelGuid")
+	if err != nil {
+		return "", err
+	}
+	if guid == defaultValueMap["panelGuid"] {
+		if saveErr := s.saveSetting("panelGuid", guid); saveErr != nil {
+			logger.Warning("save panelGuid failed:", saveErr)
+		}
+	}
+	return guid, nil
+}
+
 func (s *SettingService) SetBasePath(basePath string) error {
 	if !strings.HasPrefix(basePath, "/") {
 		basePath = "/" + basePath

+ 2 - 0
web/translation/ar-EG.json

@@ -881,6 +881,8 @@
       "connectionFailed": "فشل الاتصال",
       "never": "أبدًا",
       "justNow": "دلوقتي",
+      "subNode": "نود فرعي",
+      "subNodeTip": "للقراءة فقط: نود تابع يتم الوصول إليه عبر {parent}. تتم إدارته من لوحة {parent} نفسها.",
       "deleteConfirmTitle": "تحذف النود \"{name}\"؟",
       "deleteConfirmContent": "ده هيوقّف مراقبة النود. البانل البعيد نفسه مش هيتأثر.",
       "statusValues": {

+ 2 - 0
web/translation/en-US.json

@@ -887,6 +887,8 @@
       "connectionFailed": "Connection failed",
       "never": "never",
       "justNow": "just now",
+      "subNode": "Sub-node",
+      "subNodeTip": "Read-only: a downstream node reached through {parent}. Manage it from {parent}'s own panel.",
       "deleteConfirmTitle": "Delete node \"{name}\"?",
       "deleteConfirmContent": "This stops monitoring the node. The remote panel itself is unaffected.",
       "statusValues": {

+ 2 - 0
web/translation/es-ES.json

@@ -881,6 +881,8 @@
       "connectionFailed": "Conexión fallida",
       "never": "nunca",
       "justNow": "ahora mismo",
+      "subNode": "Subnodo",
+      "subNodeTip": "Solo lectura: un nodo descendente al que se llega a través de {parent}. Gestiónalo desde el propio panel de {parent}.",
       "deleteConfirmTitle": "¿Eliminar el nodo \"{name}\"?",
       "deleteConfirmContent": "Esto detiene la monitorización del nodo. El panel remoto en sí no se ve afectado.",
       "statusValues": {

+ 2 - 0
web/translation/fa-IR.json

@@ -881,6 +881,8 @@
       "connectionFailed": "اتصال ناموفق",
       "never": "هرگز",
       "justNow": "هم‌اکنون",
+      "subNode": "نود فرعی",
+      "subNodeTip": "فقط‌خواندنی: یک نود پایین‌دستی که از طریق {parent} در دسترس است. آن را از پنل خودِ {parent} مدیریت کنید.",
       "deleteConfirmTitle": "نود «{name}» حذف شود؟",
       "deleteConfirmContent": "نظارت روی این نود متوقف می‌شود. خود پنل ریموت تغییری نمی‌کند.",
       "statusValues": {

+ 2 - 0
web/translation/id-ID.json

@@ -881,6 +881,8 @@
       "connectionFailed": "Koneksi gagal",
       "never": "tidak pernah",
       "justNow": "baru saja",
+      "subNode": "Sub-node",
+      "subNodeTip": "Hanya-baca: node turunan yang dijangkau melalui {parent}. Kelola dari panel {parent} sendiri.",
       "deleteConfirmTitle": "Hapus node \"{name}\"?",
       "deleteConfirmContent": "Ini menghentikan pemantauan node. Panel jarak jauh itu sendiri tidak terpengaruh.",
       "statusValues": {

+ 2 - 0
web/translation/ja-JP.json

@@ -881,6 +881,8 @@
       "connectionFailed": "接続に失敗しました",
       "never": "なし",
       "justNow": "たった今",
+      "subNode": "サブノード",
+      "subNodeTip": "読み取り専用: {parent} を経由して到達する下位ノードです。{parent} 自身のパネルから管理してください。",
       "deleteConfirmTitle": "ノード「{name}」を削除しますか?",
       "deleteConfirmContent": "ノードの監視を停止します。リモートパネル自体には影響しません。",
       "statusValues": {

+ 2 - 0
web/translation/pt-BR.json

@@ -881,6 +881,8 @@
       "connectionFailed": "Falha na conexão",
       "never": "nunca",
       "justNow": "agora mesmo",
+      "subNode": "Subnó",
+      "subNodeTip": "Somente leitura: um nó descendente acessado através de {parent}. Gerencie-o pelo próprio painel de {parent}.",
       "deleteConfirmTitle": "Excluir o nó \"{name}\"?",
       "deleteConfirmContent": "Isso interrompe o monitoramento do nó. O painel remoto em si não é afetado.",
       "statusValues": {

+ 2 - 0
web/translation/ru-RU.json

@@ -881,6 +881,8 @@
       "connectionFailed": "Не удалось подключиться",
       "never": "никогда",
       "justNow": "только что",
+      "subNode": "Подузел",
+      "subNodeTip": "Только для чтения: подчинённый узел, доступный через {parent}. Управляйте им из собственной панели {parent}.",
       "deleteConfirmTitle": "Удалить узел \"{name}\"?",
       "deleteConfirmContent": "Это остановит мониторинг узла. Сама удалённая панель не будет затронута.",
       "statusValues": {

+ 2 - 0
web/translation/tr-TR.json

@@ -881,6 +881,8 @@
       "connectionFailed": "Bağlantı başarısız",
       "never": "asla",
       "justNow": "şimdi",
+      "subNode": "Alt düğüm",
+      "subNodeTip": "Salt okunur: {parent} üzerinden erişilen bir alt düğüm. Bunu {parent} panelinden yönetin.",
       "deleteConfirmTitle": "\"{name}\" düğümü silinsin mi?",
       "deleteConfirmContent": "Bu, düğüm izlemeyi durdurur. Uzak panelin kendisi etkilenmez.",
       "statusValues": {

+ 2 - 0
web/translation/uk-UA.json

@@ -881,6 +881,8 @@
       "connectionFailed": "Помилка з'єднання",
       "never": "ніколи",
       "justNow": "щойно",
+      "subNode": "Підвузол",
+      "subNodeTip": "Лише для читання: підлеглий вузол, доступний через {parent}. Керуйте ним із власної панелі {parent}.",
       "deleteConfirmTitle": "Видалити вузол \"{name}\"?",
       "deleteConfirmContent": "Це зупинить моніторинг вузла. Сама віддалена панель не зазнає змін.",
       "statusValues": {

+ 2 - 0
web/translation/vi-VN.json

@@ -881,6 +881,8 @@
       "connectionFailed": "Kết nối thất bại",
       "never": "chưa bao giờ",
       "justNow": "vừa xong",
+      "subNode": "Nút con",
+      "subNodeTip": "Chỉ đọc: một nút phía dưới được kết nối qua {parent}. Quản lý nó từ bảng điều khiển của chính {parent}.",
       "deleteConfirmTitle": "Xóa nút \"{name}\"?",
       "deleteConfirmContent": "Việc này dừng giám sát nút. Panel từ xa không bị ảnh hưởng.",
       "statusValues": {

+ 2 - 0
web/translation/zh-CN.json

@@ -881,6 +881,8 @@
       "connectionFailed": "连接失败",
       "never": "从未",
       "justNow": "刚刚",
+      "subNode": "子节点",
+      "subNodeTip": "只读:通过 {parent} 接入的下游节点。请在 {parent} 自己的面板中管理。",
       "deleteConfirmTitle": "删除节点 \"{name}\"?",
       "deleteConfirmContent": "这将停止监控该节点。远程面板本身不受影响。",
       "statusValues": {

+ 2 - 0
web/translation/zh-TW.json

@@ -881,6 +881,8 @@
       "connectionFailed": "連線失敗",
       "never": "從未",
       "justNow": "剛剛",
+      "subNode": "子節點",
+      "subNodeTip": "唯讀:透過 {parent} 連接的下游節點。請在 {parent} 自己的面板中管理。",
       "deleteConfirmTitle": "刪除節點「{name}」?",
       "deleteConfirmContent": "這將停止監控該節點。遠端面板本身不受影響。",
       "statusValues": {

+ 49 - 54
xray/online_test.go

@@ -20,51 +20,50 @@ func assertSameSet(t *testing.T, label string, got, want []string) {
 	}
 }
 
-// TestGetOnlineClientsByNodeScopesPerNode pins the fix for issue #4809: a
-// client online on one node must not be reported online on any other node.
-func TestGetOnlineClientsByNodeScopesPerNode(t *testing.T) {
+// TestMergedNodeTreesScopesPerGuid pins #4983/#4809: each node's clients stay
+// under that node's GUID, so a client on one node is never attributed to
+// another — and a sub-node's clients (reported under their own GUID inside a
+// parent's tree) compose upward without collapsing onto the parent.
+func TestMergedNodeTreesScopesPerGuid(t *testing.T) {
 	p := newOnlineTestProcess()
-	p.RefreshLocalOnline([]string{"user1"}, nil, 1000, 20000)
-	p.SetNodeOnlineClients(3, []string{"user1", "user2"})
-	p.SetNodeOnlineClients(5, []string{"user3"})
-
-	byNode := p.GetOnlineClientsByNode()
-
-	assertSameSet(t, "local (key 0)", byNode[localNodeKey], []string{"user1"})
-	assertSameSet(t, "node 3", byNode[3], []string{"user1", "user2"})
-	assertSameSet(t, "node 5", byNode[5], []string{"user3"})
-
-	if slices.Contains(byNode[5], "user1") {
-		t.Errorf("user1 leaked onto node 5: %v", byNode[5])
-	}
-	if slices.Contains(byNode[localNodeKey], "user3") || slices.Contains(byNode[3], "user3") {
-		t.Errorf("user3 leaked off node 5: local=%v node3=%v", byNode[localNodeKey], byNode[3])
+	// Node A (direct) reports its own clients plus sub-node B's tree.
+	p.SetNodeOnlineTree(1, map[string][]string{
+		"guid-a": {"user1", "user2"},
+		"guid-b": {"user3"}, // B is behind A; still keyed by B's own GUID
+	})
+	p.SetNodeOnlineTree(2, map[string][]string{
+		"guid-c": {"user4"},
+	})
+
+	merged := p.GetMergedNodeTrees()
+	assertSameSet(t, "guid-a", merged["guid-a"], []string{"user1", "user2"})
+	assertSameSet(t, "guid-b", merged["guid-b"], []string{"user3"})
+	assertSameSet(t, "guid-c", merged["guid-c"], []string{"user4"})
+
+	if slices.Contains(merged["guid-a"], "user3") {
+		t.Errorf("user3 (on sub-node B) leaked onto node A: %v", merged["guid-a"])
 	}
 }
 
-// TestGetOnlineClientsByNodeOmitsEmptyGroups keeps the payload small: a node
-// with no online clients (e.g. just cleared) must not appear as an empty key.
-func TestGetOnlineClientsByNodeOmitsEmptyGroups(t *testing.T) {
+// TestMergedNodeTreesOmitsEmpty keeps the payload small: empty GUID sets don't
+// appear as keys.
+func TestMergedNodeTreesOmitsEmpty(t *testing.T) {
 	p := newOnlineTestProcess()
-	p.SetNodeOnlineClients(3, []string{"user1"})
-	p.SetNodeOnlineClients(7, []string{})
-
-	byNode := p.GetOnlineClientsByNode()
-
-	if _, ok := byNode[7]; ok {
-		t.Errorf("node 7 has no online clients but is present: %v", byNode)
-	}
-	if _, ok := byNode[localNodeKey]; ok {
-		t.Errorf("no local clients online but key 0 is present: %v", byNode)
+	p.SetNodeOnlineTree(1, map[string][]string{
+		"guid-a": {"user1"},
+		"guid-x": {},
+	})
+	if _, ok := p.GetMergedNodeTrees()["guid-x"]; ok {
+		t.Errorf("empty GUID set should be omitted: %v", p.GetMergedNodeTrees())
 	}
 }
 
-// TestGetOnlineClientsUnionDedupes confirms the flat union (used by the
-// client-centric / total-count views) still merges every node and dedupes.
+// TestGetOnlineClientsUnionDedupes confirms the flat union (client-centric /
+// total-count views) merges local + every node and dedupes.
 func TestGetOnlineClientsUnionDedupes(t *testing.T) {
 	p := newOnlineTestProcess()
 	p.RefreshLocalOnline([]string{"user1"}, nil, 1000, 20000)
-	p.SetNodeOnlineClients(3, []string{"user1", "user2"})
+	p.SetNodeOnlineTree(1, map[string][]string{"guid-a": {"user1", "user2"}})
 
 	assertSameSet(t, "union", p.GetOnlineClients(), []string{"user1", "user2"})
 }
@@ -77,18 +76,18 @@ func TestRefreshLocalOnlineGraceWindow(t *testing.T) {
 	const grace = 20000
 
 	p.RefreshLocalOnline([]string{"user1"}, nil, 1000, grace)
-	if got := p.GetOnlineClientsByNode()[localNodeKey]; !slices.Contains(got, "user1") {
+	if got := p.GetLocalOnlineClients(); !slices.Contains(got, "user1") {
 		t.Fatalf("user1 should be online right after activity, got %v", got)
 	}
 
 	p.RefreshLocalOnline([]string{"user2"}, nil, 11000, grace)
-	got := p.GetOnlineClientsByNode()[localNodeKey]
+	got := p.GetLocalOnlineClients()
 	if !slices.Contains(got, "user1") || !slices.Contains(got, "user2") {
 		t.Fatalf("both within grace window, got %v", got)
 	}
 
 	p.RefreshLocalOnline(nil, nil, 22000, grace)
-	got = p.GetOnlineClientsByNode()[localNodeKey]
+	got = p.GetLocalOnlineClients()
 	if slices.Contains(got, "user1") {
 		t.Errorf("user1 (idle 21s, past grace) should have aged out, got %v", got)
 	}
@@ -97,40 +96,36 @@ func TestRefreshLocalOnlineGraceWindow(t *testing.T) {
 	}
 }
 
-// TestGetActiveInboundsByNodeTracksGraceWindow pins the fix for issue #4859: a
-// multi-inbound client must only count as online on inbounds that actually
-// carried traffic. The active-inbound signal honours the same grace window as
-// the online-email signal, and only this panel's tags report under key 0.
-func TestGetActiveInboundsByNodeTracksGraceWindow(t *testing.T) {
+// TestGetLocalActiveInboundsTracksGraceWindow pins #4859: a multi-inbound
+// client only counts online on inbounds that actually carried traffic, and the
+// active-inbound signal honours the same grace window as the online signal.
+func TestGetLocalActiveInboundsTracksGraceWindow(t *testing.T) {
 	p := newOnlineTestProcess()
 	const grace = 20000
 
 	p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-a"}, 1000, grace)
-	got := p.GetActiveInboundsByNode()[localNodeKey]
-	assertSameSet(t, "active after first poll", got, []string{"inbound-a"})
+	assertSameSet(t, "active after first poll", p.GetLocalActiveInbounds(), []string{"inbound-a"})
 
 	p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-b"}, 11000, grace)
-	got = p.GetActiveInboundsByNode()[localNodeKey]
-	assertSameSet(t, "both within grace", got, []string{"inbound-a", "inbound-b"})
+	assertSameSet(t, "both within grace", p.GetLocalActiveInbounds(), []string{"inbound-a", "inbound-b"})
 
 	p.RefreshLocalOnline(nil, nil, 22000, grace)
-	got = p.GetActiveInboundsByNode()[localNodeKey]
-	assertSameSet(t, "inbound-a (idle 21s, past grace) aged out, inbound-b kept", got, []string{"inbound-b"})
+	assertSameSet(t, "inbound-a (idle 21s) aged out, inbound-b kept", p.GetLocalActiveInbounds(), []string{"inbound-b"})
 
 	p.RefreshLocalOnline(nil, nil, 40000, grace)
-	if _, ok := p.GetActiveInboundsByNode()[localNodeKey]; ok {
-		t.Errorf("all inbounds idle past grace, key 0 should be absent: %v", p.GetActiveInboundsByNode())
+	if got := p.GetLocalActiveInbounds(); len(got) != 0 {
+		t.Errorf("all inbounds idle past grace, want empty, got %v", got)
 	}
 }
 
 // TestClearNodeOnlineClientsDropsNode mirrors a failed node probe: the node's
-// clients must disappear from the per-node map immediately.
+// whole subtree contribution disappears immediately.
 func TestClearNodeOnlineClientsDropsNode(t *testing.T) {
 	p := newOnlineTestProcess()
-	p.SetNodeOnlineClients(3, []string{"user1"})
+	p.SetNodeOnlineTree(3, map[string][]string{"guid-a": {"user1"}})
 	p.ClearNodeOnlineClients(3)
 
-	if _, ok := p.GetOnlineClientsByNode()[3]; ok {
-		t.Errorf("node 3 should be absent after ClearNodeOnlineClients")
+	if _, ok := p.GetMergedNodeTrees()["guid-a"]; ok {
+		t.Errorf("node 3's subtree should be absent after ClearNodeOnlineClients")
 	}
 }

+ 84 - 65
xray/process.go

@@ -155,13 +155,18 @@ type process struct {
 	// two signals stay aligned — an email within grace always has the
 	// inbound it used within grace too.
 	localInboundLastActive map[string]int64
-	// nodeOnlineClients holds the online-emails list reported by each
-	// remote node, keyed by node id. NodeTrafficSyncJob populates entries
-	// per cron tick and clears them when a node's probe fails. The mutex
-	// guards this map, onlineClients, and localLastOnline above so the
+	// nodeOnlineTrees holds, per direct remote node (keyed by that node's
+	// panel-local id), the GUID-keyed online-emails subtree that node
+	// reported — its own clients under its panelGuid plus every descendant
+	// under theirs. Keying the stored value by GUID (not node id) lets the
+	// master attribute a deeply nested client to the node that physically
+	// hosts it across a chain (#4983); the outer node-id key is only so a
+	// failed probe can drop that whole branch's contribution. NodeTrafficSyncJob
+	// populates entries per cron tick and clears them when a probe fails. The
+	// mutex guards this map, onlineClients, and localLastOnline above so the
 	// online getters never see a torn read.
-	nodeOnlineClients map[int][]string
-	onlineMu          sync.RWMutex
+	nodeOnlineTrees map[int]map[string][]string
+	onlineMu        sync.RWMutex
 
 	config     *Config
 	configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
@@ -177,12 +182,6 @@ var (
 	xrayForceStopTimeout    = 2 * time.Second
 )
 
-// localNodeKey is the GetOnlineClientsByNode key under which this panel's
-// own (non-node-managed) inbounds report their online clients. Node ids
-// autoincrement from 1, so 0 is a safe sentinel that never collides with a
-// real node. The frontend mirrors this contract (nodeId ?? 0).
-const localNodeKey = 0
-
 // newProcess creates a new internal process struct for Xray.
 func newProcess(config *Config) *process {
 	return &process{
@@ -255,7 +254,7 @@ func (p *Process) GetOnlineClients() []string {
 	p.onlineMu.RLock()
 	defer p.onlineMu.RUnlock()
 
-	if len(p.nodeOnlineClients) == 0 {
+	if len(p.nodeOnlineTrees) == 0 {
 		// Hot path for single-panel deployments: avoid the map+dedupe
 		// work entirely and return the local slice as-is.
 		return p.onlineClients
@@ -263,15 +262,8 @@ func (p *Process) GetOnlineClients() []string {
 
 	seen := make(map[string]struct{}, len(p.onlineClients))
 	out := make([]string, 0, len(p.onlineClients))
-	for _, email := range p.onlineClients {
-		if _, dup := seen[email]; dup {
-			continue
-		}
-		seen[email] = struct{}{}
-		out = append(out, email)
-	}
-	for _, list := range p.nodeOnlineClients {
-		for _, email := range list {
+	add := func(emails []string) {
+		for _, email := range emails {
 			if _, dup := seen[email]; dup {
 				continue
 			}
@@ -279,53 +271,79 @@ func (p *Process) GetOnlineClients() []string {
 			out = append(out, email)
 		}
 	}
+	add(p.onlineClients)
+	for _, tree := range p.nodeOnlineTrees {
+		for _, emails := range tree {
+			add(emails)
+		}
+	}
 	return out
 }
 
-// GetOnlineClientsByNode returns online emails grouped by the node that
-// reported them: this panel's own xray clients under localNodeKey (0), and
-// each remote node's clients under that node's id. Unlike GetOnlineClients
-// (which flattens everything into one deduped union), this preserves node
-// attribution so per-inbound/per-node online counts don't bleed a client
-// connected to one node onto every other node. Empty groups are omitted.
-func (p *Process) GetOnlineClientsByNode() map[int][]string {
+// GetLocalOnlineClients returns a copy of the emails online on THIS panel's own
+// xray within the grace window. The service layer keys these under the panel's
+// own GUID when assembling the per-node online view.
+func (p *Process) GetLocalOnlineClients() []string {
 	p.onlineMu.RLock()
 	defer p.onlineMu.RUnlock()
-
-	out := make(map[int][]string, len(p.nodeOnlineClients)+1)
-	if len(p.onlineClients) > 0 {
-		local := make([]string, len(p.onlineClients))
-		copy(local, p.onlineClients)
-		out[localNodeKey] = local
+	if len(p.onlineClients) == 0 {
+		return nil
 	}
-	for nodeID, list := range p.nodeOnlineClients {
-		if len(list) == 0 {
-			continue
+	out := make([]string, len(p.onlineClients))
+	copy(out, p.onlineClients)
+	return out
+}
+
+// GetMergedNodeTrees returns the union of every direct node's reported subtree,
+// keyed by the panelGuid of the node that physically hosts each client set.
+// Because each child already reports its descendants under their own GUIDs,
+// merging the direct children yields the whole tree at any depth (#4983), so a
+// client three hops down is attributed to its real node, not the intermediate
+// one. GUIDs are globally unique, but a set reported under the same GUID by more
+// than one path is deduped per key; empty sets are omitted.
+func (p *Process) GetMergedNodeTrees() map[string][]string {
+	p.onlineMu.RLock()
+	defer p.onlineMu.RUnlock()
+	if len(p.nodeOnlineTrees) == 0 {
+		return map[string][]string{}
+	}
+	out := make(map[string][]string)
+	seen := make(map[string]map[string]struct{})
+	for _, tree := range p.nodeOnlineTrees {
+		for guid, emails := range tree {
+			if guid == "" || len(emails) == 0 {
+				continue
+			}
+			dedup := seen[guid]
+			if dedup == nil {
+				dedup = make(map[string]struct{}, len(emails))
+				seen[guid] = dedup
+			}
+			for _, email := range emails {
+				if _, ok := dedup[email]; ok {
+					continue
+				}
+				dedup[email] = struct{}{}
+				out[guid] = append(out[guid], email)
+			}
 		}
-		cp := make([]string, len(list))
-		copy(cp, list)
-		out[nodeID] = cp
 	}
 	return out
 }
 
-// GetActiveInboundsByNode returns the inbound tags that carried traffic within
-// the grace window, grouped by node. Only this panel's own xray reports
-// per-inbound activity (under localNodeKey); remote-node snapshots don't carry
-// it, so their nodes are simply absent — the per-inbound view reads "node
-// missing" as "don't gate" and falls back to the email-only signal there.
-// Empty groups are omitted, mirroring GetOnlineClientsByNode.
-func (p *Process) GetActiveInboundsByNode() map[int][]string {
+// GetLocalActiveInbounds returns a copy of THIS panel's inbound tags that
+// carried traffic within the grace window. Only the local xray reports
+// per-inbound activity; remote-node snapshots don't carry it, so the service
+// layer keys these under the panel's own GUID and a node missing from the
+// active-inbounds map means "don't gate" (fall back to the email-only signal).
+func (p *Process) GetLocalActiveInbounds() []string {
 	p.onlineMu.RLock()
 	defer p.onlineMu.RUnlock()
-
 	if len(p.localActiveInbounds) == 0 {
-		return map[int][]string{}
+		return nil
 	}
-	out := make(map[int][]string, 1)
-	local := make([]string, len(p.localActiveInbounds))
-	copy(local, p.localActiveInbounds)
-	out[localNodeKey] = local
+	out := make([]string, len(p.localActiveInbounds))
+	copy(out, p.localActiveInbounds)
 	return out
 }
 
@@ -371,25 +389,26 @@ func (p *Process) RefreshLocalOnline(activeEmails, activeInboundTags []string, n
 	p.localActiveInbounds = activeInbounds
 }
 
-// SetNodeOnlineClients records the online-emails set for one remote
-// node. Replaces any previous entry for that node — NodeTrafficSyncJob
-// always sends the full list per tick.
-func (p *Process) SetNodeOnlineClients(nodeID int, emails []string) {
+// SetNodeOnlineTree records the GUID-keyed online subtree one direct remote
+// node reported (its own clients under its panelGuid plus every descendant
+// under theirs). Replaces any previous entry for that node — NodeTrafficSyncJob
+// always sends the full subtree per tick.
+func (p *Process) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
 	p.onlineMu.Lock()
 	defer p.onlineMu.Unlock()
-	if p.nodeOnlineClients == nil {
-		p.nodeOnlineClients = map[int][]string{}
+	if p.nodeOnlineTrees == nil {
+		p.nodeOnlineTrees = map[int]map[string][]string{}
 	}
-	p.nodeOnlineClients[nodeID] = emails
+	p.nodeOnlineTrees[nodeID] = tree
 }
 
-// ClearNodeOnlineClients drops a node's contribution to the online set.
-// Called when a probe fails so a downed node doesn't keep its clients
-// listed as "online" until the next successful probe.
+// ClearNodeOnlineClients drops a direct node's whole subtree contribution.
+// Called when a probe fails so a downed node — and everything behind it — doesn't
+// keep its clients listed as "online" until the next successful probe.
 func (p *Process) ClearNodeOnlineClients(nodeID int) {
 	p.onlineMu.Lock()
 	defer p.onlineMu.Unlock()
-	delete(p.nodeOnlineClients, nodeID)
+	delete(p.nodeOnlineTrees, nodeID)
 }
 
 // GetUptime returns the uptime of the Xray process in seconds.