Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ const handleInputChange = useCallback(
- Follow key format: `<context>_<content>` (e.g., `action_save`, `field_name`)
- Register keysets with `registerKeysets()` using unique component name

### Display Placeholders (MANDATORY)

- ALWAYS use `EMPTY_DATA_PLACEHOLDER` for empty UI values. Do not hardcode em or en dashes (`—`, `–`) as placeholders. Hyphen `-`/dashes may be used as separators in titles/ranges. Before submitting a PR, grep for `—` and `–` and ensure placeholder usages use `EMPTY_DATA_PLACEHOLDER` from `src/utils/constants.ts`.

### State Management

- Use Redux Toolkit with domain-based organization
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ const [urlParam, setUrlParam] = useQueryParam('sort', SortOrderParam);
- **NEVER** call APIs directly - use `window.api.module.method()`
- **NEVER** mutate state in RTK Query - return new objects/arrays
- **NEVER** hardcode user-facing strings - use i18n
- **ALWAYS** use `EMPTY_DATA_PLACEHOLDER` for empty UI values. Do not hardcode em dashes `—` or en dashes `–` as placeholders. Hyphen `-` and dashes may be used as separators in titles/ranges. Before submitting, grep the code for `—`/`–` and ensure placeholders use `EMPTY_DATA_PLACEHOLDER` from `src/utils/constants.ts`.
- **ALWAYS** use `cn()` for classNames: `const b = cn('component-name')`
- **ALWAYS** clear errors on user input
- **ALWAYS** handle loading states in UI
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ const [urlParam, setUrlParam] = useQueryParam('sort', SortOrderParam);
- **NEVER** call APIs directly - use `window.api.module.method()`
- **NEVER** mutate state in RTK Query - return new objects/arrays
- **NEVER** hardcode user-facing strings - use i18n
- **ALWAYS** use `EMPTY_DATA_PLACEHOLDER` for empty UI values. Do not hardcode em dashes `—` or en dashes `–` as placeholders. Hyphen `-` and dashes may be used as separators in titles/ranges. Before submitting, grep the code for `—`/`–` and ensure placeholders use `EMPTY_DATA_PLACEHOLDER` from `src/utils/constants.ts`.
- **ALWAYS** use `cn()` for classNames: `const b = cn('component-name')`
- **ALWAYS** clear errors on user input
- **ALWAYS** handle loading states in UI
Expand Down
3 changes: 2 additions & 1 deletion src/components/NodeHostWrapper/NodeHostWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {getDefaultNodePath} from '../../containers/Node/NodePages';
import type {GetNodeRefFunc, NodeAddress} from '../../types/additionalProps';
import type {TNodeInfo, TSystemStateInfo} from '../../types/api/nodes';
import {EMPTY_DATA_PLACEHOLDER} from '../../utils/constants';
import {
createDeveloperUIInternalPageHref,
createDeveloperUILinkWithNodeId,
Expand Down Expand Up @@ -30,7 +31,7 @@ export const NodeHostWrapper = ({
statusForIcon = 'SystemState',
}: NodeHostWrapperProps) => {
if (!node.Host) {
return <span>—</span>;
return EMPTY_DATA_PLACEHOLDER;
}

const status = statusForIcon === 'ConnectStatus' ? node.ConnectStatus : node.SystemState;
Expand Down
10 changes: 10 additions & 0 deletions src/components/nodesColumns/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ export function getRackColumn<T extends {Rack?: string}>(): Column<T> {
width: 100,
};
}

export function getPileNameColumn<T extends {PileName?: string}>(): Column<T> {
return {
name: NODES_COLUMNS_IDS.PileName,
header: i18n('PileName'),
align: DataTable.LEFT,
render: ({row}) => row.PileName || EMPTY_DATA_PLACEHOLDER,
width: 100,
};
}
export function getVersionColumn<T extends {Version?: string}>(): Column<T> {
return {
name: NODES_COLUMNS_IDS.Version,
Expand Down
9 changes: 9 additions & 0 deletions src/components/nodesColumns/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const NODES_COLUMNS_IDS = {
Missing: 'Missing',
Tablets: 'Tablets',
PDisks: 'PDisks',
PileName: 'PileName',
} as const;

export type NodesColumnId = ValueOf<typeof NODES_COLUMNS_IDS>;
Expand Down Expand Up @@ -130,6 +131,9 @@ export const NODES_COLUMNS_TITLES = {
get PDisks() {
return i18n('pdisks');
},
get PileName() {
return i18n('PileName');
},
} as const satisfies Record<NodesColumnId, string>;

const NODES_COLUMNS_GROUP_BY_TITLES = {
Expand Down Expand Up @@ -178,6 +182,9 @@ const NODES_COLUMNS_GROUP_BY_TITLES = {
get PingTime() {
return i18n('ping-time');
},
get PileName() {
return i18n('PileName');
},
} as const satisfies Record<NodesGroupByField, string>;

export function getNodesGroupByFieldTitle(groupByField: NodesGroupByField) {
Expand Down Expand Up @@ -213,6 +220,7 @@ export const NODES_COLUMNS_TO_DATA_FIELDS: Record<NodesColumnId, NodesRequiredFi
Missing: ['Missing'],
Tablets: ['Tablets', 'Database'],
PDisks: ['PDisks'],
PileName: ['PileName'],
};

const NODES_COLUMNS_TO_SORT_FIELDS: Record<NodesColumnId, NodesSortValue | undefined> = {
Expand Down Expand Up @@ -242,6 +250,7 @@ const NODES_COLUMNS_TO_SORT_FIELDS: Record<NodesColumnId, NodesSortValue | undef
Missing: 'Missing',
Tablets: undefined,
PDisks: undefined,
PileName: undefined,
};

export function getNodesColumnSortField(columnId?: string) {
Expand Down
4 changes: 1 addition & 3 deletions src/components/nodesColumns/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@
"sessions": "Sessions",
"missing": "Missing",
"pdisks": "PDisks",

"field_memory-used": "Memory used",
"field_memory-limit": "Memory limit",

"PileName": "Pile Name",
"system-state": "System State",
"connect-status": "Connect Status",
"utilization": "Utilization",
Expand All @@ -33,7 +32,6 @@
"ping": "Ping",
"send": "Send",
"receive": "Receive",

"max": "Max",
"min": "Min",
"avg": "Avg",
Expand Down
11 changes: 11 additions & 0 deletions src/containers/Cluster/ClusterInfo/ClusterInfo.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,15 @@

margin-left: 5px;
}

&__details-layout {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems this class is never used

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add to bots check for unused classes

align-items: flex-start;
gap: var(--g-spacing-6);
}

&__bridge-table {
flex: 0 0 360px; // do not shrink, fixed basis so stats don't take all space

min-width: 360px;
}
}
33 changes: 25 additions & 8 deletions src/containers/Cluster/ClusterInfo/ClusterInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import {InfoViewerSkeleton} from '../../../components/InfoViewerSkeleton/InfoVie
import {LinkWithIcon} from '../../../components/LinkWithIcon/LinkWithIcon';
import type {ClusterGroupsStats} from '../../../store/reducers/cluster/types';
import type {AdditionalClusterProps} from '../../../types/additionalProps';
import type {TClusterInfo} from '../../../types/api/cluster';
import type {TBridgePile, TClusterInfo} from '../../../types/api/cluster';
import type {IResponseError} from '../../../types/api/error';
import {formatNumber} from '../../../utils/dataFormatters/dataFormatters';
import {BridgeInfoTable} from '../ClusterOverview/components/BridgeInfoTable';
import i18n from '../i18n';
import {getTotalStorageGroupsUsed} from '../utils';

Expand All @@ -19,12 +20,15 @@ import {getInfo, getStorageGroupStats} from './utils/utils';

import './ClusterInfo.scss';

const GROUPS_SECTION_GAP = 10;

interface ClusterInfoProps {
cluster?: TClusterInfo;
loading?: boolean;
error?: IResponseError | string;
additionalClusterProps?: AdditionalClusterProps;
groupStats?: ClusterGroupsStats;
bridgePiles?: TBridgePile[];
}

export const ClusterInfo = ({
Expand All @@ -33,6 +37,7 @@ export const ClusterInfo = ({
error,
additionalClusterProps = {},
groupStats = {},
bridgePiles,
}: ClusterInfoProps) => {
const {info = [], links = []} = additionalClusterProps;

Expand Down Expand Up @@ -96,13 +101,25 @@ export const ClusterInfo = ({
}
return (
<InfoSection>
<Text as="div" variant="subheader-2" className={b('section-title')}>
{i18n('title_storage-groups')}{' '}
<Text variant="subheader-2" color="secondary">
{formatNumber(total)}
</Text>
</Text>
<Flex gap={2}>{stats}</Flex>
<Flex gap={GROUPS_SECTION_GAP} width="full">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'd better hardcode gap property like in other usecases of Flex component. No use in constant, cause it is needed only this place.

<Flex direction="column" gap={2}>
<Text as="div" variant="subheader-2" className={b('section-title')}>
{i18n('title_storage-groups')}{' '}
<Text variant="subheader-2" color="secondary">
{formatNumber(total)}
</Text>
</Text>
<Flex gap={2}>{stats}</Flex>
</Flex>
{bridgePiles?.length ? (
<Flex direction="column" gap={2} className={b('bridge-table')}>
<Text as="div" variant="subheader-2" className={b('section-title')}>
{i18n('title_bridge')}
</Text>
<BridgeInfoTable piles={bridgePiles} />
</Flex>
) : null}
</Flex>
</InfoSection>
);
};
Expand Down
22 changes: 18 additions & 4 deletions src/containers/Cluster/ClusterOverview/ClusterOverview.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import React from 'react';

import {ArrowToggle, Disclosure, Flex, Icon, Text} from '@gravity-ui/uikit';

import {ResponseError} from '../../../components/Errors/ResponseError';
import {useClusterDashboardAvailable} from '../../../store/reducers/capabilities/hooks';
import {
useBridgeModeEnabled,
useClusterDashboardAvailable,
} from '../../../store/reducers/capabilities/hooks';
import type {ClusterGroupsStats} from '../../../store/reducers/cluster/types';
import type {AdditionalClusterProps} from '../../../types/additionalProps';
import {isClusterInfoV2, isClusterInfoV5} from '../../../types/api/cluster';
import type {TClusterInfo} from '../../../types/api/cluster';
import type {TBridgePile, TClusterInfo} from '../../../types/api/cluster';
import type {IResponseError} from '../../../types/api/error';
import {valueIsDefined} from '../../../utils';
import {EXPAND_CLUSTER_DASHBOARD} from '../../../utils/constants';
Expand Down Expand Up @@ -36,6 +41,15 @@ interface ClusterOverviewProps {

export function ClusterOverview(props: ClusterOverviewProps) {
const [expandDashboard, setExpandDashboard] = useSetting<boolean>(EXPAND_CLUSTER_DASHBOARD);
const bridgeModeEnabled = useBridgeModeEnabled();
let bridgePiles: TBridgePile[] | undefined;
if (isClusterInfoV5(props.cluster)) {
const {BridgeInfo} = props.cluster;
const shouldShowBridge = bridgeModeEnabled && Boolean(BridgeInfo?.Piles?.length);
if (shouldShowBridge) {
bridgePiles = BridgeInfo?.Piles;
}
}
if (props.error) {
return <ResponseError error={props.error} className={b('error')} />;
}
Expand Down Expand Up @@ -67,7 +81,7 @@ export function ClusterOverview(props: ClusterOverviewProps) {
)}
</Disclosure.Summary>
<ClusterDashboard {...props} />
<ClusterInfo {...props} />
<ClusterInfo {...props} bridgePiles={bridgePiles} />
</Disclosure>
</Flex>
);
Expand All @@ -93,7 +107,7 @@ function ClusterDoughnuts({cluster, groupStats = {}, loading, collapsed}: Cluste
if (loading) {
return <ClusterDashboardSkeleton collapsed={collapsed} />;
}
const metricsCards = [];
const metricsCards: React.ReactNode[] = [];
if (isClusterInfoV2(cluster)) {
const {CoresUsed, NumberOfCpus, CoresTotal} = cluster;
const total = CoresTotal ?? NumberOfCpus;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React from 'react';

import type {Column} from '@gravity-ui/react-data-table';
import DataTable from '@gravity-ui/react-data-table';

import {ResizeableDataTable} from '../../../../components/ResizeableDataTable/ResizeableDataTable';
import type {TBridgePile} from '../../../../types/api/cluster';
import {DEFAULT_TABLE_SETTINGS, EMPTY_DATA_PLACEHOLDER} from '../../../../utils/constants';
import {formatNumber} from '../../../../utils/dataFormatters/dataFormatters';
import i18n from '../../i18n';

interface BridgeInfoTableProps {
piles: TBridgePile[];
collapsed?: boolean;
}

export function BridgeInfoTable({piles}: BridgeInfoTableProps) {
const columns = React.useMemo<Column<TBridgePile>[]>(
() => [
{
name: 'Name',
header: i18n('label_name'),
width: 160,
align: DataTable.LEFT,
},
{
name: 'IsPrimary',
header: i18n('label_primary'),
width: 110,
align: DataTable.LEFT,
render: ({row}) => (row.IsPrimary ? i18n('value_yes') : i18n('value_no')),
},
{
name: 'State',
header: i18n('label_state'),
width: 160,
align: DataTable.LEFT,
},
{
name: 'Nodes',
header: i18n('label_nodes'),
width: 100,
align: DataTable.RIGHT,
render: ({row}) =>
row.Nodes === undefined ? EMPTY_DATA_PLACEHOLDER : formatNumber(row.Nodes),
},
],
[],
);

return (
<ResizeableDataTable<TBridgePile>
columnsWidthLSKey="bridge-columns-width"
data={piles}
columns={columns}
settings={{...DEFAULT_TABLE_SETTINGS, sortable: false}}
rowKey={(row) => `${row.PileId ?? ''}|${row.Name ?? ''}`}
/>
);
}
7 changes: 7 additions & 0 deletions src/containers/Cluster/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,15 @@
"title_network": "Network",
"title_links": "Links",
"title_details": "Details",
"title_bridge": "Bridge Piles",
"label_overview": "Overview",
"label_load": "Load",
"label_name": "Name",
"label_primary": "Primary",
"label_state": "State",
"label_nodes": "Nodes",
"value_yes": "Yes",
"value_no": "No",
"context_of": "of",
"context_cpu": "CPU load",
"context_memory": "Memory used",
Expand Down
4 changes: 2 additions & 2 deletions src/containers/Clusters/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {VersionsBar} from '../../components/VersionsBar/VersionsBar';
import type {PreparedCluster} from '../../store/reducers/clusters/types';
import {EFlag} from '../../types/api/enums';
import {uiFactory} from '../../uiFactory/uiFactory';
import {EMPTY_DATA_PLACEHOLDER} from '../../utils/constants';
import {formatNumber, formatStorageValuesToTb} from '../../utils/dataFormatters/dataFormatters';
import {createDeveloperUIMonitoringPageHref} from '../../utils/developerUI/developerUI';
import {getCleanBalancerValue} from '../../utils/parseBalancer';
Expand All @@ -25,10 +26,9 @@ import {clusterTabsIds, getClusterPath} from '../Cluster/utils';
import {COLUMNS_NAMES, COLUMNS_TITLES} from './constants';
import i18n from './i18n';
import {b} from './shared';

export const CLUSTERS_COLUMNS_WIDTH_LS_KEY = 'clustersTableColumnsWidth';

const EMPTY_CELL = <span className={b('empty-cell')}></span>;
const EMPTY_CELL = <span className={b('empty-cell')}>{EMPTY_DATA_PLACEHOLDER}</span>;

interface ClustersColumnsParams {
isEditClusterAvailable?: boolean;
Expand Down
5 changes: 3 additions & 2 deletions src/containers/Heatmap/Heatmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {heatmapApi, setHeatmapOptions} from '../../store/reducers/heatmap';
import {hideTooltip, showTooltip} from '../../store/reducers/tooltip';
import type {IHeatmapMetricValue} from '../../types/store/heatmap';
import {cn} from '../../utils/cn';
import {EMPTY_DATA_PLACEHOLDER} from '../../utils/constants';
import {formatNumber} from '../../utils/dataFormatters/dataFormatters';
import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../utils/hooks';

Expand Down Expand Up @@ -148,13 +149,13 @@ export const Heatmap = ({path, database}: HeatmapProps) => {
<div className={b('limits-block')}>
<div className={b('limits-title')}>min:</div>
<div className={b('limits-value')}>
{Number.isInteger(min) ? formatNumber(min) : '—'}
{Number.isInteger(min) ? formatNumber(min) : EMPTY_DATA_PLACEHOLDER}
</div>
</div>
<div className={b('limits-block')}>
<div className={b('limits-title')}>max:</div>
<div className={b('limits-value')}>
{Number.isInteger(max) ? formatNumber(max) : '—'}
{Number.isInteger(max) ? formatNumber(max) : EMPTY_DATA_PLACEHOLDER}
</div>
</div>
<div className={b('limits-block')}>
Expand Down
Loading
Loading