diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index af34971d46..158dbb29b7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,3 +1,11 @@ +# Copilot Instructions + +## Internationalization (i18n) + +- Do NOT hardcode user-facing strings. +- ALWAYS use the component i18n keysets. +- For key naming, see `i18n-naming-ruleset.md` in the repo root. + # GitHub Copilot Instructions for YDB Embedded UI > **Note**: This file contains project-specific instructions for GitHub Copilot code review and assistance. @@ -82,6 +90,10 @@ const handleInputChange = useCallback( - Follow key format: `_` (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 diff --git a/AGENTS.md b/AGENTS.md index 22b00c12c1..61bc506048 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,21 +232,7 @@ Uses BEM naming convention with `cn()` utility from `utils/cn`. Create a block f ### Internationalization (i18n) -All user-facing text must be internationalized using the i18n system. Follow the naming rules from `i18n-naming-ruleset.md`: - -- **Component Structure**: Each component has an `i18n/` folder with `en.json` and `index.ts` -- **Registration**: Use `registerKeysets()` with a unique component name -- **Key Format**: Follow `_` pattern (e.g., `action_save`, `field_name`, `alert_error`) -- **Context Prefixes**: - - `action_` - buttons, links, menu items - - `field_` - form fields, table columns - - `title_` - page/section titles - - `alert_` - notifications, errors - - `context_` - descriptions, hints - - `confirm_` - confirmation dialogs - - `value_` - status values, options -- **NEVER** use hardcoded strings in UI components -- **ALWAYS** create i18n entries for all user-visible text +See `i18n-naming-ruleset.md` in the repo root for all i18n conventions (naming and usage). ### Performance Considerations @@ -297,6 +283,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 diff --git a/CLAUDE.md b/CLAUDE.md index 22b00c12c1..61bc506048 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,21 +232,7 @@ Uses BEM naming convention with `cn()` utility from `utils/cn`. Create a block f ### Internationalization (i18n) -All user-facing text must be internationalized using the i18n system. Follow the naming rules from `i18n-naming-ruleset.md`: - -- **Component Structure**: Each component has an `i18n/` folder with `en.json` and `index.ts` -- **Registration**: Use `registerKeysets()` with a unique component name -- **Key Format**: Follow `_` pattern (e.g., `action_save`, `field_name`, `alert_error`) -- **Context Prefixes**: - - `action_` - buttons, links, menu items - - `field_` - form fields, table columns - - `title_` - page/section titles - - `alert_` - notifications, errors - - `context_` - descriptions, hints - - `confirm_` - confirmation dialogs - - `value_` - status values, options -- **NEVER** use hardcoded strings in UI components -- **ALWAYS** create i18n entries for all user-visible text +See `i18n-naming-ruleset.md` in the repo root for all i18n conventions (naming and usage). ### Performance Considerations @@ -297,6 +283,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 diff --git a/src/components/NodeHostWrapper/NodeHostWrapper.tsx b/src/components/NodeHostWrapper/NodeHostWrapper.tsx index 5f60d8936e..1c97a8c855 100644 --- a/src/components/NodeHostWrapper/NodeHostWrapper.tsx +++ b/src/components/NodeHostWrapper/NodeHostWrapper.tsx @@ -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, @@ -30,7 +31,7 @@ export const NodeHostWrapper = ({ statusForIcon = 'SystemState', }: NodeHostWrapperProps) => { if (!node.Host) { - return ; + return EMPTY_DATA_PLACEHOLDER; } const status = statusForIcon === 'ConnectStatus' ? node.ConnectStatus : node.SystemState; diff --git a/src/components/nodesColumns/columns.tsx b/src/components/nodesColumns/columns.tsx index 7605f8be1d..729a5ea404 100644 --- a/src/components/nodesColumns/columns.tsx +++ b/src/components/nodesColumns/columns.tsx @@ -110,6 +110,16 @@ export function getRackColumn(): Column { width: 100, }; } + +export function getPileNameColumn(): Column { + return { + name: NODES_COLUMNS_IDS.PileName, + header: i18n('field_pile-name'), + align: DataTable.LEFT, + render: ({row}) => row.PileName || EMPTY_DATA_PLACEHOLDER, + width: 100, + }; +} export function getVersionColumn(): Column { return { name: NODES_COLUMNS_IDS.Version, diff --git a/src/components/nodesColumns/constants.ts b/src/components/nodesColumns/constants.ts index 7416714baa..daffdc1d07 100644 --- a/src/components/nodesColumns/constants.ts +++ b/src/components/nodesColumns/constants.ts @@ -32,6 +32,7 @@ export const NODES_COLUMNS_IDS = { Missing: 'Missing', Tablets: 'Tablets', PDisks: 'PDisks', + PileName: 'PileName', } as const; export type NodesColumnId = ValueOf; @@ -130,6 +131,9 @@ export const NODES_COLUMNS_TITLES = { get PDisks() { return i18n('pdisks'); }, + get PileName() { + return i18n('field_pile-name'); + }, } as const satisfies Record; const NODES_COLUMNS_GROUP_BY_TITLES = { @@ -178,6 +182,9 @@ const NODES_COLUMNS_GROUP_BY_TITLES = { get PingTime() { return i18n('ping-time'); }, + get PileName() { + return i18n('field_pile-name'); + }, } as const satisfies Record; export function getNodesGroupByFieldTitle(groupByField: NodesGroupByField) { @@ -213,6 +220,7 @@ export const NODES_COLUMNS_TO_DATA_FIELDS: Record = { @@ -242,6 +250,7 @@ const NODES_COLUMNS_TO_SORT_FIELDS: Record { const {info = [], links = []} = additionalClusterProps; @@ -96,13 +99,28 @@ export const ClusterInfo = ({ } return ( - - {i18n('title_storage-groups')}{' '} - - {formatNumber(total)} - - - {stats} + + + + {i18n('title_storage-groups')}{' '} + + {formatNumber(total)} + + + {stats} + + {bridgePiles?.length ? ( + + + {i18n('title_bridge')}{' '} + + {formatNumber(bridgePiles.length)} + + + + + ) : null} + ); }; diff --git a/src/containers/Cluster/ClusterOverview/ClusterOverview.tsx b/src/containers/Cluster/ClusterOverview/ClusterOverview.tsx index f66494ed45..05ab4a8570 100644 --- a/src/containers/Cluster/ClusterOverview/ClusterOverview.tsx +++ b/src/containers/Cluster/ClusterOverview/ClusterOverview.tsx @@ -1,7 +1,12 @@ +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'; @@ -36,6 +41,16 @@ interface ClusterOverviewProps { export function ClusterOverview(props: ClusterOverviewProps) { const [expandDashboard, setExpandDashboard] = useSetting(EXPAND_CLUSTER_DASHBOARD); + const bridgeModeEnabled = useBridgeModeEnabled(); + + const bridgePiles = React.useMemo(() => { + if (!bridgeModeEnabled || !isClusterInfoV5(props.cluster)) { + return undefined; + } + + const {BridgeInfo} = props.cluster; + return BridgeInfo?.Piles?.length ? BridgeInfo.Piles : undefined; + }, [props.cluster, bridgeModeEnabled]); if (props.error) { return ; } @@ -67,7 +82,7 @@ export function ClusterOverview(props: ClusterOverviewProps) { )} - + ); @@ -93,7 +108,7 @@ function ClusterDoughnuts({cluster, groupStats = {}, loading, collapsed}: Cluste if (loading) { return ; } - const metricsCards = []; + const metricsCards: React.ReactNode[] = []; if (isClusterInfoV2(cluster)) { const {CoresUsed, NumberOfCpus, CoresTotal} = cluster; const total = CoresTotal ?? NumberOfCpus; diff --git a/src/containers/Cluster/ClusterOverview/components/BridgeInfoTable.scss b/src/containers/Cluster/ClusterOverview/components/BridgeInfoTable.scss new file mode 100644 index 0000000000..a60ce052de --- /dev/null +++ b/src/containers/Cluster/ClusterOverview/components/BridgeInfoTable.scss @@ -0,0 +1,27 @@ +@use '../../../../styles/mixins.scss'; + +.bridge-info-table { + height: 100%; + + &__pile-card { + --g-definition-list-item-gap: var(--g-spacing-3); + + width: 347px; + padding: var(--g-spacing-3) var(--g-spacing-4); + + border-radius: var(--g-border-radius-s); + background-color: var(--g-color-base-generic-ultralight); + + @include mixins.body-2-typography(); + } + + &__status-icon { + &_primary { + color: var(--g-color-text-positive); + } + + &:not(&_primary) { + color: var(--g-color-private-white-250); + } + } +} diff --git a/src/containers/Cluster/ClusterOverview/components/BridgeInfoTable.tsx b/src/containers/Cluster/ClusterOverview/components/BridgeInfoTable.tsx new file mode 100644 index 0000000000..d930bc47de --- /dev/null +++ b/src/containers/Cluster/ClusterOverview/components/BridgeInfoTable.tsx @@ -0,0 +1,93 @@ +import React from 'react'; + +import {CircleCheckFill, CircleXmarkFill} from '@gravity-ui/icons'; +import {DefinitionList, Flex, Icon, Label, Text} from '@gravity-ui/uikit'; + +import type {TBridgePile} from '../../../../types/api/cluster'; +import {cn} from '../../../../utils/cn'; +import {EMPTY_DATA_PLACEHOLDER} from '../../../../utils/constants'; +import {formatNumber} from '../../../../utils/dataFormatters/dataFormatters'; +import i18n from '../../i18n'; + +import './BridgeInfoTable.scss'; + +const b = cn('bridge-info-table'); + +interface BridgeInfoTableProps { + piles: TBridgePile[]; +} + +interface BridgePileCardProps { + pile: TBridgePile; +} + +const BridgePileCard = React.memo(function BridgePileCard({pile}: BridgePileCardProps) { + const renderPrimaryStatus = React.useCallback(() => { + const isPrimary = pile.IsPrimary; + const icon = isPrimary ? CircleCheckFill : CircleXmarkFill; + const text = isPrimary ? i18n('value_yes') : i18n('value_no'); + + return ( + + + {text} + + ); + }, [pile.IsPrimary]); + + const renderStateStatus = React.useCallback(() => { + if (!pile.State) { + return EMPTY_DATA_PLACEHOLDER; + } + + const isSynchronized = pile.State.toUpperCase() === 'SYNCHRONIZED'; + const theme = isSynchronized ? 'success' : 'info'; + + return ; + }, [pile.State]); + + const info = React.useMemo( + () => [ + { + name: i18n('field_primary'), + content: renderPrimaryStatus(), + }, + { + name: i18n('field_state'), + content: renderStateStatus(), + }, + { + name: i18n('field_nodes'), + content: + pile.Nodes === undefined ? EMPTY_DATA_PLACEHOLDER : formatNumber(pile.Nodes), + }, + ], + [renderPrimaryStatus, renderStateStatus, pile.Nodes], + ); + + return ( + + {pile.Name || EMPTY_DATA_PLACEHOLDER} + + {info.map(({name, content}) => ( + + {content} + + ))} + + + ); +}); + +export const BridgeInfoTable = React.memo(function BridgeInfoTable({piles}: BridgeInfoTableProps) { + const renderedPiles = React.useMemo( + () => piles.map((pile, index) => ), + [piles], + ); + + return ( + + {renderedPiles} + + ); +}); diff --git a/src/containers/Cluster/i18n/en.json b/src/containers/Cluster/i18n/en.json index 18e8a46119..8bdd168b47 100644 --- a/src/containers/Cluster/i18n/en.json +++ b/src/containers/Cluster/i18n/en.json @@ -17,8 +17,15 @@ "title_network": "Network", "title_links": "Links", "title_details": "Details", + "title_bridge": "Bridge Piles", "label_overview": "Overview", "label_load": "Load", + "field_name": "Name", + "field_primary": "Primary", + "field_state": "State", + "field_nodes": "Nodes", + "value_yes": "Yes", + "value_no": "No", "context_of": "of", "context_cpu": "CPU load", "context_memory": "Memory used", diff --git a/src/containers/Clusters/columns.tsx b/src/containers/Clusters/columns.tsx index 121fd8da2d..82e0806bb4 100644 --- a/src/containers/Clusters/columns.tsx +++ b/src/containers/Clusters/columns.tsx @@ -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'; @@ -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 = ; +const EMPTY_CELL = {EMPTY_DATA_PLACEHOLDER}; interface ClustersColumnsParams { isEditClusterAvailable?: boolean; diff --git a/src/containers/Heatmap/Heatmap.tsx b/src/containers/Heatmap/Heatmap.tsx index 6b3639ce79..1a3897071a 100644 --- a/src/containers/Heatmap/Heatmap.tsx +++ b/src/containers/Heatmap/Heatmap.tsx @@ -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'; @@ -148,13 +149,13 @@ export const Heatmap = ({path, database}: HeatmapProps) => {
min:
- {Number.isInteger(min) ? formatNumber(min) : '—'} + {Number.isInteger(min) ? formatNumber(min) : EMPTY_DATA_PLACEHOLDER}
max:
- {Number.isInteger(max) ? formatNumber(max) : '—'} + {Number.isInteger(max) ? formatNumber(max) : EMPTY_DATA_PLACEHOLDER}
diff --git a/src/containers/Nodes/Nodes.tsx b/src/containers/Nodes/Nodes.tsx index 8751e76378..b12fd2e699 100644 --- a/src/containers/Nodes/Nodes.tsx +++ b/src/containers/Nodes/Nodes.tsx @@ -2,10 +2,12 @@ import React from 'react'; import type {Column} from '../../components/PaginatedTable'; import { + NODES_COLUMNS_IDS, isMonitoringUserNodesColumn, isViewerUserNodesColumn, } from '../../components/nodesColumns/constants'; import type {NodesColumnId} from '../../components/nodesColumns/constants'; +import {useBridgeModeEnabled} from '../../store/reducers/capabilities/hooks'; import type {NodesPreparedEntity} from '../../store/reducers/nodes/types'; import type {AdditionalNodesProps} from '../../types/additionalProps'; import type {NodesGroupByField} from '../../types/api/nodes'; @@ -50,21 +52,46 @@ export function Nodes({ selectedColumnsKey = NODES_TABLE_SELECTED_COLUMNS_LS_KEY, groupByParams = ALL_NODES_GROUP_BY_PARAMS, }: NodesProps) { + const bridgeModeEnabled = useBridgeModeEnabled(); + + const columnsWithPile = React.useMemo(() => { + return bridgeModeEnabled + ? columns + : columns.filter((c) => c.name !== NODES_COLUMNS_IDS.PileName); + }, [bridgeModeEnabled, columns]); + + const effectiveDefaultColumns = React.useMemo(() => { + if (!bridgeModeEnabled) { + return defaultColumnsIds; + } + return defaultColumnsIds.includes(NODES_COLUMNS_IDS.PileName) + ? defaultColumnsIds + : [...defaultColumnsIds, NODES_COLUMNS_IDS.PileName]; + }, [bridgeModeEnabled, defaultColumnsIds]); const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const isViewerUser = useIsViewerUser(); const preparedColumns = React.useMemo(() => { if (isUserAllowedToMakeChanges) { - return columns; + return columnsWithPile; } - const filteredColumns = columns.filter( + const filteredColumns = columnsWithPile.filter( (column) => !isMonitoringUserNodesColumn(column.name), ); if (isViewerUser) { return filteredColumns; } return filteredColumns.filter((column) => !isViewerUserNodesColumn(column.name)); - }, [columns, isUserAllowedToMakeChanges, isViewerUser]); + }, [columnsWithPile, isUserAllowedToMakeChanges, isViewerUser]); + + const effectiveGroupByParams = React.useMemo(() => { + if (!bridgeModeEnabled || !groupByParams) { + return groupByParams; + } + return groupByParams.includes('PileName') + ? groupByParams + : ([...groupByParams, 'PileName'] as NodesGroupByField[]); + }, [bridgeModeEnabled, groupByParams]); return ( ); } diff --git a/src/containers/Nodes/columns/columns.tsx b/src/containers/Nodes/columns/columns.tsx index b8a225914e..3f2d67ee3d 100644 --- a/src/containers/Nodes/columns/columns.tsx +++ b/src/containers/Nodes/columns/columns.tsx @@ -6,6 +6,7 @@ import { getMemoryColumn, getNodeIdColumn, getNodeNameColumn, + getPileNameColumn, getPoolsColumn, getRAMColumn, getRackColumn, @@ -24,6 +25,7 @@ export function getNodesColumns(params: GetNodesColumnsParams): Column(params), getNodeNameColumn(), getDataCenterColumn(), + getPileNameColumn(), getRackColumn(), getUptimeColumn(), getCpuColumn(), diff --git a/src/containers/Storage/PaginatedStorageGroups/StorageGroupsControls.tsx b/src/containers/Storage/PaginatedStorageGroups/StorageGroupsControls.tsx index aafb89fe86..0841c7f97f 100644 --- a/src/containers/Storage/PaginatedStorageGroups/StorageGroupsControls.tsx +++ b/src/containers/Storage/PaginatedStorageGroups/StorageGroupsControls.tsx @@ -6,6 +6,7 @@ import {Select, TableColumnSetup, Text} from '@gravity-ui/uikit'; import {EntitiesCount} from '../../../components/EntitiesCount/EntitiesCount'; import {usePaginatedTableState} from '../../../components/PaginatedTable/PaginatedTableContext'; import {Search} from '../../../components/Search/Search'; +import {useBridgeModeEnabled} from '../../../store/reducers/capabilities/hooks'; import {useIsUserAllowedToMakeChanges} from '../../../utils/hooks/useIsUserAllowedToMakeChanges'; import {STORAGE_GROUPS_GROUP_BY_OPTIONS} from '../PaginatedStorageGroupsTable/columns/constants'; import {StorageTypeFilter} from '../StorageTypeFilter/StorageTypeFilter'; @@ -49,6 +50,13 @@ export function StorageGroupsControls({ } = useStorageQueryParams(); const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); + const bridgeModeEnabled = useBridgeModeEnabled(); + const groupByOptions = React.useMemo(() => { + if (bridgeModeEnabled) { + return STORAGE_GROUPS_GROUP_BY_OPTIONS; + } + return STORAGE_GROUPS_GROUP_BY_OPTIONS.filter((opt) => opt.value !== 'PileName'); + }, [bridgeModeEnabled]); const handleGroupBySelectUpdate = (value: string[]) => { handleStorageGroupsGroupByParamChange(value[0]); @@ -91,7 +99,7 @@ export function StorageGroupsControls({ storageGroupsGroupByParam ? [storageGroupsGroupByParam] : undefined } onUpdate={handleGroupBySelectUpdate} - options={STORAGE_GROUPS_GROUP_BY_OPTIONS} + options={groupByOptions} /> ) : null} diff --git a/src/containers/Storage/PaginatedStorageGroupsTable/columns/columns.tsx b/src/containers/Storage/PaginatedStorageGroupsTable/columns/columns.tsx index a1cdf77c2a..b87d9d727c 100644 --- a/src/containers/Storage/PaginatedStorageGroupsTable/columns/columns.tsx +++ b/src/containers/Storage/PaginatedStorageGroupsTable/columns/columns.tsx @@ -52,6 +52,14 @@ const poolNameColumn: StorageGroupsColumn = { align: DataTable.LEFT, }; +const pileNameColumn: StorageGroupsColumn = { + name: STORAGE_GROUPS_COLUMNS_IDS.PileName, + header: STORAGE_GROUPS_COLUMNS_TITLES.PileName, + width: 120, + render: ({row}) => row.PileName || EMPTY_DATA_PLACEHOLDER, + align: DataTable.LEFT, +}; + const typeColumn: StorageGroupsColumn = { name: STORAGE_GROUPS_COLUMNS_IDS.MediaType, header: STORAGE_GROUPS_COLUMNS_TITLES.MediaType, @@ -60,7 +68,7 @@ const typeColumn: StorageGroupsColumn = { align: DataTable.LEFT, render: ({row}) => ( - + {'\u00a0'} {row.Encryption && ( { const columns = [ groupIdColumn, poolNameColumn, + pileNameColumn, typeColumn, erasureColumn, degradedColumn, diff --git a/src/containers/Storage/PaginatedStorageGroupsTable/columns/constants.ts b/src/containers/Storage/PaginatedStorageGroupsTable/columns/constants.ts index 8125fee8d4..12ffed74e3 100644 --- a/src/containers/Storage/PaginatedStorageGroupsTable/columns/constants.ts +++ b/src/containers/Storage/PaginatedStorageGroupsTable/columns/constants.ts @@ -16,6 +16,7 @@ export const STORAGE_GROUPS_SELECTED_COLUMNS_LS_KEY = 'storageGroupsSelectedColu export const STORAGE_GROUPS_COLUMNS_IDS = { GroupId: 'GroupId', PoolName: 'PoolName', + PileName: 'PileName', MediaType: 'MediaType', Erasure: 'Erasure', Used: 'Used', @@ -72,6 +73,9 @@ export const STORAGE_GROUPS_COLUMNS_TITLES = { get MediaType() { return i18n('type'); }, + get PileName() { + return i18n('pile-name'); + }, get Erasure() { return i18n('erasure'); }, @@ -135,6 +139,9 @@ const STORAGE_GROUPS_COLUMNS_GROUP_BY_TITLES = { get PoolName() { return i18n('pool-name'); }, + get PileName() { + return i18n('pile-name'); + }, get Kind() { return i18n('type'); }, @@ -157,6 +164,7 @@ const STORAGE_GROUPS_COLUMNS_GROUP_BY_TITLES = { const STORAGE_GROUPS_GROUP_BY_PARAMS = [ 'PoolName', + 'PileName', 'MediaType', 'Encryption', 'Erasure', @@ -187,6 +195,7 @@ export const storageGroupsGroupByParamSchema = z export const GROUPS_COLUMNS_TO_DATA_FIELDS: Record = { GroupId: ['GroupId'], PoolName: ['PoolName'], + PileName: ['PileName'], // We display MediaType and Encryption in one Type column MediaType: ['MediaType', 'Encryption'], Erasure: ['Erasure'], @@ -212,6 +221,7 @@ const STORAGE_GROUPS_COLUMNS_TO_SORT_FIELDS: Record< > = { GroupId: 'GroupId', PoolName: 'PoolName', + PileName: undefined, MediaType: 'MediaType', Erasure: 'Erasure', Used: 'Used', diff --git a/src/containers/Storage/PaginatedStorageGroupsTable/columns/hooks.ts b/src/containers/Storage/PaginatedStorageGroupsTable/columns/hooks.ts index cbc95dce41..eaffdf2711 100644 --- a/src/containers/Storage/PaginatedStorageGroupsTable/columns/hooks.ts +++ b/src/containers/Storage/PaginatedStorageGroupsTable/columns/hooks.ts @@ -1,5 +1,6 @@ import React from 'react'; +import {useBridgeModeEnabled} from '../../../../store/reducers/capabilities/hooks'; import {VISIBLE_ENTITIES} from '../../../../store/reducers/storage/constants'; import { useIsUserAllowedToMakeChanges, @@ -25,21 +26,25 @@ export function useStorageGroupsSelectedColumns({ }: GetStorageGroupsColumnsParams) { const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const isViewerUser = useIsViewerUser(); + const bridgeModeEnabled = useBridgeModeEnabled(); const columns = React.useMemo(() => { const allColumns = getStorageGroupsColumns({viewContext}); + const filteredByBridge = bridgeModeEnabled + ? allColumns + : allColumns.filter((c) => c.name !== STORAGE_GROUPS_COLUMNS_IDS.PileName); if (isUserAllowedToMakeChanges) { - return allColumns; + return filteredByBridge; } - const filteredColumns = allColumns.filter( + const filteredColumns = filteredByBridge.filter( (column) => !isMonitoringUserGroupsColumn(column.name), ); if (isViewerUser) { return filteredColumns; } return filteredColumns.filter((column) => !isViewerGroupsColumn(column.name)); - }, [isUserAllowedToMakeChanges, viewContext, isViewerUser]); + }, [isUserAllowedToMakeChanges, viewContext, isViewerUser, bridgeModeEnabled]); const requiredColumns = React.useMemo(() => { if (visibleEntities === VISIBLE_ENTITIES.missing) { @@ -53,11 +58,20 @@ export function useStorageGroupsSelectedColumns({ return REQUIRED_STORAGE_GROUPS_COLUMNS; }, [visibleEntities]); + const defaultColumns = React.useMemo(() => { + if (!bridgeModeEnabled) { + return DEFAULT_STORAGE_GROUPS_COLUMNS; + } + return DEFAULT_STORAGE_GROUPS_COLUMNS.includes(STORAGE_GROUPS_COLUMNS_IDS.PileName) + ? DEFAULT_STORAGE_GROUPS_COLUMNS + : [...DEFAULT_STORAGE_GROUPS_COLUMNS, STORAGE_GROUPS_COLUMNS_IDS.PileName]; + }, [bridgeModeEnabled]); + return useSelectedColumns( columns, STORAGE_GROUPS_SELECTED_COLUMNS_LS_KEY, STORAGE_GROUPS_COLUMNS_TITLES, - DEFAULT_STORAGE_GROUPS_COLUMNS, + defaultColumns, requiredColumns, ); } diff --git a/src/containers/Storage/PaginatedStorageGroupsTable/columns/i18n/en.json b/src/containers/Storage/PaginatedStorageGroupsTable/columns/i18n/en.json index 30b4bfdee1..afe0585a61 100644 --- a/src/containers/Storage/PaginatedStorageGroupsTable/columns/i18n/en.json +++ b/src/containers/Storage/PaginatedStorageGroupsTable/columns/i18n/en.json @@ -1,5 +1,6 @@ { "pool-name": "Pool Name", + "pile-name": "Pile Name", "type": "Type", "encryption": "Encryption", "erasure": "Erasure", diff --git a/src/containers/Storage/PaginatedStorageNodesTable/columns/columns.tsx b/src/containers/Storage/PaginatedStorageNodesTable/columns/columns.tsx index 4aa9624e33..12fef84e9d 100644 --- a/src/containers/Storage/PaginatedStorageNodesTable/columns/columns.tsx +++ b/src/containers/Storage/PaginatedStorageNodesTable/columns/columns.tsx @@ -9,6 +9,7 @@ import { getMissingDisksColumn, getNodeIdColumn, getNodeNameColumn, + getPileNameColumn, getPoolsColumn, getRAMColumn, getRackColumn, @@ -86,6 +87,7 @@ export const getStorageNodesColumns = ({ getHostColumn({getNodeRef, database}), getNodeNameColumn(), getDataCenterColumn(), + getPileNameColumn(), getRackColumn(), getUptimeColumn(), getCpuColumn(), diff --git a/src/containers/Storage/PaginatedStorageNodesTable/columns/hooks.ts b/src/containers/Storage/PaginatedStorageNodesTable/columns/hooks.ts index 0f33c2b577..ca39a25a21 100644 --- a/src/containers/Storage/PaginatedStorageNodesTable/columns/hooks.ts +++ b/src/containers/Storage/PaginatedStorageNodesTable/columns/hooks.ts @@ -4,6 +4,7 @@ import { NODES_COLUMNS_IDS, NODES_COLUMNS_TITLES, } from '../../../../components/nodesColumns/constants'; +import {useBridgeModeEnabled} from '../../../../store/reducers/capabilities/hooks'; import {VISIBLE_ENTITIES} from '../../../../store/reducers/storage/constants'; import {useSelectedColumns} from '../../../../utils/hooks/useSelectedColumns'; @@ -22,14 +23,17 @@ export function useStorageNodesSelectedColumns({ viewContext, columnsSettings, }: GetStorageNodesColumnsParams) { + const bridgeModeEnabled = useBridgeModeEnabled(); + const columns = React.useMemo(() => { - return getStorageNodesColumns({ + const all = getStorageNodesColumns({ database, additionalNodesProps, viewContext, columnsSettings, }); - }, [database, additionalNodesProps, viewContext, columnsSettings]); + return bridgeModeEnabled ? all : all.filter((c) => c.name !== NODES_COLUMNS_IDS.PileName); + }, [database, additionalNodesProps, viewContext, columnsSettings, bridgeModeEnabled]); const requiredColumns = React.useMemo(() => { if (visibleEntities === VISIBLE_ENTITIES.missing) { @@ -38,11 +42,20 @@ export function useStorageNodesSelectedColumns({ return REQUIRED_STORAGE_NODES_COLUMNS; }, [visibleEntities]); + const defaultColumns = React.useMemo(() => { + if (!bridgeModeEnabled) { + return DEFAULT_STORAGE_NODES_COLUMNS; + } + return DEFAULT_STORAGE_NODES_COLUMNS.includes(NODES_COLUMNS_IDS.PileName) + ? DEFAULT_STORAGE_NODES_COLUMNS + : [...DEFAULT_STORAGE_NODES_COLUMNS, NODES_COLUMNS_IDS.PileName]; + }, [bridgeModeEnabled]); + return useSelectedColumns( columns, STORAGE_NODES_SELECTED_COLUMNS_LS_KEY, NODES_COLUMNS_TITLES, - DEFAULT_STORAGE_NODES_COLUMNS, + defaultColumns, requiredColumns, ); } diff --git a/src/containers/Tablet/components/TabletTable/TabletTable.tsx b/src/containers/Tablet/components/TabletTable/TabletTable.tsx index 3ac096ba7d..0f3101c38a 100644 --- a/src/containers/Tablet/components/TabletTable/TabletTable.tsx +++ b/src/containers/Tablet/components/TabletTable/TabletTable.tsx @@ -9,6 +9,7 @@ import {NodeId} from '../../../../components/NodeId/NodeId'; import {ResizeableDataTable} from '../../../../components/ResizeableDataTable/ResizeableDataTable'; import {TabletState} from '../../../../components/TabletState/TabletState'; import {TabletUptime} from '../../../../components/UptimeViewer/UptimeViewer'; +import {EMPTY_DATA_PLACEHOLDER} from '../../../../lib'; import {getTabletPagePath} from '../../../../routes'; import type {ITabletPreparedHistoryItem} from '../../../../types/store/tablet'; @@ -63,7 +64,7 @@ const getColumns: (props: { width: 300, render: ({row}) => { if (!row.fqdn) { - return ; + return EMPTY_DATA_PLACEHOLDER; } return ; }, diff --git a/src/containers/Tablets/TabletsTable.tsx b/src/containers/Tablets/TabletsTable.tsx index 2482f96e58..cb29c9684d 100644 --- a/src/containers/Tablets/TabletsTable.tsx +++ b/src/containers/Tablets/TabletsTable.tsx @@ -96,7 +96,7 @@ function getColumns({database, nodeId}: {database?: string; nodeId?: string | nu }, render: ({row}) => { if (!row.fqdn) { - return ; + return EMPTY_DATA_PLACEHOLDER; } return ; }, diff --git a/src/containers/Tenant/Diagnostics/Consumers/columns/columns.tsx b/src/containers/Tenant/Diagnostics/Consumers/columns/columns.tsx index 1d3549ee26..9052e80949 100644 --- a/src/containers/Tenant/Diagnostics/Consumers/columns/columns.tsx +++ b/src/containers/Tenant/Diagnostics/Consumers/columns/columns.tsx @@ -4,6 +4,7 @@ import qs from 'qs'; import {InternalLink} from '../../../../../components/InternalLink'; import {SpeedMultiMeter} from '../../../../../components/SpeedMultiMeter'; +import {EMPTY_DATA_PLACEHOLDER} from '../../../../../lib'; import {TENANT_DIAGNOSTICS_TABS_IDS} from '../../../../../store/reducers/tenant/constants'; import type {IPreparedConsumerData} from '../../../../../types/store/topic'; import {cn} from '../../../../../utils/cn'; @@ -30,7 +31,7 @@ export const columns: Column[] = [ align: DataTable.LEFT, render: ({row}) => { if (!row.name) { - return '–'; + return EMPTY_DATA_PLACEHOLDER; } const queryParams = qs.parse(location.search, { diff --git a/src/containers/Tenant/Diagnostics/Partitions/columns/columns.tsx b/src/containers/Tenant/Diagnostics/Partitions/columns/columns.tsx index 503e1d33ce..e1b2342192 100644 --- a/src/containers/Tenant/Diagnostics/Partitions/columns/columns.tsx +++ b/src/containers/Tenant/Diagnostics/Partitions/columns/columns.tsx @@ -7,6 +7,7 @@ import {SpeedMultiMeter} from '../../../../../components/SpeedMultiMeter'; import {useTopicDataAvailable} from '../../../../../store/reducers/capabilities/hooks'; import {TENANT_DIAGNOSTICS_TABS_IDS} from '../../../../../store/reducers/tenant/constants'; import {cn} from '../../../../../utils/cn'; +import {EMPTY_DATA_PLACEHOLDER} from '../../../../../utils/constants'; import {formatBytes, formatMsToUptime} from '../../../../../utils/dataFormatters/dataFormatters'; import {isNumeric} from '../../../../../utils/utils'; import {getDefaultNodePath} from '../../../../Node/NodePages'; @@ -182,7 +183,7 @@ export const allColumns: Column[] = [ row.readSessionId ? ( ) : ( - '–' + EMPTY_DATA_PLACEHOLDER ), }, { @@ -198,7 +199,7 @@ export const allColumns: Column[] = [ row.readerName ? ( ) : ( - '–' + EMPTY_DATA_PLACEHOLDER ), }, { @@ -219,7 +220,7 @@ export const allColumns: Column[] = [ hasClipboardButton /> ) : ( - '–' + EMPTY_DATA_PLACEHOLDER ), }, { @@ -240,7 +241,7 @@ export const allColumns: Column[] = [ hasClipboardButton /> ) : ( - '–' + EMPTY_DATA_PLACEHOLDER ), }, ]; diff --git a/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx b/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx index 182dbc4345..32ace6202f 100644 --- a/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx +++ b/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx @@ -2,6 +2,7 @@ import DataTable from '@gravity-ui/react-data-table'; import type {Column, OrderType} from '@gravity-ui/react-data-table'; import {FixedHeightQuery} from '../../../../../components/FixedHeightQuery/FixedHeightQuery'; +import {EMPTY_DATA_PLACEHOLDER} from '../../../../../lib'; import type {KeyValueRow} from '../../../../../types/api/query'; import {cn} from '../../../../../utils/cn'; import {formatDateTime, formatNumber} from '../../../../../utils/dataFormatters/dataFormatters'; @@ -81,7 +82,7 @@ const readBytesColumn: Column = { const userSIDColumn: Column = { name: QUERIES_COLUMNS_IDS.UserSID, header: QUERIES_COLUMNS_TITLES.UserSID, - render: ({row}) =>
{row.UserSID || '–'}
, + render: ({row}) =>
{row.UserSID || EMPTY_DATA_PLACEHOLDER}
, align: DataTable.LEFT, width: 120, }; @@ -120,7 +121,9 @@ const requestUnitsColumn: Column = { const applicationColumn: Column = { name: QUERIES_COLUMNS_IDS.ApplicationName, header: QUERIES_COLUMNS_TITLES.ApplicationName, - render: ({row}) =>
{row.ApplicationName || '–'}
, + render: ({row}) => ( +
{row.ApplicationName || EMPTY_DATA_PLACEHOLDER}
+ ), }; export function getTopQueriesColumns() { diff --git a/src/containers/Tenants/Tenants.tsx b/src/containers/Tenants/Tenants.tsx index 1648234ec6..7d2afad2aa 100644 --- a/src/containers/Tenants/Tenants.tsx +++ b/src/containers/Tenants/Tenants.tsx @@ -194,7 +194,7 @@ export const Tenants = ({additionalTenantsProps, scrollContainerRef}: TenantsPro { name: 'State', width: 150, - render: ({row}) => (row.State ? row.State.toLowerCase() : '—'), + render: ({row}) => (row.State ? row.State.toLowerCase() : EMPTY_DATA_PLACEHOLDER), customStyle: () => ({textTransform: 'capitalize'}), }, { @@ -206,7 +206,7 @@ export const Tenants = ({additionalTenantsProps, scrollContainerRef}: TenantsPro if (row.cpu && row.cpu > 10_000) { return formatCPU(row.cpu); } - return '—'; + return EMPTY_DATA_PLACEHOLDER; }, align: DataTable.RIGHT, defaultOrder: DataTable.DESCENDING, @@ -215,7 +215,8 @@ export const Tenants = ({additionalTenantsProps, scrollContainerRef}: TenantsPro name: 'memory', header: 'Memory', width: 120, - render: ({row}) => (row.memory ? formatStorageValuesToGb(row.memory) : '—'), + render: ({row}) => + row.memory ? formatStorageValuesToGb(row.memory) : EMPTY_DATA_PLACEHOLDER, align: DataTable.RIGHT, defaultOrder: DataTable.DESCENDING, }, @@ -223,7 +224,8 @@ export const Tenants = ({additionalTenantsProps, scrollContainerRef}: TenantsPro name: 'storage', header: 'Storage', width: 120, - render: ({row}) => (row.storage ? formatStorageValuesToGb(row.storage) : '—'), + render: ({row}) => + row.storage ? formatStorageValuesToGb(row.storage) : EMPTY_DATA_PLACEHOLDER, align: DataTable.RIGHT, defaultOrder: DataTable.DESCENDING, }, @@ -259,7 +261,8 @@ export const Tenants = ({additionalTenantsProps, scrollContainerRef}: TenantsPro name: 'nodesCount', header: 'Nodes', width: 100, - render: ({row}) => (row.nodesCount ? formatNumber(row.nodesCount) : '—'), + render: ({row}) => + row.nodesCount ? formatNumber(row.nodesCount) : EMPTY_DATA_PLACEHOLDER, align: DataTable.RIGHT, defaultOrder: DataTable.DESCENDING, }, @@ -267,7 +270,8 @@ export const Tenants = ({additionalTenantsProps, scrollContainerRef}: TenantsPro name: 'groupsCount', header: 'Groups', width: 100, - render: ({row}) => (row.groupsCount ? formatNumber(row.groupsCount) : '—'), + render: ({row}) => + row.groupsCount ? formatNumber(row.groupsCount) : EMPTY_DATA_PLACEHOLDER, align: DataTable.RIGHT, defaultOrder: DataTable.DESCENDING, }, diff --git a/src/containers/Versions/NodesTable/NodesTable.tsx b/src/containers/Versions/NodesTable/NodesTable.tsx index 572f4e183e..63718f3b34 100644 --- a/src/containers/Versions/NodesTable/NodesTable.tsx +++ b/src/containers/Versions/NodesTable/NodesTable.tsx @@ -6,10 +6,13 @@ import { getHostColumn, getLoadAverageColumn, getNodeIdColumn, + getPileNameColumn, getRAMColumn, getUptimeColumn, } from '../../../components/nodesColumns/columns'; +import {NODES_COLUMNS_IDS} from '../../../components/nodesColumns/constants'; import type {GetNodesColumnsParams} from '../../../components/nodesColumns/types'; +import {useBridgeModeEnabled} from '../../../store/reducers/capabilities/hooks'; import type {NodesPreparedEntity} from '../../../store/reducers/nodes/types'; import {DEFAULT_TABLE_SETTINGS} from '../../../utils/constants'; import {useAdditionalNodesProps} from '../../../utils/hooks/useAdditionalNodesProps'; @@ -18,12 +21,13 @@ const VERSIONS_COLUMNS_WIDTH_LS_KEY = 'versionsTableColumnsWidth'; function getColumns(params: GetNodesColumnsParams): Column[] { return [ - getNodeIdColumn(), - getHostColumn(params), - getUptimeColumn(), - getRAMColumn(), - getCpuColumn(), - getLoadAverageColumn(), + getNodeIdColumn(), + getHostColumn(params), + getPileNameColumn(), + getUptimeColumn(), + getRAMColumn(), + getCpuColumn(), + getLoadAverageColumn(), ]; } @@ -33,8 +37,12 @@ interface NodesTableProps { export const NodesTable = ({nodes}: NodesTableProps) => { const additionalNodesProps = useAdditionalNodesProps(); + const bridgeModeEnabled = useBridgeModeEnabled(); - const columns = getColumns({getNodeRef: additionalNodesProps?.getNodeRef}); + const allColumns = getColumns({getNodeRef: additionalNodesProps?.getNodeRef}); + const columns = bridgeModeEnabled + ? allColumns + : allColumns.filter((c) => c.name !== NODES_COLUMNS_IDS.PileName); return ( state, + (_state: RootState, database?: string) => database, + (state, database) => + selectDatabaseCapabilities(state, database).data?.Settings?.Cluster?.BridgeModeEnabled, +); + export async function queryCapability( capability: Capability, database: string | undefined, diff --git a/src/store/reducers/capabilities/hooks.ts b/src/store/reducers/capabilities/hooks.ts index 6f03517ee3..97b98eb858 100644 --- a/src/store/reducers/capabilities/hooks.ts +++ b/src/store/reducers/capabilities/hooks.ts @@ -4,6 +4,7 @@ import {useDatabaseFromQuery} from '../../../utils/hooks/useDatabaseFromQuery'; import { capabilitiesApi, + selectBridgeModeEnabled, selectCapabilityVersion, selectDatabaseCapabilities, selectGraphShardExists, @@ -105,6 +106,12 @@ export const useGraphShardExists = () => { return useTypedSelector((state) => selectGraphShardExists(state, database)); }; +export const useBridgeModeEnabled = () => { + const database = useDatabaseFromQuery(); + const enabled = useTypedSelector((state) => selectBridgeModeEnabled(state, database)); + return Boolean(enabled); +}; + export const useClusterWithoutAuthInUI = () => { return useGetSecuritySetting('UseLoginProvider') === false; }; diff --git a/src/store/reducers/nodes/types.ts b/src/store/reducers/nodes/types.ts index 25874fb6a0..894ed2f418 100644 --- a/src/store/reducers/nodes/types.ts +++ b/src/store/reducers/nodes/types.ts @@ -15,6 +15,8 @@ export interface NodesPreparedEntity extends PreparedNodeSystemState { NodeName?: string; SystemState?: EFlag; Version?: string; + // Bridge mode + PileName?: string; StartTime?: string; DisconnectTime?: string; diff --git a/src/store/reducers/storage/types.ts b/src/store/reducers/storage/types.ts index 1103b85b2a..dcdd033e96 100644 --- a/src/store/reducers/storage/types.ts +++ b/src/store/reducers/storage/types.ts @@ -37,6 +37,9 @@ export interface PreparedStorageNode extends PreparedNodeSystemState { Missing: number; MaximumSlotsPerDisk: number; MaximumDisksPerNode: number; + + // Bridge mode + PileName?: string; } export interface PreparedStorageGroupFilters { @@ -54,6 +57,7 @@ export interface PreparedStorageGroupFilters { export interface PreparedStorageGroup { PoolName?: string; + PileName?: string; MediaType?: string; Encryption?: boolean; ErasureSpecies?: Erasure; diff --git a/src/store/reducers/storage/utils.ts b/src/store/reducers/storage/utils.ts index c80272541e..6cda694057 100644 --- a/src/store/reducers/storage/utils.ts +++ b/src/store/reducers/storage/utils.ts @@ -219,6 +219,7 @@ const prepareStorageNodeData = ( ...prepareNodeSystemState(node.SystemState), NodeId: node.NodeId, DiskSpaceUsage: node.DiskSpaceUsage, + PileName: node.PileName, PDisks: pDisks, VDisks: vDisks, Missing: missing, diff --git a/src/store/reducers/tenants/utils.ts b/src/store/reducers/tenants/utils.ts index c9ec581ad9..503a53c99c 100644 --- a/src/store/reducers/tenants/utils.ts +++ b/src/store/reducers/tenants/utils.ts @@ -3,7 +3,11 @@ import {isNil} from 'lodash'; import type {PoolName, TPoolStats} from '../../../types/api/nodes'; import type {TTenant} from '../../../types/api/tenant'; import {EType} from '../../../types/api/tenant'; -import {DEFAULT_DANGER_THRESHOLD, DEFAULT_WARNING_THRESHOLD} from '../../../utils/constants'; +import { + DEFAULT_DANGER_THRESHOLD, + DEFAULT_WARNING_THRESHOLD, + EMPTY_DATA_PLACEHOLDER, +} from '../../../utils/constants'; import {isNumeric, safeParseNumber} from '../../../utils/utils'; import {METRIC_STATUS} from './contants'; @@ -11,7 +15,7 @@ import type {PreparedTenant} from './types'; const getControlPlaneValue = (tenant: TTenant) => { const parts = tenant.Name?.split('/'); - const defaultValue = parts?.length ? parts[parts.length - 1] : '—'; + const defaultValue = parts?.length ? parts[parts.length - 1] : EMPTY_DATA_PLACEHOLDER; const controlPlaneName = tenant.ControlPlane?.name; return controlPlaneName ?? defaultValue; diff --git a/src/types/api/capabilities.ts b/src/types/api/capabilities.ts index c2bc53f9a8..739bce59b6 100644 --- a/src/types/api/capabilities.ts +++ b/src/types/api/capabilities.ts @@ -8,6 +8,9 @@ export interface CapabilitiesResponse { Database?: { GraphShardExists?: boolean; }; + Cluster?: { + BridgeModeEnabled?: boolean; + }; }; } diff --git a/src/types/api/cluster.ts b/src/types/api/cluster.ts index ac44e8f78e..675f2b8658 100644 --- a/src/types/api/cluster.ts +++ b/src/types/api/cluster.ts @@ -79,6 +79,7 @@ export interface TClusterInfoV5 extends TClusterInfoV2 { NetworkUtilization?: number; /** value is uint64 */ NetworkWriteThroughput?: string; + BridgeInfo?: TBridgeInfo; } export type TClusterInfo = TClusterInfoV1 | TClusterInfoV2 | TClusterInfoV5; @@ -95,3 +96,20 @@ function isClusterParticularVersionOrHigher(info: TClusterInfo | undefined, vers info && 'Version' in info && typeof info.Version === 'number' && info.Version >= version, ); } + +export interface TBridgePile { + /** unique pile identifier */ + PileId?: number; + /** pile name, e.g., r1 */ + Name?: string; + /** pile state (string from backend, e.g., SYNCHRONIZED) */ + State?: string; + /** whether this pile is primary */ + IsPrimary?: boolean; + /** number of nodes in the pile */ + Nodes?: number; +} + +export interface TBridgeInfo { + Piles?: TBridgePile[]; +} diff --git a/src/types/api/nodes.ts b/src/types/api/nodes.ts index b42b7a31d3..7677e2e803 100644 --- a/src/types/api/nodes.ts +++ b/src/types/api/nodes.ts @@ -68,6 +68,9 @@ export interface TNodeInfo { ReversePingTimeUs?: string; // Avg Peers?: TNodeStateInfo[]; ReversePeers?: TNodeStateInfo[]; + + // Bridge mode + PileName?: string; } export interface TNodesGroup { @@ -262,7 +265,8 @@ export type NodesGroupByField = | 'ConnectStatus' // v13 | 'NetworkUtilization' // v13 | 'ClockSkew' // v13 - | 'PingTime'; // v13 + | 'PingTime' // v13 + | 'PileName'; export type NodesRequiredField = | 'NodeId' @@ -292,7 +296,8 @@ export type NodesRequiredField = | `ClockSkew` // v13 | `PingTime` // v13 | `SendThroughput` // v13 - | `ReceiveThroughput`; // v13 + | `ReceiveThroughput` // v13 + | 'PileName'; export type NodesSortValue = | 'NodeId' diff --git a/src/types/api/storage.ts b/src/types/api/storage.ts index 6a23ad6055..0c55d5bd83 100644 --- a/src/types/api/storage.ts +++ b/src/types/api/storage.ts @@ -132,6 +132,8 @@ export interface TGroupsStorageGroupInfo { /** uint64 */ GroupGeneration?: string; PoolName?: string; + // Bridge mode + PileName?: string; Encryption?: boolean; Overall?: EFlag; DiskSpace?: EFlag; @@ -278,6 +280,7 @@ export type GroupsGroupByField = | 'Usage' | 'DiskSpaceUsage' | 'PoolName' + | 'PileName' | 'Kind' | 'Encryption' | 'MediaType' @@ -288,6 +291,7 @@ export type GroupsGroupByField = export type GroupsRequiredField = | 'GroupId' // always required | 'PoolName' + | 'PileName' | 'Kind' | 'MediaType' | 'Erasure' diff --git a/tests/suites/bridge/bridge.test.ts b/tests/suites/bridge/bridge.test.ts new file mode 100644 index 0000000000..0134e6cb60 --- /dev/null +++ b/tests/suites/bridge/bridge.test.ts @@ -0,0 +1,126 @@ +import {expect, test} from '@playwright/test'; + +import {backend, nodesPage} from '../../utils/constants'; +import {ClusterPage} from '../cluster/ClusterPage'; +import {ClusterNodesTable, ClusterStorageTable} from '../paginatedTable/paginatedTable'; +import {StoragePage} from '../storage/StoragePage'; + +import { + mockCapabilities, + mockClusterWithBridgePiles, + mockNodesWithPile, + mockStorageGroupsWithPile, +} from './mocks'; + +test.describe('Bridge mode - Nodes table', () => { + test('off: no Pile Name column and no group-by option', async ({page}) => { + await mockCapabilities(page, false); + await page.route(`${backend}/viewer/json/nodes?*`, (route) => route.continue()); + await page.goto(`/${nodesPage}`); + const table = new ClusterNodesTable(page); + await table.waitForTableToLoad(); + const headers = await table.getHeaders(); + expect(headers.join(' ')).not.toContain('Pile Name'); + // open columns setup and ensure Pile Name not present + await table.getControls().openColumnSetup(); + expect(await table.getControls().isColumnVisible('PileName')).toBeFalsy(); + }); + + test('on: shows Pile Name column', async ({page}) => { + await mockCapabilities(page, true); + await mockNodesWithPile(page); + await page.goto(`/${nodesPage}`); + const table = new ClusterNodesTable(page); + await table.getControls().openColumnSetup(); + await table.getControls().setColumnChecked('PileName'); + await table.getControls().applyColumnVisibility(); + await table.waitForTableToLoad(); + const headers = await table.getHeaders(); + expect(headers.join(' ')).toContain('Pile Name'); + }); +}); + +test.describe('Bridge mode - Storage nodes', () => { + test('on: shows Pile Name', async ({page}) => { + await mockCapabilities(page, true); + await mockNodesWithPile(page); + const storage = new StoragePage(page); + await storage.goto({visible: 'all'}); + const table = new ClusterStorageTable(page); + await table.getControls().openColumnSetup(); + await table.getControls().setColumnChecked('PileName'); + await table.getControls().applyColumnVisibility(); + await table.waitForTableToLoad(); + const headers = await table.getHeaders(); + expect(headers.join(' ')).toContain('Pile Name'); + }); + + test('off: hides Pile Name', async ({page}) => { + await mockCapabilities(page, false); + const storage = new StoragePage(page); + await storage.goto({visible: 'all'}); + const table = new ClusterStorageTable(page); + await table.waitForTableToLoad(); + const headers = await table.getHeaders(); + expect(headers.join(' ')).not.toContain('Pile Name'); + }); +}); + +test.describe('Bridge mode - Storage groups', () => { + test('on: shows Pile Name and group-by option', async ({page}) => { + await mockCapabilities(page, true); + await mockStorageGroupsWithPile(page); + const storage = new StoragePage(page); + await storage.goto({visible: 'all', type: 'groups'}); + const table = new ClusterStorageTable(page); + await table.getControls().openColumnSetup(); + await table.getControls().setColumnChecked('PileName'); + await table.getControls().applyColumnVisibility(); + await table.waitForTableToLoad(); + const headers = await table.getHeaders(); + expect(headers.join(' ')).toContain('Pile Name'); + }); + + test('off: hides Pile Name and group-by option', async ({page}) => { + await mockCapabilities(page, false); + const storage = new StoragePage(page); + await storage.goto({visible: 'all', type: 'groups'}); + const table = new ClusterStorageTable(page); + await table.waitForTableToLoad(); + const headers = await table.getHeaders(); + expect(headers.join(' ')).not.toContain('Pile Name'); + }); +}); + +test.describe('Bridge mode - Cluster Overview', () => { + test('off: does not show Bridge piles section', async ({page}) => { + await mockCapabilities(page, false); + + const clusterPage = new ClusterPage(page); + await clusterPage.goto(); + + // Bridge piles section should not be visible + expect(await clusterPage.isBridgeSectionVisible()).toBe(false); + }); + + test('on: shows Bridge piles section with data', async ({page}) => { + await mockCapabilities(page, true); + await mockClusterWithBridgePiles(page); + + const clusterPage = new ClusterPage(page); + await clusterPage.goto(); + + // Bridge piles section should be visible + expect(await clusterPage.isBridgeSectionVisible()).toBe(true); + + // Should show pile cards + expect(await clusterPage.getPileCardsCount()).toBe(2); + + // Check first pile content + const firstPileContent = await clusterPage.getFirstPileContent(); + expect(firstPileContent).toContain('r1'); + expect(firstPileContent).toContain('Yes'); // Primary status + expect(firstPileContent).toContain('SYNCHRONIZED'); + expect(firstPileContent).toContain('16'); // Nodes count + }); +}); diff --git a/tests/suites/bridge/mocks.ts b/tests/suites/bridge/mocks.ts new file mode 100644 index 0000000000..888fc75436 --- /dev/null +++ b/tests/suites/bridge/mocks.ts @@ -0,0 +1,141 @@ +import type {Page, Route} from '@playwright/test'; + +export const mockCapabilities = (page: Page, enabled: boolean) => { + return page.route(`**/viewer/capabilities`, async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + Database: '/local', + Settings: {Cluster: {BridgeModeEnabled: enabled}}, + Capabilities: { + '/viewer/cluster': 5, // > 4 to enable cluster dashboard + }, + }), + }); + }); +}; + +export const mockNodesWithPile = (page: Page) => { + return page.route(`**/viewer/json/nodes?*`, async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + FoundNodes: 1, + TotalNodes: 1, + Nodes: [ + { + NodeId: 1, + SystemState: { + Host: 'localhost', + Version: 'test-version', + LoadAverage: [0.1, 0.1, 0.1], + NumberOfCpus: 8, + StartTime: '1', + }, + PileName: 'r1', + }, + ], + }), + }); + }); +}; + +export const mockStorageGroupsWithPile = (page: Page) => { + return page.route(`**/storage/groups?*`, async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + FoundGroups: 1, + TotalGroups: 1, + StorageGroups: [{GroupId: '1', PoolName: 'p', MediaType: 'NVME', PileName: 'r1'}], + }), + }); + }); +}; + +export const mockClusterWithBridgePiles = (page: Page) => { + return page.route(`**/viewer/json/cluster?*`, async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + Version: 6, + Domain: '/dev02', + Overall: 'Green', + NodesTotal: 28, + NodesAlive: 28, + NumberOfCpus: 1152, + CoresTotal: 949, + CoresUsed: 1.9579652512078709, + LoadAverage: 10.42, + PoolStats: [ + { + Name: 'System', + Usage: 0.0031261495677549389, + Threads: 244, + }, + { + Name: 'User', + Usage: 0.0013519195624372728, + Threads: 433, + }, + ], + MemoryTotal: '2651615580160', + MemoryUsed: '64894066688', + StorageTotal: '102385812766720', + StorageUsed: '12139909611520', + MapStorageTotal: { + SSD: '102385812766720', + }, + MapStorageUsed: { + SSD: '12139909611520', + }, + DataCenters: ['FAKE', 'KLG', 'VLA'], + MapDataCenters: { + FAKE: 8, + KLG: 1, + VLA: 19, + }, + Versions: ['improve-ssl-errors-handling.d28b5d8'], + MapVersions: { + 'improve-ssl-errors-handling.d28b5d8': 28, + }, + MapNodeStates: { + Green: 28, + }, + MapNodeRoles: { + StateStorage: 8, + Bootstrapper: 8, + SchemeBoard: 8, + StateStorageBoard: 8, + Tenant: 20, + Storage: 8, + }, + StorageStats: [ + { + PDiskFilter: 'Type:SSD', + ErasureSpecies: 'block-4-2', + CurrentGroupsCreated: 16, + CurrentAllocatedSize: '9030951676583', + CurrentAvailableSize: '9845932905808', + AvailableGroupsToCreate: 47, + AvailableSizeToCreate: '49682593436982', + }, + ], + Hosts: '9', + Tenants: '9', + NetworkUtilization: 0.00087389813662801838, + NetworkWriteThroughput: '1445752', + BridgeInfo: { + Piles: [ + {PileId: 1, Name: 'r1', State: 'SYNCHRONIZED', IsPrimary: true, Nodes: 16}, + {PileId: 2, Name: 'r2', State: 'READY', IsPrimary: false, Nodes: 12}, + ], + }, + }), + }); + }); +}; diff --git a/tests/suites/cluster/ClusterPage.ts b/tests/suites/cluster/ClusterPage.ts new file mode 100644 index 0000000000..029372c374 --- /dev/null +++ b/tests/suites/cluster/ClusterPage.ts @@ -0,0 +1,36 @@ +import type {Locator, Page} from '@playwright/test'; + +import {PageModel} from '../../models/PageModel'; +import {clusterPage} from '../../utils/constants'; + +export class ClusterPage extends PageModel { + readonly clusterInfo: Locator; + readonly bridgeSection: Locator; + readonly pileCards: Locator; + + constructor(page: Page) { + super(page, clusterPage); + + this.clusterInfo = this.selector.locator('.cluster-info'); + this.bridgeSection = this.clusterInfo.locator('.cluster-info__bridge-table'); + this.pileCards = this.bridgeSection.locator('.bridge-info-table__pile-card'); + } + + async isBridgeSectionVisible(): Promise { + try { + await this.bridgeSection.waitFor({state: 'visible', timeout: 3000}); + return true; + } catch { + return false; + } + } + + async getPileCardsCount(): Promise { + return await this.pileCards.count(); + } + + async getFirstPileContent(): Promise { + const firstPile = this.pileCards.first(); + return (await firstPile.textContent()) || ''; + } +} diff --git a/tests/suites/paginatedTable/paginatedTable.ts b/tests/suites/paginatedTable/paginatedTable.ts index 27159c19d7..80733823a3 100644 --- a/tests/suites/paginatedTable/paginatedTable.ts +++ b/tests/suites/paginatedTable/paginatedTable.ts @@ -127,6 +127,10 @@ export class PaginatedTable { return this.controls; } + async getHeaders(): Promise { + return this.headCells.allTextContents(); + } + async waitForTableVisible() { await this.tableSelector.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); } diff --git a/tests/utils/constants.ts b/tests/utils/constants.ts index e6d44da3f0..01f320fae3 100644 --- a/tests/utils/constants.ts +++ b/tests/utils/constants.ts @@ -2,7 +2,7 @@ export const tenantsPage = 'cluster/tenants'; export const nodesPage = 'cluster/nodes'; export const storagePage = 'cluster/storage'; -export const clusterPage = 'cluster/cluster'; +export const clusterPage = 'cluster/tenants'; export const authPage = 'auth'; export const tenantPage = 'tenant';