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
+
+
+
+
+
+
+
+
+
+ {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 4d7b19a535..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';
@@ -259,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",