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')} + + ) : (