From e6c1ce9aa91c84b3a8a5f92f501479db89a44f7e Mon Sep 17 00:00:00 2001 From: Sanaei Date: Sat, 6 Jun 2026 12:33:39 +0200 Subject: [PATCH] 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: 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 --- database/model/model.go | 41 ++++ frontend/public/openapi.json | 56 ++++- frontend/src/api/queryKeys.ts | 2 +- frontend/src/models/dbinbound.ts | 3 + frontend/src/pages/api-docs/endpoints.ts | 16 +- frontend/src/pages/inbounds/useInbounds.ts | 74 +++---- frontend/src/pages/nodes/NodeList.tsx | 87 ++++++-- frontend/src/schemas/node.ts | 5 + web/controller/client.go | 8 +- web/controller/node.go | 2 +- web/controller/server.go | 9 + web/job/node_heartbeat_job.go | 12 +- web/job/node_traffic_sync_job.go | 4 +- web/job/xray_traffic_job.go | 4 +- web/runtime/remote.go | 46 ++++- web/service/inbound.go | 132 +++++++++++- web/service/node.go | 43 ++-- web/service/node_origin_guid_test.go | 71 +++++++ web/service/node_tree.go | 226 +++++++++++++++++++++ web/service/node_tree_test.go | 81 ++++++++ web/service/server.go | 4 + web/service/setting.go | 20 ++ web/translation/ar-EG.json | 2 + web/translation/en-US.json | 2 + web/translation/es-ES.json | 2 + web/translation/fa-IR.json | 2 + web/translation/id-ID.json | 2 + web/translation/ja-JP.json | 2 + web/translation/pt-BR.json | 2 + web/translation/ru-RU.json | 2 + web/translation/tr-TR.json | 2 + web/translation/uk-UA.json | 2 + web/translation/vi-VN.json | 2 + web/translation/zh-CN.json | 2 + web/translation/zh-TW.json | 2 + xray/online_test.go | 99 +++++---- xray/process.go | 163 ++++++++------- 37 files changed, 1007 insertions(+), 227 deletions(-) create mode 100644 web/service/node_origin_guid_test.go create mode 100644 web/service/node_tree.go create mode 100644 web/service/node_tree_test.go diff --git a/database/model/model.go b/database/model/model.go index 2092adae..2fd54421 100644 --- a/database/model/model.go +++ b/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"` diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 1beb1632..b023a861 100644 --- a/frontend/public/openapi.json +++ b/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" ] diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 7af1e1d6..fc953eba 100644 --- a/frontend/src/api/queryKeys.ts +++ b/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, diff --git a/frontend/src/models/dbinbound.ts b/frontend/src/models/dbinbound.ts index 74cd95ac..d6ba7bdc 100644 --- a/frontend/src/models/dbinbound.ts +++ b/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 | 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; diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 3d6e4c02..1255d7c7 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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', diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts index 9564a163..4446d91f 100644 --- a/frontend/src/pages/inbounds/useInbounds.ts +++ b/frontend/src/pages/inbounds/useInbounds.ts @@ -58,13 +58,14 @@ async function fetchOnlineClients(): Promise { 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> { - 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> { + 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) : {}; } @@ -79,11 +80,11 @@ async function fetchActiveInboundsByNode(): Promise> { return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record) : {}; } -function toNodeOnlineMap(data: Record): Map> { - const map = new Map>(); +function toGuidOnlineMap(data: Record): Map> { + const map = new Map>(); 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([]); 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>>(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>>(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>>(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>>(new Map()); const [lastOnlineMap, setLastOnlineMap] = useState>({}); @@ -209,13 +211,17 @@ export function useInbounds() { const comments = new Map(); 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; activeInbounds?: Record; lastOnlineMap?: Record }; + const p = payload as { onlineClients?: string[]; onlineByGuid?: Record; activeInbounds?: Record; lastOnlineMap?: Record }; 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! })); diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index d63f94ea..8c169402 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/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(null); const [expandedIds, setExpandedIds] = useState>(new Set()); - const dataSource = useMemo( - () => 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(); + 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(() => { + 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(); + 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(); + 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 ? ( + + } style={{ margin: 0 }}>{t('pages.nodes.subNode')} + + ) : (