From 9a61fde9211b56303684cf9770fe343453fd6d3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Sep 2025 06:53:36 +0000 Subject: [PATCH 1/4] Initial plan From e96a344f7395b7f7a6303cce5b50e4c01715d101 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Sep 2025 07:09:22 +0000 Subject: [PATCH 2/4] feat: add network peer information to node page Co-authored-by: adameat <34044711+adameat@users.noreply.github.com> --- .../FullNodeViewer/FullNodeViewer.tsx | 19 ++++++- .../FullNodeViewer/NodeNetworkInfo.tsx | 56 +++++++++++++++++++ src/components/FullNodeViewer/i18n/en.json | 12 +++- src/containers/Node/Node.tsx | 7 ++- src/store/reducers/node/node.ts | 18 ++++++ src/utils/yaMetrica.ts | 11 ++-- 6 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 src/components/FullNodeViewer/NodeNetworkInfo.tsx diff --git a/src/components/FullNodeViewer/FullNodeViewer.tsx b/src/components/FullNodeViewer/FullNodeViewer.tsx index 2251740f42..bc4747577e 100644 --- a/src/components/FullNodeViewer/FullNodeViewer.tsx +++ b/src/components/FullNodeViewer/FullNodeViewer.tsx @@ -1,5 +1,7 @@ import {Flex} from '@gravity-ui/uikit'; +import {skipToken} from '@reduxjs/toolkit/query'; +import {nodeApi} from '../../store/reducers/node/node'; import type {PreparedNode} from '../../store/reducers/node/types'; import {cn} from '../../utils/cn'; import {useNodeDeveloperUIHref} from '../../utils/hooks/useNodeDeveloperUIHref'; @@ -10,6 +12,7 @@ import {PoolUsage} from '../PoolUsage/PoolUsage'; import {ProgressViewer} from '../ProgressViewer/ProgressViewer'; import {NodeUptime} from '../UptimeViewer/UptimeViewer'; +import {NodeNetworkInfo} from './NodeNetworkInfo'; import i18n from './i18n'; import './FullNodeViewer.scss'; @@ -19,14 +22,20 @@ const b = cn('full-node-viewer'); interface FullNodeViewerProps { node?: PreparedNode; className?: string; + database?: string; } const getLoadAverageIntervalTitle = (index: number) => { return [i18n('la-interval-1m'), i18n('la-interval-5m'), i18n('la-interval-15m')][index]; }; -export const FullNodeViewer = ({node, className}: FullNodeViewerProps) => { +export const FullNodeViewer = ({node, className, database}: FullNodeViewerProps) => { const developerUIHref = useNodeDeveloperUIHref(node); + const nodeId = node?.NodeId?.toString(); + const {currentData: nodeNetworkInfo} = nodeApi.useGetNodeNetworkInfoQuery( + nodeId && database ? {nodeId, database} : skipToken, + ); + const commonInfo: InfoViewerItem[] = []; if (node?.Tenants?.length) { @@ -86,6 +95,14 @@ export const FullNodeViewer = ({node, className}: FullNodeViewerProps) => { info={endpointsInfo} /> ) : null} + + {nodeNetworkInfo ? ( + + ) : null} diff --git a/src/components/FullNodeViewer/NodeNetworkInfo.tsx b/src/components/FullNodeViewer/NodeNetworkInfo.tsx new file mode 100644 index 0000000000..b69c221dbe --- /dev/null +++ b/src/components/FullNodeViewer/NodeNetworkInfo.tsx @@ -0,0 +1,56 @@ +import {getDefaultNodePath} from '../../containers/Node/NodePages'; +import type {TNodeInfo} from '../../types/api/nodes'; +import {EMPTY_DATA_PLACEHOLDER} from '../../utils/constants'; +import {InfoViewer} from '../InfoViewer/InfoViewer'; +import type {InfoViewerItem} from '../InfoViewer/InfoViewer'; +import {InternalLink} from '../InternalLink'; + +import i18n from './i18n'; + +interface NodeNetworkInfoProps { + nodeNetworkInfo?: TNodeInfo | null; + className?: string; + database?: string; +} + +export const NodeNetworkInfo = ({nodeNetworkInfo, className, database}: NodeNetworkInfoProps) => { + const peers = nodeNetworkInfo?.Peers; + + if (!peers || peers.length === 0) { + return null; + } + + const connectedPeers = peers.filter((peer) => peer.Connected); + const totalPeers = peers.length; + + const networkInfo: InfoViewerItem[] = [ + { + label: i18n('network.total-peers'), + value: totalPeers, + }, + { + label: i18n('network.connected-peers'), + value: connectedPeers.length, + }, + ]; + + // Show up to 5 peers in the info section + const displayPeers = peers.slice(0, 5); + + displayPeers.forEach((peer, index) => { + const nodeLink = peer.NodeId ? ( + + {peer.NodeId} + + ) : ( + EMPTY_DATA_PLACEHOLDER + ); + + networkInfo.push({ + label: `${i18n('network.peer-node-id')} ${index + 1}`, + value: nodeLink, + }); + }); + + return ; +}; diff --git a/src/components/FullNodeViewer/i18n/en.json b/src/components/FullNodeViewer/i18n/en.json index d20f622645..ae9f6dbb52 100644 --- a/src/components/FullNodeViewer/i18n/en.json +++ b/src/components/FullNodeViewer/i18n/en.json @@ -17,5 +17,15 @@ "title.endpoints": "Endpoints", "title.roles": "Roles", "title.pools": "Pools", - "title.load-average": "Load average" + "title.load-average": "Load average", + "title.network": "Network", + + "network.connected-peers": "Connected peers", + "network.total-peers": "Total peers", + "network.peer-node-id": "Node ID", + "network.peer-host": "Host", + "network.peer-dc": "DC", + "network.peer-rack": "Rack", + "network.peer-connected": "Connected", + "network.peer-status": "Status" } diff --git a/src/containers/Node/Node.tsx b/src/containers/Node/Node.tsx index 4d7b19a535..acb395fc7f 100644 --- a/src/containers/Node/Node.tsx +++ b/src/containers/Node/Node.tsx @@ -109,7 +109,7 @@ export function Node() { {} {} {error ? : null} - {} + {} {nodeId ? ( ; } - return ; + return ; } interface NodePageContentProps { diff --git a/src/store/reducers/node/node.ts b/src/store/reducers/node/node.ts index 645f206528..9f6b7c40d9 100644 --- a/src/store/reducers/node/node.ts +++ b/src/store/reducers/node/node.ts @@ -15,6 +15,24 @@ export const nodeApi = api.injectEndpoints({ }, providesTags: ['All'], }), + getNodeNetworkInfo: build.query({ + queryFn: async ({nodeId, database}: {nodeId: string; database?: string}, {signal}) => { + try { + const data = await window.api.viewer.getNodes( + { + node_id: nodeId, + database, + fieldsRequired: ['Peers'], + }, + {signal}, + ); + return {data: data.Nodes?.[0] || null}; + } catch (error) { + return {error}; + } + }, + providesTags: ['All'], + }), getNodeStructure: build.query({ queryFn: async ({nodeId}: {nodeId: string}, {signal}) => { try { diff --git a/src/utils/yaMetrica.ts b/src/utils/yaMetrica.ts index b6465eee4b..2b8f37b125 100644 --- a/src/utils/yaMetrica.ts +++ b/src/utils/yaMetrica.ts @@ -2,11 +2,10 @@ import {uiFactory} from '../uiFactory/uiFactory'; /** * Interface for a counter that provides methods for tracking metrics. - * - * @method hit - Tracks a hit event with optional arguments. https://yandex.ru/support/metrica/ru/objects/hit - * @method params - Sets parameters for the counter with optional arguments. https://yandex.ru/support/metrica/ru/objects/params-method - * @method userParams - Sets user-specific parameters for the counter with optional arguments. https://yandex.ru/support/metrica/ru/objects/user-params - * @method reachGoal - Tracks a goal achievement event with optional arguments. https://yandex.ru/support/metrica/ru/objects/reachgoal + * @function hit - Tracks a hit event with optional arguments. https://yandex.ru/support/metrica/ru/objects/hit + * @function params - Sets parameters for the counter with optional arguments. https://yandex.ru/support/metrica/ru/objects/params-method + * @function userParams - Sets user-specific parameters for the counter with optional arguments. https://yandex.ru/support/metrica/ru/objects/user-params + * @function reachGoal - Tracks a goal achievement event with optional arguments. https://yandex.ru/support/metrica/ru/objects/reachgoal */ export interface Counter { hit: (...args: unknown[]) => void; @@ -21,7 +20,6 @@ const yaMetricaMap = uiFactory.yaMetricaMap; * A fake implementation of a counter metric for Yandex.Metrica. * This class is used when the actual Yandex.Metrica counter is not defined, * and it provides a warning message the first time any of its methods are called. - * * @property name - The name of the counter. * @property warnShown - Flag to indicate if the warning has been shown. */ @@ -61,7 +59,6 @@ class FakeMetrica implements Counter { /** * Retrieves a Yandex Metrica instance by name from the global window object. * If no instance is found for the given name, returns a FakeMetrica instance instead. - * * @param name The name of the metrica to retrieve * @returns The Yandex Metrica instance if found, otherwise a FakeMetrica instance */ From d4205992ea8204bcb018ed1fe6870352b1fc7910 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 04:25:15 +0000 Subject: [PATCH 3/4] feat: add network tab to node page with peer connectivity visualization Co-authored-by: adameat <34044711+adameat@users.noreply.github.com> --- .../FullNodeViewer/FullNodeViewer.tsx | 19 +- .../FullNodeViewer/NodeNetworkInfo.tsx | 56 ---- src/components/FullNodeViewer/i18n/en.json | 12 +- src/containers/Node/Network/NodeNetwork.scss | 98 ++++++ src/containers/Node/Network/NodeNetwork.tsx | 302 ++++++++++++++++++ src/containers/Node/Node.tsx | 12 +- src/containers/Node/NodePages.ts | 7 + src/containers/Node/i18n/en.json | 1 + src/store/reducers/node/node.ts | 18 -- src/utils/yaMetrica.ts | 11 +- 10 files changed, 425 insertions(+), 111 deletions(-) delete mode 100644 src/components/FullNodeViewer/NodeNetworkInfo.tsx create mode 100644 src/containers/Node/Network/NodeNetwork.scss create mode 100644 src/containers/Node/Network/NodeNetwork.tsx diff --git a/src/components/FullNodeViewer/FullNodeViewer.tsx b/src/components/FullNodeViewer/FullNodeViewer.tsx index bc4747577e..2251740f42 100644 --- a/src/components/FullNodeViewer/FullNodeViewer.tsx +++ b/src/components/FullNodeViewer/FullNodeViewer.tsx @@ -1,7 +1,5 @@ import {Flex} from '@gravity-ui/uikit'; -import {skipToken} from '@reduxjs/toolkit/query'; -import {nodeApi} from '../../store/reducers/node/node'; import type {PreparedNode} from '../../store/reducers/node/types'; import {cn} from '../../utils/cn'; import {useNodeDeveloperUIHref} from '../../utils/hooks/useNodeDeveloperUIHref'; @@ -12,7 +10,6 @@ import {PoolUsage} from '../PoolUsage/PoolUsage'; import {ProgressViewer} from '../ProgressViewer/ProgressViewer'; import {NodeUptime} from '../UptimeViewer/UptimeViewer'; -import {NodeNetworkInfo} from './NodeNetworkInfo'; import i18n from './i18n'; import './FullNodeViewer.scss'; @@ -22,20 +19,14 @@ const b = cn('full-node-viewer'); interface FullNodeViewerProps { node?: PreparedNode; className?: string; - database?: string; } const getLoadAverageIntervalTitle = (index: number) => { return [i18n('la-interval-1m'), i18n('la-interval-5m'), i18n('la-interval-15m')][index]; }; -export const FullNodeViewer = ({node, className, database}: FullNodeViewerProps) => { +export const FullNodeViewer = ({node, className}: FullNodeViewerProps) => { const developerUIHref = useNodeDeveloperUIHref(node); - const nodeId = node?.NodeId?.toString(); - const {currentData: nodeNetworkInfo} = nodeApi.useGetNodeNetworkInfoQuery( - nodeId && database ? {nodeId, database} : skipToken, - ); - const commonInfo: InfoViewerItem[] = []; if (node?.Tenants?.length) { @@ -95,14 +86,6 @@ export const FullNodeViewer = ({node, className, database}: FullNodeViewerProps) info={endpointsInfo} /> ) : null} - - {nodeNetworkInfo ? ( - - ) : null} diff --git a/src/components/FullNodeViewer/NodeNetworkInfo.tsx b/src/components/FullNodeViewer/NodeNetworkInfo.tsx deleted file mode 100644 index b69c221dbe..0000000000 --- a/src/components/FullNodeViewer/NodeNetworkInfo.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import {getDefaultNodePath} from '../../containers/Node/NodePages'; -import type {TNodeInfo} from '../../types/api/nodes'; -import {EMPTY_DATA_PLACEHOLDER} from '../../utils/constants'; -import {InfoViewer} from '../InfoViewer/InfoViewer'; -import type {InfoViewerItem} from '../InfoViewer/InfoViewer'; -import {InternalLink} from '../InternalLink'; - -import i18n from './i18n'; - -interface NodeNetworkInfoProps { - nodeNetworkInfo?: TNodeInfo | null; - className?: string; - database?: string; -} - -export const NodeNetworkInfo = ({nodeNetworkInfo, className, database}: NodeNetworkInfoProps) => { - const peers = nodeNetworkInfo?.Peers; - - if (!peers || peers.length === 0) { - return null; - } - - const connectedPeers = peers.filter((peer) => peer.Connected); - const totalPeers = peers.length; - - const networkInfo: InfoViewerItem[] = [ - { - label: i18n('network.total-peers'), - value: totalPeers, - }, - { - label: i18n('network.connected-peers'), - value: connectedPeers.length, - }, - ]; - - // Show up to 5 peers in the info section - const displayPeers = peers.slice(0, 5); - - displayPeers.forEach((peer, index) => { - const nodeLink = peer.NodeId ? ( - - {peer.NodeId} - - ) : ( - EMPTY_DATA_PLACEHOLDER - ); - - networkInfo.push({ - label: `${i18n('network.peer-node-id')} ${index + 1}`, - value: nodeLink, - }); - }); - - return ; -}; diff --git a/src/components/FullNodeViewer/i18n/en.json b/src/components/FullNodeViewer/i18n/en.json index ae9f6dbb52..d20f622645 100644 --- a/src/components/FullNodeViewer/i18n/en.json +++ b/src/components/FullNodeViewer/i18n/en.json @@ -17,15 +17,5 @@ "title.endpoints": "Endpoints", "title.roles": "Roles", "title.pools": "Pools", - "title.load-average": "Load average", - "title.network": "Network", - - "network.connected-peers": "Connected peers", - "network.total-peers": "Total peers", - "network.peer-node-id": "Node ID", - "network.peer-host": "Host", - "network.peer-dc": "DC", - "network.peer-rack": "Rack", - "network.peer-connected": "Connected", - "network.peer-status": "Status" + "title.load-average": "Load average" } diff --git a/src/containers/Node/Network/NodeNetwork.scss b/src/containers/Node/Network/NodeNetwork.scss new file mode 100644 index 0000000000..d923d11d00 --- /dev/null +++ b/src/containers/Node/Network/NodeNetwork.scss @@ -0,0 +1,98 @@ +.node-network { + &__inner { + padding: 20px; + } + + &__controls-wrapper { + margin-bottom: 20px; + } + + &__controls { + display: flex; + align-items: center; + gap: 16px; + } + + &__problem-filter { + margin-right: 12px; + } + + &__checkbox-wrapper { + display: flex; + align-items: center; + } + + &__nodes-row { + display: flex; + gap: 32px; + align-items: flex-start; + } + + &__left, + &__right { + flex: 1; + } + + &__section-title { + font-size: 16px; + font-weight: 500; + margin-bottom: 16px; + } + + &__nodes-container { + margin-bottom: 24px; + } + + &__nodes-title { + font-size: 14px; + font-weight: 500; + margin-bottom: 12px; + } + + &__nodes { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__rack-column { + display: flex; + flex-direction: column; + align-items: center; + margin-right: 16px; + } + + &__rack-index { + font-size: 12px; + margin-bottom: 8px; + min-height: 16px; + } + + &__link { + color: var(--g-color-text-primary); + text-decoration: underline; + + &:hover { + color: var(--g-color-text-primary); + } + } + + &__placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + text-align: center; + } + + &__placeholder-img { + margin-bottom: 16px; + opacity: 0.5; + } + + &__placeholder-text { + color: var(--g-color-text-secondary); + font-size: 14px; + } +} \ No newline at end of file diff --git a/src/containers/Node/Network/NodeNetwork.tsx b/src/containers/Node/Network/NodeNetwork.tsx new file mode 100644 index 0000000000..ee16416195 --- /dev/null +++ b/src/containers/Node/Network/NodeNetwork.tsx @@ -0,0 +1,302 @@ +import React from 'react'; + +import {Checkbox, Icon, Loader} from '@gravity-ui/uikit'; +import {Link} from 'react-router-dom'; + +import {ResponseError} from '../../../components/Errors/ResponseError'; +import {Illustration} from '../../../components/Illustration'; +import {ProblemFilter} from '../../../components/ProblemFilter'; +import {networkApi} from '../../../store/reducers/network/network'; +import { + ProblemFilterValues, + changeFilter, + selectProblemFilter, +} from '../../../store/reducers/settings/settings'; +import {hideTooltip, showTooltip} from '../../../store/reducers/tooltip'; +import type {TNetNodeInfo, TNetNodePeerInfo} from '../../../types/api/netInfo'; +import {cn} from '../../../utils/cn'; +import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../../utils/hooks'; +import {getDefaultNodePath} from '../NodePages'; + +import {NodeNetwork as NodeNetworkComponent} from '../../../containers/Tenant/Diagnostics/Network/NodeNetwork/NodeNetwork'; +import {getConnectedNodesCount} from '../../../containers/Tenant/Diagnostics/Network/utils'; + +import networkIcon from '../../../assets/icons/network.svg'; + +import './NodeNetwork.scss'; + +const b = cn('node-network'); + +interface NodeNetworkProps { + nodeId: string; + tenantName?: string; +} + +export function NodeNetwork({nodeId, tenantName}: NodeNetworkProps) { + const [autoRefreshInterval] = useAutoRefreshInterval(); + const filter = useTypedSelector(selectProblemFilter); + const dispatch = useTypedDispatch(); + + const [showId, setShowId] = React.useState(false); + const [showRacks, setShowRacks] = React.useState(false); + + const {currentData, isFetching, error} = networkApi.useGetNetworkInfoQuery( + tenantName || 'unknown', + { + pollingInterval: autoRefreshInterval, + }, + ); + const loading = isFetching && currentData === undefined; + + if (loading) { + return ( +
+ +
+ ); + } + + const netWorkInfo = currentData; + const allNodes = (netWorkInfo?.Tenants && netWorkInfo.Tenants[0].Nodes) ?? []; + + // Find the current node and its peers + const currentNode = allNodes.find((node) => node.NodeId.toString() === nodeId); + const peers = currentNode?.Peers ?? []; + + if (!error && !currentNode) { + return
No network data found for node {nodeId}
; + } + + if (!error && allNodes.length === 0) { + return
No nodes data
; + } + + // Group current node by type for consistent display + const currentNodeGrouped: Record = currentNode + ? {[currentNode.NodeType]: [currentNode]} + : {}; + + // Group peers by type + const peersGrouped = groupNodesByField(peers, 'NodeType'); + + return ( +
+ {error ? : null} + {currentNode ? ( +
+
+
+ { + dispatch(changeFilter(v)); + }} + className={b('problem-filter')} + /> +
+ { + setShowId(!showId); + }} + checked={showId} + > + ID + +
+
+ { + setShowRacks(!showRacks); + }} + checked={showRacks} + > + Racks + +
+
+
+ +
+
+
Current Node
+ +
+ +
+ {peers.length > 0 ? ( +
+
+ Network peers of node{' '} + + {currentNode.NodeId} + +
+
+ +
+
+ ) : ( +
+
+ +
+
+ No network peers found for this node +
+
+ )} +
+
+
+ ) : null} +
+ ); +} + +interface NodesProps { + nodes: Record; + showId?: boolean; + showRacks?: boolean; + filter: ProblemFilterValues; + dispatch: ReturnType; + isCurrentNode: boolean; +} + +function Nodes({nodes, showId, showRacks, filter, dispatch, isCurrentNode}: NodesProps) { + let problemNodesCount = 0; + + const result = Object.keys(nodes).map((key, j) => { + const nodesGroupedByRack = groupNodesByField(nodes[key], 'Rack'); + return ( +
+
{key} nodes
+
+ {showRacks + ? Object.keys(nodesGroupedByRack).map((rackKey, i) => ( +
+
+ {rackKey === 'undefined' ? '?' : rackKey} +
+ {nodesGroupedByRack[rackKey].map((nodeInfo, index) => { + let capacity, connected; + if ('Peers' in nodeInfo && nodeInfo.Peers) { + capacity = nodeInfo.Peers.length; + connected = getConnectedNodesCount(nodeInfo.Peers); + } + + if ( + (filter === ProblemFilterValues.PROBLEMS && + capacity !== connected) || + filter === ProblemFilterValues.ALL + ) { + problemNodesCount++; + return ( + { + dispatch(showTooltip(...params)); + }} + onMouseLeave={() => { + dispatch(hideTooltip()); + }} + onClick={undefined} + isBlurred={false} + /> + ); + } + return null; + })} +
+ )) + : nodes[key].map((nodeInfo, index) => { + let capacity, connected; + if ('Peers' in nodeInfo && nodeInfo.Peers) { + capacity = nodeInfo.Peers.length; + connected = getConnectedNodesCount(nodeInfo.Peers); + } + + if ( + (filter === ProblemFilterValues.PROBLEMS && + capacity !== connected) || + filter === ProblemFilterValues.ALL + ) { + problemNodesCount++; + return ( + { + dispatch(showTooltip(...params)); + }} + onMouseLeave={() => { + dispatch(hideTooltip()); + }} + onClick={undefined} + isBlurred={false} + /> + ); + } + return null; + })} +
+
+ ); + }); + + if (filter === ProblemFilterValues.PROBLEMS && problemNodesCount === 0) { + return ; + } else { + return result; + } +} + +function groupNodesByField>( + nodes: T[], + field: 'NodeType' | 'Rack', +) { + return nodes.reduce>((acc, node) => { + const fieldValue = node[field] || 'undefined'; + if (acc[fieldValue]) { + acc[fieldValue].push(node); + } else { + acc[fieldValue] = [node]; + } + return acc; + }, {}); +} \ No newline at end of file diff --git a/src/containers/Node/Node.tsx b/src/containers/Node/Node.tsx index acb395fc7f..044d5c15d7 100644 --- a/src/containers/Node/Node.tsx +++ b/src/containers/Node/Node.tsx @@ -28,6 +28,7 @@ import {Tablets} from '../Tablets/Tablets'; import type {NodeTab} from './NodePages'; import {NODE_TABS, getDefaultNodePath, nodePageQueryParams, nodePageTabSchema} from './NodePages'; +import {NodeNetwork} from './Network/NodeNetwork'; import NodeStructure from './NodeStructure/NodeStructure'; import {Threads} from './Threads/Threads'; import i18n from './i18n'; @@ -109,7 +110,7 @@ export function Node() { {} {} {error ? : null} - {} + {} {nodeId ? ( ; } - return ; + return ; } interface NodePageContentProps { @@ -260,6 +260,10 @@ function NodePageContent({ return ; } + case 'network': { + return ; + } + default: return false; } diff --git a/src/containers/Node/NodePages.ts b/src/containers/Node/NodePages.ts index 3f4c23ac13..34e8eaf85f 100644 --- a/src/containers/Node/NodePages.ts +++ b/src/containers/Node/NodePages.ts @@ -12,6 +12,7 @@ const NODE_TABS_IDS = { tablets: 'tablets', structure: 'structure', threads: 'threads', + network: 'network', } as const; export type NodeTab = ValueOf; @@ -41,6 +42,12 @@ export const NODE_TABS = [ return i18n('tabs.threads'); }, }, + { + id: NODE_TABS_IDS.network, + get title() { + return i18n('tabs.network'); + }, + }, ]; export const nodePageTabSchema = z.nativeEnum(NODE_TABS_IDS).catch(NODE_TABS_IDS.tablets); diff --git a/src/containers/Node/i18n/en.json b/src/containers/Node/i18n/en.json index 0762ce7af3..a395bacb2a 100644 --- a/src/containers/Node/i18n/en.json +++ b/src/containers/Node/i18n/en.json @@ -6,6 +6,7 @@ "tabs.structure": "Structure", "tabs.tablets": "Tablets", "tabs.threads": "Threads", + "tabs.network": "Network", "node": "Node", "fqdn": "FQDN", diff --git a/src/store/reducers/node/node.ts b/src/store/reducers/node/node.ts index 9f6b7c40d9..645f206528 100644 --- a/src/store/reducers/node/node.ts +++ b/src/store/reducers/node/node.ts @@ -15,24 +15,6 @@ export const nodeApi = api.injectEndpoints({ }, providesTags: ['All'], }), - getNodeNetworkInfo: build.query({ - queryFn: async ({nodeId, database}: {nodeId: string; database?: string}, {signal}) => { - try { - const data = await window.api.viewer.getNodes( - { - node_id: nodeId, - database, - fieldsRequired: ['Peers'], - }, - {signal}, - ); - return {data: data.Nodes?.[0] || null}; - } catch (error) { - return {error}; - } - }, - providesTags: ['All'], - }), getNodeStructure: build.query({ queryFn: async ({nodeId}: {nodeId: string}, {signal}) => { try { diff --git a/src/utils/yaMetrica.ts b/src/utils/yaMetrica.ts index 2b8f37b125..b6465eee4b 100644 --- a/src/utils/yaMetrica.ts +++ b/src/utils/yaMetrica.ts @@ -2,10 +2,11 @@ import {uiFactory} from '../uiFactory/uiFactory'; /** * Interface for a counter that provides methods for tracking metrics. - * @function hit - Tracks a hit event with optional arguments. https://yandex.ru/support/metrica/ru/objects/hit - * @function params - Sets parameters for the counter with optional arguments. https://yandex.ru/support/metrica/ru/objects/params-method - * @function userParams - Sets user-specific parameters for the counter with optional arguments. https://yandex.ru/support/metrica/ru/objects/user-params - * @function reachGoal - Tracks a goal achievement event with optional arguments. https://yandex.ru/support/metrica/ru/objects/reachgoal + * + * @method hit - Tracks a hit event with optional arguments. https://yandex.ru/support/metrica/ru/objects/hit + * @method params - Sets parameters for the counter with optional arguments. https://yandex.ru/support/metrica/ru/objects/params-method + * @method userParams - Sets user-specific parameters for the counter with optional arguments. https://yandex.ru/support/metrica/ru/objects/user-params + * @method reachGoal - Tracks a goal achievement event with optional arguments. https://yandex.ru/support/metrica/ru/objects/reachgoal */ export interface Counter { hit: (...args: unknown[]) => void; @@ -20,6 +21,7 @@ const yaMetricaMap = uiFactory.yaMetricaMap; * A fake implementation of a counter metric for Yandex.Metrica. * This class is used when the actual Yandex.Metrica counter is not defined, * and it provides a warning message the first time any of its methods are called. + * * @property name - The name of the counter. * @property warnShown - Flag to indicate if the warning has been shown. */ @@ -59,6 +61,7 @@ class FakeMetrica implements Counter { /** * Retrieves a Yandex Metrica instance by name from the global window object. * If no instance is found for the given name, returns a FakeMetrica instance instead. + * * @param name The name of the metrica to retrieve * @returns The Yandex Metrica instance if found, otherwise a FakeMetrica instance */ From 4789b5a015de33e1ab64e0c0fd1439deb463b409 Mon Sep 17 00:00:00 2001 From: Daria Vorontsova Date: Fri, 14 Nov 2025 18:20:41 +0300 Subject: [PATCH 4/4] feat: add network tab to node page with peer connectivity visualization --- src/components/NetworkTable/NetworkTable.tsx | 4 +- src/containers/Node/Network/NodeNetwork.scss | 98 ------ src/containers/Node/Network/NodeNetwork.tsx | 302 ------------------ src/containers/Node/Node.tsx | 9 +- src/containers/Nodes/Nodes.tsx | 3 + src/containers/Nodes/NodesTable.tsx | 5 + .../PaginatedNodes/GroupedNodesComponent.tsx | 3 + .../Nodes/PaginatedNodes/NodesComponent.tsx | 3 + .../Nodes/PaginatedNodes/PaginatedNodes.tsx | 1 + src/containers/Nodes/getNodes.ts | 2 + src/store/reducers/nodes/types.ts | 1 + 11 files changed, 29 insertions(+), 402 deletions(-) delete mode 100644 src/containers/Node/Network/NodeNetwork.scss delete mode 100644 src/containers/Node/Network/NodeNetwork.tsx diff --git a/src/components/NetworkTable/NetworkTable.tsx b/src/components/NetworkTable/NetworkTable.tsx index 5724d1bb89..d330760046 100644 --- a/src/components/NetworkTable/NetworkTable.tsx +++ b/src/components/NetworkTable/NetworkTable.tsx @@ -11,7 +11,7 @@ import { type NetworkWrapperProps = Pick< NodesProps, - 'path' | 'scrollContainerRef' | 'database' | 'databaseFullPath' + 'path' | 'scrollContainerRef' | 'database' | 'databaseFullPath' | 'nodeId' >; export function NetworkTable({ @@ -19,6 +19,7 @@ export function NetworkTable({ databaseFullPath, path, scrollContainerRef, + nodeId, }: NetworkWrapperProps) { return ( ); } diff --git a/src/containers/Node/Network/NodeNetwork.scss b/src/containers/Node/Network/NodeNetwork.scss deleted file mode 100644 index d923d11d00..0000000000 --- a/src/containers/Node/Network/NodeNetwork.scss +++ /dev/null @@ -1,98 +0,0 @@ -.node-network { - &__inner { - padding: 20px; - } - - &__controls-wrapper { - margin-bottom: 20px; - } - - &__controls { - display: flex; - align-items: center; - gap: 16px; - } - - &__problem-filter { - margin-right: 12px; - } - - &__checkbox-wrapper { - display: flex; - align-items: center; - } - - &__nodes-row { - display: flex; - gap: 32px; - align-items: flex-start; - } - - &__left, - &__right { - flex: 1; - } - - &__section-title { - font-size: 16px; - font-weight: 500; - margin-bottom: 16px; - } - - &__nodes-container { - margin-bottom: 24px; - } - - &__nodes-title { - font-size: 14px; - font-weight: 500; - margin-bottom: 12px; - } - - &__nodes { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - &__rack-column { - display: flex; - flex-direction: column; - align-items: center; - margin-right: 16px; - } - - &__rack-index { - font-size: 12px; - margin-bottom: 8px; - min-height: 16px; - } - - &__link { - color: var(--g-color-text-primary); - text-decoration: underline; - - &:hover { - color: var(--g-color-text-primary); - } - } - - &__placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 40px; - text-align: center; - } - - &__placeholder-img { - margin-bottom: 16px; - opacity: 0.5; - } - - &__placeholder-text { - color: var(--g-color-text-secondary); - font-size: 14px; - } -} \ No newline at end of file diff --git a/src/containers/Node/Network/NodeNetwork.tsx b/src/containers/Node/Network/NodeNetwork.tsx deleted file mode 100644 index ee16416195..0000000000 --- a/src/containers/Node/Network/NodeNetwork.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import React from 'react'; - -import {Checkbox, Icon, Loader} from '@gravity-ui/uikit'; -import {Link} from 'react-router-dom'; - -import {ResponseError} from '../../../components/Errors/ResponseError'; -import {Illustration} from '../../../components/Illustration'; -import {ProblemFilter} from '../../../components/ProblemFilter'; -import {networkApi} from '../../../store/reducers/network/network'; -import { - ProblemFilterValues, - changeFilter, - selectProblemFilter, -} from '../../../store/reducers/settings/settings'; -import {hideTooltip, showTooltip} from '../../../store/reducers/tooltip'; -import type {TNetNodeInfo, TNetNodePeerInfo} from '../../../types/api/netInfo'; -import {cn} from '../../../utils/cn'; -import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../../utils/hooks'; -import {getDefaultNodePath} from '../NodePages'; - -import {NodeNetwork as NodeNetworkComponent} from '../../../containers/Tenant/Diagnostics/Network/NodeNetwork/NodeNetwork'; -import {getConnectedNodesCount} from '../../../containers/Tenant/Diagnostics/Network/utils'; - -import networkIcon from '../../../assets/icons/network.svg'; - -import './NodeNetwork.scss'; - -const b = cn('node-network'); - -interface NodeNetworkProps { - nodeId: string; - tenantName?: string; -} - -export function NodeNetwork({nodeId, tenantName}: NodeNetworkProps) { - const [autoRefreshInterval] = useAutoRefreshInterval(); - const filter = useTypedSelector(selectProblemFilter); - const dispatch = useTypedDispatch(); - - const [showId, setShowId] = React.useState(false); - const [showRacks, setShowRacks] = React.useState(false); - - const {currentData, isFetching, error} = networkApi.useGetNetworkInfoQuery( - tenantName || 'unknown', - { - pollingInterval: autoRefreshInterval, - }, - ); - const loading = isFetching && currentData === undefined; - - if (loading) { - return ( -
- -
- ); - } - - const netWorkInfo = currentData; - const allNodes = (netWorkInfo?.Tenants && netWorkInfo.Tenants[0].Nodes) ?? []; - - // Find the current node and its peers - const currentNode = allNodes.find((node) => node.NodeId.toString() === nodeId); - const peers = currentNode?.Peers ?? []; - - if (!error && !currentNode) { - return
No network data found for node {nodeId}
; - } - - if (!error && allNodes.length === 0) { - return
No nodes data
; - } - - // Group current node by type for consistent display - const currentNodeGrouped: Record = currentNode - ? {[currentNode.NodeType]: [currentNode]} - : {}; - - // Group peers by type - const peersGrouped = groupNodesByField(peers, 'NodeType'); - - return ( -
- {error ? : null} - {currentNode ? ( -
-
-
- { - dispatch(changeFilter(v)); - }} - className={b('problem-filter')} - /> -
- { - setShowId(!showId); - }} - checked={showId} - > - ID - -
-
- { - setShowRacks(!showRacks); - }} - checked={showRacks} - > - Racks - -
-
-
- -
-
-
Current Node
- -
- -
- {peers.length > 0 ? ( -
-
- Network peers of node{' '} - - {currentNode.NodeId} - -
-
- -
-
- ) : ( -
-
- -
-
- No network peers found for this node -
-
- )} -
-
-
- ) : null} -
- ); -} - -interface NodesProps { - nodes: Record; - showId?: boolean; - showRacks?: boolean; - filter: ProblemFilterValues; - dispatch: ReturnType; - isCurrentNode: boolean; -} - -function Nodes({nodes, showId, showRacks, filter, dispatch, isCurrentNode}: NodesProps) { - let problemNodesCount = 0; - - const result = Object.keys(nodes).map((key, j) => { - const nodesGroupedByRack = groupNodesByField(nodes[key], 'Rack'); - return ( -
-
{key} nodes
-
- {showRacks - ? Object.keys(nodesGroupedByRack).map((rackKey, i) => ( -
-
- {rackKey === 'undefined' ? '?' : rackKey} -
- {nodesGroupedByRack[rackKey].map((nodeInfo, index) => { - let capacity, connected; - if ('Peers' in nodeInfo && nodeInfo.Peers) { - capacity = nodeInfo.Peers.length; - connected = getConnectedNodesCount(nodeInfo.Peers); - } - - if ( - (filter === ProblemFilterValues.PROBLEMS && - capacity !== connected) || - filter === ProblemFilterValues.ALL - ) { - problemNodesCount++; - return ( - { - dispatch(showTooltip(...params)); - }} - onMouseLeave={() => { - dispatch(hideTooltip()); - }} - onClick={undefined} - isBlurred={false} - /> - ); - } - return null; - })} -
- )) - : nodes[key].map((nodeInfo, index) => { - let capacity, connected; - if ('Peers' in nodeInfo && nodeInfo.Peers) { - capacity = nodeInfo.Peers.length; - connected = getConnectedNodesCount(nodeInfo.Peers); - } - - if ( - (filter === ProblemFilterValues.PROBLEMS && - capacity !== connected) || - filter === ProblemFilterValues.ALL - ) { - problemNodesCount++; - return ( - { - dispatch(showTooltip(...params)); - }} - onMouseLeave={() => { - dispatch(hideTooltip()); - }} - onClick={undefined} - isBlurred={false} - /> - ); - } - return null; - })} -
-
- ); - }); - - if (filter === ProblemFilterValues.PROBLEMS && problemNodesCount === 0) { - return ; - } else { - return result; - } -} - -function groupNodesByField>( - nodes: T[], - field: 'NodeType' | 'Rack', -) { - return nodes.reduce>((acc, node) => { - const fieldValue = node[field] || 'undefined'; - if (acc[fieldValue]) { - acc[fieldValue].push(node); - } else { - acc[fieldValue] = [node]; - } - return acc; - }, {}); -} \ No newline at end of file diff --git a/src/containers/Node/Node.tsx b/src/containers/Node/Node.tsx index 8109de9a65..7276d09ace 100644 --- a/src/containers/Node/Node.tsx +++ b/src/containers/Node/Node.tsx @@ -11,6 +11,7 @@ import {ResponseError} from '../../components/Errors/ResponseError'; import {FullNodeViewer} from '../../components/FullNodeViewer/FullNodeViewer'; import {InfoViewerSkeleton} from '../../components/InfoViewerSkeleton/InfoViewerSkeleton'; import {InternalLink} from '../../components/InternalLink'; +import {NetworkTable} from '../../components/NetworkTable/NetworkTable'; import {PageMetaWithAutorefresh} from '../../components/PageMeta/PageMeta'; import routes, {getDefaultNodePath} from '../../routes'; import { @@ -282,7 +283,13 @@ function NodePageContent({ } case 'network': { - return ; + return ( + + ); } default: diff --git a/src/containers/Nodes/Nodes.tsx b/src/containers/Nodes/Nodes.tsx index 772e2fbda9..02175b4adc 100644 --- a/src/containers/Nodes/Nodes.tsx +++ b/src/containers/Nodes/Nodes.tsx @@ -37,6 +37,7 @@ export interface NodesProps { requiredColumnsIds?: NodesColumnId[]; selectedColumnsKey?: string; groupByParams?: NodesGroupByField[]; + nodeId?: string; } export function Nodes({ @@ -50,6 +51,7 @@ export function Nodes({ requiredColumnsIds = REQUIRED_NODES_COLUMNS, selectedColumnsKey = NODES_TABLE_SELECTED_COLUMNS_LS_KEY, groupByParams = ALL_NODES_GROUP_BY_PARAMS, + nodeId, }: NodesProps) { const {handleDataFetched, columnsSettings} = useStorageColumnsSettings(); @@ -109,6 +111,7 @@ export function Nodes({ selectedColumnsKey={selectedColumnsKey} groupByParams={effectiveGroupByParams} onDataFetched={handleDataFetched} + nodeId={nodeId} /> ); } diff --git a/src/containers/Nodes/NodesTable.tsx b/src/containers/Nodes/NodesTable.tsx index 8728cb02e3..c7fe7bcb3b 100644 --- a/src/containers/Nodes/NodesTable.tsx +++ b/src/containers/Nodes/NodesTable.tsx @@ -20,6 +20,8 @@ interface NodesTableProps { database?: string; databaseFullPath?: string; + nodeId?: string; + searchValue: string; withProblems: boolean; uptimeFilter: NodesUptimeFilterValues; @@ -40,6 +42,7 @@ export function NodesTable({ path, database, databaseFullPath, + nodeId, searchValue, withProblems, uptimeFilter, @@ -56,6 +59,7 @@ export function NodesTable({ path, databaseFullPath, database, + nodeId, searchValue, withProblems, uptimeFilter, @@ -67,6 +71,7 @@ export function NodesTable({ path, databaseFullPath, database, + nodeId, searchValue, withProblems, uptimeFilter, diff --git a/src/containers/Nodes/PaginatedNodes/GroupedNodesComponent.tsx b/src/containers/Nodes/PaginatedNodes/GroupedNodesComponent.tsx index ee1b86d82d..ba8bb1fc27 100644 --- a/src/containers/Nodes/PaginatedNodes/GroupedNodesComponent.tsx +++ b/src/containers/Nodes/PaginatedNodes/GroupedNodesComponent.tsx @@ -102,6 +102,7 @@ interface GroupedNodesComponentProps { selectedColumnsKey: string; groupByParams: NodesGroupByField[]; onDataFetched?: (data: PaginatedTableData) => void; + nodeId?: string; } export function GroupedNodesComponent({ @@ -116,6 +117,7 @@ export function GroupedNodesComponent({ selectedColumnsKey, groupByParams, onDataFetched, + nodeId, }: GroupedNodesComponentProps) { const {searchValue, peerRoleFilter, groupByParam} = useNodesPageQueryParams( groupByParams, @@ -145,6 +147,7 @@ export function GroupedNodesComponent({ filter: searchValue, filter_peer_role: peerRoleFilter, group: groupByParam, + node_id: nodeId, limit: 0, }, { diff --git a/src/containers/Nodes/PaginatedNodes/NodesComponent.tsx b/src/containers/Nodes/PaginatedNodes/NodesComponent.tsx index d6cedfa1af..cc538eb295 100644 --- a/src/containers/Nodes/PaginatedNodes/NodesComponent.tsx +++ b/src/containers/Nodes/PaginatedNodes/NodesComponent.tsx @@ -27,6 +27,7 @@ interface NodesComponentProps { selectedColumnsKey: string; groupByParams: NodesGroupByField[]; onDataFetched?: (data: PaginatedTableData) => void; + nodeId?: string; } export function NodesComponent({ @@ -41,6 +42,7 @@ export function NodesComponent({ selectedColumnsKey, groupByParams, onDataFetched, + nodeId, }: NodesComponentProps) { const {searchValue, uptimeFilter, peerRoleFilter, withProblems} = useNodesPageQueryParams( groupByParams, @@ -78,6 +80,7 @@ export function NodesComponent({ path={path} database={database} databaseFullPath={databaseFullPath} + nodeId={nodeId} searchValue={searchValue} withProblems={withProblems} uptimeFilter={uptimeFilter} diff --git a/src/containers/Nodes/PaginatedNodes/PaginatedNodes.tsx b/src/containers/Nodes/PaginatedNodes/PaginatedNodes.tsx index 9438921d7c..401906bfcb 100644 --- a/src/containers/Nodes/PaginatedNodes/PaginatedNodes.tsx +++ b/src/containers/Nodes/PaginatedNodes/PaginatedNodes.tsx @@ -30,6 +30,7 @@ export interface PaginatedNodesProps { selectedColumnsKey: string; groupByParams: NodesGroupByField[]; onDataFetched?: (data: PaginatedTableData) => void; + nodeId?: string; } export function PaginatedNodes(props: PaginatedNodesProps) { diff --git a/src/containers/Nodes/getNodes.ts b/src/containers/Nodes/getNodes.ts index 391b5f0f2e..7519891079 100644 --- a/src/containers/Nodes/getNodes.ts +++ b/src/containers/Nodes/getNodes.ts @@ -31,6 +31,7 @@ export const getNodes: FetchData< peerRoleFilter, filterGroup, filterGroupBy, + nodeId, } = filters ?? {}; const sortField = getNodesColumnSortField(columnId); @@ -55,6 +56,7 @@ export const getNodes: FetchData< filter_peer_role: peerRoleFilter, filter_group: filterGroup, filter_group_by: filterGroupBy, + node_id: nodeId, fieldsRequired: dataFieldsRequired, }); const preparedResponse = prepareStorageNodesResponse(response); diff --git a/src/store/reducers/nodes/types.ts b/src/store/reducers/nodes/types.ts index 023ab558a3..e9e3e2b57e 100644 --- a/src/store/reducers/nodes/types.ts +++ b/src/store/reducers/nodes/types.ts @@ -10,6 +10,7 @@ export interface NodesFilters { path?: string; databaseFullPath?: string; database?: string; + nodeId?: string; filterGroup?: string; filterGroupBy?: NodesGroupByField;