diff --git a/src/containers/App/Content.tsx b/src/containers/App/Content.tsx index 78b310839..2fdf24edf 100644 --- a/src/containers/App/Content.tsx +++ b/src/containers/App/Content.tsx @@ -27,7 +27,6 @@ import {lazyComponent} from '../../utils/lazyComponent'; import Authentication from '../Authentication/Authentication'; import {getClusterPath} from '../Cluster/utils'; import Header from '../Header/Header'; -import type {RawBreadcrumbItem} from '../Header/breadcrumbs'; import { ClusterSlot, @@ -41,7 +40,6 @@ import { TenantSlot, VDiskPageSlot, } from './appSlots'; -import i18n from './i18n'; import './App.scss'; @@ -147,26 +145,22 @@ export function Content(props: ContentProps) { const redirectProps: RedirectProps = redirect?.props ?? (singleClusterMode ? {to: getClusterPath()} : {to: routes.clusters}); - let mainPage: RawBreadcrumbItem | undefined; - if (!singleClusterMode) { - mainPage = {text: i18n('pages.clusters'), link: routes.clusters}; - } - return ( - {singleClusterMode - ? null - : renderRouteSlot(slots, { - path: routes.clusters, - exact: true, - component: Clusters, - slot: ClustersSlot, - })} {additionalRoutes?.rendered} - {/* Single cluster routes */} - -
+ +
+ {singleClusterMode + ? null + : renderRouteSlot(slots, { + path: routes.clusters, + exact: true, + component: Clusters, + slot: ClustersSlot, + wrapper: GetMetaCapabilities, + })} + {/* Single cluster routes */} {routesSlots.map((route) => { return renderRouteSlot(slots, route); })} @@ -226,6 +220,20 @@ function GetCapabilities({children}: {children: React.ReactNode}) { ); } +// Only for Clusters page, there is no need to request cluster capabilities there (GetCapabilities) +// This wrapper is not used in GetCapabilities so the page does not wait for 2 consecutive capabilities requests +function GetMetaCapabilities({children}: {children: React.ReactNode}) { + useMetaCapabilitiesQuery(); + // It is always true if there is no meta, since request finishes with an error + const metaCapabilitiesLoaded = useMetaCapabilitiesLoaded(); + + return ( + + {children} + + ); +} + interface ContentWrapperProps { singleClusterMode: boolean; isAuthenticated: boolean; diff --git a/src/containers/App/i18n/en.json b/src/containers/App/i18n/en.json deleted file mode 100644 index ed956223d..000000000 --- a/src/containers/App/i18n/en.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "pages.clusters": "All clusters" -} diff --git a/src/containers/App/i18n/index.ts b/src/containers/App/i18n/index.ts deleted file mode 100644 index ae8a8fc8d..000000000 --- a/src/containers/App/i18n/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {registerKeysets} from '../../../utils/i18n'; - -import en from './en.json'; -import ru from './ru.json'; - -const COMPONENT = 'ydb-app-content'; - -export default registerKeysets(COMPONENT, {ru, en}); diff --git a/src/containers/App/i18n/ru.json b/src/containers/App/i18n/ru.json deleted file mode 100644 index a44868be4..000000000 --- a/src/containers/App/i18n/ru.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "pages.clusters": "Все кластеры" -} diff --git a/src/containers/Clusters/Clusters.scss b/src/containers/Clusters/Clusters.scss index 65ff0febf..e152677fe 100644 --- a/src/containers/Clusters/Clusters.scss +++ b/src/containers/Clusters/Clusters.scss @@ -2,18 +2,16 @@ .clusters { overflow: auto; + gap: var(--g-spacing-4); - padding-top: 15px; + padding: var(--g-spacing-4) var(--g-spacing-5) 0; @include mixins.body-2-typography(); @include mixins.flex-container(); + &__autorefresh { margin-left: auto; } - &__cluster { - display: flex; - align-items: center; - } &__cluster-status { width: 18px; height: 18px; @@ -63,11 +61,6 @@ white-space: normal; } - &__controls { - display: flex; - - margin-bottom: 20px; - } &__control { width: 200px; margin-right: 15px; @@ -87,48 +80,6 @@ transition: none; } - &__aggregation, - &__controls { - margin-right: 15px; - margin-left: 15px; - } - - &__aggregation { - display: flex; - align-items: center; - - width: max-content; - height: 46px; - margin-bottom: 20px; - padding: 10px 20px; - - border: 1px solid var(--g-color-line-generic); - border-radius: 10px; - background: var(--g-color-base-generic-ultralight); - } - - &__aggregation-value-container { - display: flex; - align-items: center; - - max-width: 230px; - - font-size: var(--g-text-subheader-3-font-size); - line-height: var(--g-text-subheader-3-line-height); - } - - &__aggregation-value-container:not(:last-child) { - margin-right: 30px; - } - - &__aggregation-label { - margin-right: 8px; - - font-weight: 200; - - color: var(--g-color-text-complementary); - } - &__text { color: var(--g-color-text-primary); @include mixins.body-2-typography(); @@ -147,7 +98,6 @@ &__table-wrapper { overflow: auto; - padding-left: 5px; @include mixins.flex-container(); } @@ -186,4 +136,8 @@ margin-left: 15px; @include mixins.body-2-typography(); } + + &__remove-cluster { + color: var(--ydb-color-status-red); + } } diff --git a/src/containers/Clusters/Clusters.tsx b/src/containers/Clusters/Clusters.tsx index eb0e723a6..e1b6765bb 100644 --- a/src/containers/Clusters/Clusters.tsx +++ b/src/containers/Clusters/Clusters.tsx @@ -1,7 +1,7 @@ import React from 'react'; import DataTable from '@gravity-ui/react-data-table'; -import {Select, TableColumnSetup} from '@gravity-ui/uikit'; +import {Flex, Select, TableColumnSetup, Text} from '@gravity-ui/uikit'; import {Helmet} from 'react-helmet-async'; import {AutoRefreshControl} from '../../components/AutoRefreshControl/AutoRefreshControl'; @@ -9,22 +9,26 @@ import {ResponseError} from '../../components/Errors/ResponseError'; import {Loader} from '../../components/Loader'; import {ResizeableDataTable} from '../../components/ResizeableDataTable/ResizeableDataTable'; import {Search} from '../../components/Search'; +import { + useDeleteClusterFeatureAvailable, + useEditClusterFeatureAvailable, +} from '../../store/reducers/capabilities/hooks'; import {changeClustersFilters, clustersApi} from '../../store/reducers/clusters/clusters'; import { - aggregateClustersInfo, filterClusters, selectClusterNameFilter, selectServiceFilter, selectStatusFilter, selectVersionFilter, } from '../../store/reducers/clusters/selectors'; +import {setHeaderBreadcrumbs} from '../../store/reducers/header/header'; +import {uiFactory} from '../../uiFactory/uiFactory'; import {DEFAULT_TABLE_SETTINGS} from '../../utils/constants'; import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../utils/hooks'; import {useSelectedColumns} from '../../utils/hooks/useSelectedColumns'; import {getMinorVersion} from '../../utils/versions'; -import {ClustersStatistics} from './ClustersStatistics'; -import {CLUSTERS_COLUMNS, CLUSTERS_COLUMNS_WIDTH_LS_KEY} from './columns'; +import {CLUSTERS_COLUMNS_WIDTH_LS_KEY, getClustersColumns} from './columns'; import { CLUSTERS_SELECTED_COLUMNS_KEY, COLUMNS_NAMES, @@ -44,6 +48,15 @@ export function Clusters() { const dispatch = useTypedDispatch(); + React.useEffect(() => { + dispatch(setHeaderBreadcrumbs('clusters', {})); + }, [dispatch]); + + const isEditClusterAvailable = + useEditClusterFeatureAvailable() && uiFactory.onEditCluster !== undefined; + const isDeleteClusterAvailable = + useDeleteClusterFeatureAvailable() && uiFactory.onDeleteCluster !== undefined; + const clusterName = useTypedSelector(selectClusterNameFilter); const status = useTypedSelector(selectStatusFilter); const service = useTypedSelector(selectServiceFilter); @@ -62,8 +75,12 @@ export function Clusters() { dispatch(changeClustersFilters({version: value})); }; + const rawColumns = React.useMemo(() => { + return getClustersColumns({isEditClusterAvailable, isDeleteClusterAvailable}); + }, [isDeleteClusterAvailable, isEditClusterAvailable]); + const {columnsToShow, columnsToSelect, setColumns} = useSelectedColumns( - CLUSTERS_COLUMNS, + rawColumns, CLUSTERS_SELECTED_COLUMNS_KEY, COLUMNS_TITLES, DEFAULT_COLUMNS, @@ -99,11 +116,6 @@ export function Clusters() { return filterClusters(clusters ?? [], {clusterName, status, service, version}); }, [clusterName, clusters, service, status, version]); - const aggregation = React.useMemo( - () => aggregateClustersInfo(filteredClusters), - [filteredClusters], - ); - const statuses = React.useMemo(() => { return Array.from( new Set( @@ -114,14 +126,29 @@ export function Clusters() { .map((el) => ({value: el, content: el})); }, [clusters]); + const renderPageTitle = () => { + return ( + + + {i18n('page_title')} + + {clusters?.length} + + + + + ); + }; + return (
{i18n('page_title')} - -
+ {renderPageTitle()} + +
- -
+ {query.isError ? : null} {query.isLoading ? : null} {query.fulfilledTimeStamp ? ( diff --git a/src/containers/Clusters/ClustersStatistics.tsx b/src/containers/Clusters/ClustersStatistics.tsx deleted file mode 100644 index 8cca6859d..000000000 --- a/src/containers/Clusters/ClustersStatistics.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import {ProgressViewer} from '../../components/ProgressViewer/ProgressViewer'; -import type {ClusterDataAggregation} from '../../store/reducers/clusters/types'; -import {formatStorageValues} from '../../utils/dataFormatters/dataFormatters'; - -import i18n from './i18n'; -import {b} from './shared'; - -interface ClustersStatisticsProps { - count: number; - stats: ClusterDataAggregation; -} - -export const ClustersStatistics = ({count, stats}: ClustersStatisticsProps) => { - const { - NodesTotal, - NodesAlive, - Hosts, - Tenants, - LoadAverage, - NumberOfCpus, - StorageUsed, - StorageTotal, - } = stats; - - return ( -
-
- {i18n('statistics_clusters')} - {count} -
-
- {i18n('statistics_hosts')} - {Hosts} -
-
- {i18n('statistics_tenants')} - {Tenants} -
-
- {i18n('statistics_nodes')} - -
-
- {i18n('statistics_load')} - -
-
- {i18n('statistics_storage')} - -
-
- ); -}; diff --git a/src/containers/Clusters/columns.tsx b/src/containers/Clusters/columns.tsx index 7da8200d7..4988c9a23 100644 --- a/src/containers/Clusters/columns.tsx +++ b/src/containers/Clusters/columns.tsx @@ -1,13 +1,22 @@ import React from 'react'; import {HelpPopover} from '@gravity-ui/components'; +import {Pencil, TrashBin} from '@gravity-ui/icons'; import DataTable from '@gravity-ui/react-data-table'; import type {Column} from '@gravity-ui/react-data-table'; -import {ClipboardButton, Link as ExternalLink, Progress} from '@gravity-ui/uikit'; +import type {DropdownMenuItem} from '@gravity-ui/uikit'; +import { + ClipboardButton, + DropdownMenu, + Link as ExternalLink, + Flex, + Progress, +} from '@gravity-ui/uikit'; import {ProgressViewer} from '../../components/ProgressViewer/ProgressViewer'; import {UserCard} from '../../components/User/User'; import type {PreparedCluster} from '../../store/reducers/clusters/types'; +import {uiFactory} from '../../uiFactory/uiFactory'; import {formatStorageValuesToTb} from '../../utils/dataFormatters/dataFormatters'; import {createDeveloperUIMonitoringPageHref} from '../../utils/developerUI/developerUI'; import {getCleanBalancerValue} from '../../utils/parseBalancer'; @@ -21,11 +30,17 @@ export const CLUSTERS_COLUMNS_WIDTH_LS_KEY = 'clustersTableColumnsWidth'; const EMPTY_CELL = ; -export const CLUSTERS_COLUMNS: Column[] = [ - { +interface ClustersColumnsParams { + isEditClusterAvailable?: boolean; + isDeleteClusterAvailable?: boolean; +} + +function getTitleColumn({isEditClusterAvailable, isDeleteClusterAvailable}: ClustersColumnsParams) { + return { name: COLUMNS_NAMES.TITLE, header: COLUMNS_TITLES[COLUMNS_NAMES.TITLE], width: 230, + defaultOrder: DataTable.ASCENDING, render: ({row}) => { const { name: clusterName, @@ -40,36 +55,73 @@ export const CLUSTERS_COLUMNS: Column[] = [ const clusterStatus = row.cluster?.Overall; + const renderActions = () => { + const menuItems: (DropdownMenuItem | DropdownMenuItem[])[] = []; + + const {onEditCluster, onDeleteCluster} = uiFactory; + + if (isEditClusterAvailable && onEditCluster) { + menuItems.push({ + text: i18n('edit-cluster'), + iconStart: , + action: () => { + onEditCluster({clusterData: row}); + }, + }); + } + if (isDeleteClusterAvailable && onDeleteCluster) { + menuItems.push({ + text: i18n('remove-cluster'), + iconStart: , + action: () => { + onDeleteCluster({clusterData: row}); + }, + className: b('remove-cluster'), + }); + } + + if (!menuItems.length) { + return null; + } + + return ; + }; + return ( -
- {clusterStatus ? ( - -
- - ) : ( -
- - {row.cluster?.error || i18n('tooltip_no-cluster-data')} - - } - offset={{left: 0}} - /> + + + {clusterStatus ? ( + +
+ + ) : ( +
+ + {row.cluster?.error || i18n('tooltip_no-cluster-data')} + + } + offset={{left: 0}} + /> +
+ )} +
+ {row.title || row.name}
- )} -
- {row.title} -
-
+
+ {renderActions()} +
); }, - defaultOrder: DataTable.ASCENDING, - }, + } satisfies Column; +} + +const CLUSTERS_COLUMNS: Column[] = [ { name: COLUMNS_NAMES.VERSIONS, header: COLUMNS_TITLES[COLUMNS_NAMES.VERSIONS], @@ -294,3 +346,7 @@ export const CLUSTERS_COLUMNS: Column[] = [ }, }, ]; + +export function getClustersColumns(params: ClustersColumnsParams) { + return [getTitleColumn(params), ...CLUSTERS_COLUMNS]; +} diff --git a/src/containers/Clusters/i18n/en.json b/src/containers/Clusters/i18n/en.json index db7438356..49164c22b 100644 --- a/src/containers/Clusters/i18n/en.json +++ b/src/containers/Clusters/i18n/en.json @@ -15,5 +15,8 @@ "tooltip_no-cluster-data": "No cluster data", - "page_title": "Clusters" + "page_title": "Clusters", + + "edit-cluster": "Edit Cluster", + "remove-cluster": "Remove Cluster" } diff --git a/src/containers/Clusters/i18n/index.ts b/src/containers/Clusters/i18n/index.ts index ce7771fca..d17b09a80 100644 --- a/src/containers/Clusters/i18n/index.ts +++ b/src/containers/Clusters/i18n/index.ts @@ -1,8 +1,7 @@ import {registerKeysets} from '../../../utils/i18n'; import en from './en.json'; -import ru from './ru.json'; const COMPONENT = 'ydb-clusters-page'; -export default registerKeysets(COMPONENT, {ru, en}); +export default registerKeysets(COMPONENT, {en}); diff --git a/src/containers/Clusters/i18n/ru.json b/src/containers/Clusters/i18n/ru.json deleted file mode 100644 index f420ffde2..000000000 --- a/src/containers/Clusters/i18n/ru.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "controls_status-select-label": "Статус:", - "controls_service-select-label": "Сервис:", - "controls_version-select-label": "Версия:", - - "controls_search-placeholder": "Имя кластера, версия или хост", - "controls_select-placeholder": "Все", - - "statistics_clusters": "Кластеры", - "statistics_hosts": "Хосты", - "statistics_tenants": "Базы данных", - "statistics_nodes": "Узлы", - "statistics_load": "Нагрузка", - "statistics_storage": "Хранилище", - - "tooltip_no-cluster-data": "Нет данных кластера", - - "page_title": "Кластеры" -} diff --git a/src/containers/Header/Header.scss b/src/containers/Header/Header.scss index bffe551b7..eabd3d306 100644 --- a/src/containers/Header/Header.scss +++ b/src/containers/Header/Header.scss @@ -4,7 +4,7 @@ justify-content: space-between; align-items: center; - padding: 0 20px 0 12px; + padding: 0 var(--g-spacing-5); border-bottom: 1px solid var(--g-color-line-generic); diff --git a/src/containers/Header/Header.tsx b/src/containers/Header/Header.tsx index baedcc00c..af0e3ef46 100644 --- a/src/containers/Header/Header.tsx +++ b/src/containers/Header/Header.tsx @@ -1,12 +1,14 @@ import React from 'react'; -import {ArrowUpRightFromSquare, PlugConnection} from '@gravity-ui/icons'; +import {ArrowUpRightFromSquare, CirclePlus, PlugConnection} from '@gravity-ui/icons'; import {Breadcrumbs, Button, Divider, Flex, Icon} from '@gravity-ui/uikit'; import {useLocation} from 'react-router-dom'; import {getConnectToDBDialog} from '../../components/ConnectToDB/ConnectToDBDialog'; import {InternalLink} from '../../components/InternalLink'; +import {useAddClusterFeatureAvailable} from '../../store/reducers/capabilities/hooks'; import {useClusterBaseInfo} from '../../store/reducers/cluster/cluster'; +import {uiFactory} from '../../uiFactory/uiFactory'; import {cn} from '../../utils/cn'; import {DEVELOPER_UI_TITLE} from '../../utils/constants'; import {createDeveloperUIInternalPageHref} from '../../utils/developerUI/developerUI'; @@ -14,7 +16,6 @@ import {useTypedSelector} from '../../utils/hooks'; import {useDatabaseFromQuery} from '../../utils/hooks/useDatabaseFromQuery'; import {useIsUserAllowedToMakeChanges} from '../../utils/hooks/useIsUserAllowedToMakeChanges'; -import type {RawBreadcrumbItem} from './breadcrumbs'; import {getBreadcrumbs} from './breadcrumbs'; import {headerKeyset} from './i18n'; @@ -22,12 +23,9 @@ import './Header.scss'; const b = cn('header'); -interface HeaderProps { - mainPage?: RawBreadcrumbItem; -} - -function Header({mainPage}: HeaderProps) { +function Header() { const {page, pageBreadcrumbsOptions} = useTypedSelector((state) => state.header); + const singleClusterMode = useTypedSelector((state) => state.singleClusterMode); const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const {title: clusterTitle} = useClusterBaseInfo(); @@ -35,14 +33,13 @@ function Header({mainPage}: HeaderProps) { const database = useDatabaseFromQuery(); const location = useLocation(); const isDatabasePage = location.pathname === '/tenant'; + const isClustersPage = location.pathname === '/clusters'; - const breadcrumbItems = React.useMemo(() => { - const rawBreadcrumbs: RawBreadcrumbItem[] = []; - let options = pageBreadcrumbsOptions; + const isAddClusterAvailable = + useAddClusterFeatureAvailable() && uiFactory.onAddCluster !== undefined; - if (mainPage) { - rawBreadcrumbs.push(mainPage); - } + const breadcrumbItems = React.useMemo(() => { + let options = {...pageBreadcrumbsOptions, singleClusterMode}; if (clusterTitle) { options = { @@ -51,16 +48,25 @@ function Header({mainPage}: HeaderProps) { }; } - const breadcrumbs = getBreadcrumbs(page, options, rawBreadcrumbs); + const breadcrumbs = getBreadcrumbs(page, options); return breadcrumbs.map((item) => { return {...item, action: () => {}}; }); - }, [clusterTitle, mainPage, page, pageBreadcrumbsOptions]); + }, [clusterTitle, page, pageBreadcrumbsOptions, singleClusterMode]); const renderRightControls = () => { const elements: React.ReactNode[] = []; + if (isClustersPage && isAddClusterAvailable) { + elements.push( + , + ); + } + if (isDatabasePage && database) { elements.push(