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 ( <Switch> - {singleClusterMode - ? null - : renderRouteSlot(slots, { - path: routes.clusters, - exact: true, - component: Clusters, - slot: ClustersSlot, - })} {additionalRoutes?.rendered} - {/* Single cluster routes */} - <Route key="single-cluster"> - <Header mainPage={mainPage} /> + <Route> + <Header /> <Switch> + {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 ( + <LoaderWrapper loading={!metaCapabilitiesLoaded} size="l"> + {children} + </LoaderWrapper> + ); +} + 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 ( + <Flex justifyContent="space-between"> + <Flex gap={2}> + <Text variant="header-1">{i18n('page_title')}</Text> + <Text variant="header-1" color="secondary"> + {clusters?.length} + </Text> + </Flex> + <AutoRefreshControl className={b('autorefresh')} /> + </Flex> + ); + }; + return ( <div className={b()}> <Helmet> <title>{i18n('page_title')}</title> </Helmet> - <ClustersStatistics stats={aggregation} count={filteredClusters.length} /> - <div className={b('controls')}> + {renderPageTitle()} + + <Flex> <div className={b('control', {wide: true})}> <Search placeholder={i18n('controls_search-placeholder')} @@ -178,8 +205,7 @@ export function Clusters() { sortable={false} /> </div> - <AutoRefreshControl className={b('autorefresh')} /> - </div> + </Flex> {query.isError ? <ResponseError error={query.error} className={b('error')} /> : null} {query.isLoading ? <Loader size="l" /> : 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 ( - <div className={b('aggregation')}> - <div className={b('aggregation-value-container')}> - <span className={b('aggregation-label')}>{i18n('statistics_clusters')}</span> - {count} - </div> - <div className={b('aggregation-value-container')}> - <span className={b('aggregation-label')}>{i18n('statistics_hosts')}</span> - {Hosts} - </div> - <div className={b('aggregation-value-container')}> - <span className={b('aggregation-label')}>{i18n('statistics_tenants')}</span> - {Tenants} - </div> - <div className={b('aggregation-value-container')}> - <span className={b('aggregation-label')}>{i18n('statistics_nodes')}</span> - <ProgressViewer - size="ns" - value={NodesAlive} - capacity={NodesTotal} - colorizeProgress={true} - inverseColorize={true} - /> - </div> - <div className={b('aggregation-value-container')}> - <span className={b('aggregation-label')}>{i18n('statistics_load')}</span> - <ProgressViewer - size="ns" - value={LoadAverage} - capacity={NumberOfCpus} - colorizeProgress={true} - /> - </div> - <div className={b('aggregation-value-container')}> - <span className={b('aggregation-label')}>{i18n('statistics_storage')}</span> - <ProgressViewer - size="ns" - value={StorageUsed} - capacity={StorageTotal} - formatValues={formatStorageValues} - colorizeProgress={true} - /> - </div> - </div> - ); -}; 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 = <span className={b('empty-cell')}>—</span>; -export const CLUSTERS_COLUMNS: Column<PreparedCluster>[] = [ - { +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<PreparedCluster>[] = [ 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: <Pencil />, + action: () => { + onEditCluster({clusterData: row}); + }, + }); + } + if (isDeleteClusterAvailable && onDeleteCluster) { + menuItems.push({ + text: i18n('remove-cluster'), + iconStart: <TrashBin />, + action: () => { + onDeleteCluster({clusterData: row}); + }, + className: b('remove-cluster'), + }); + } + + if (!menuItems.length) { + return null; + } + + return <DropdownMenu items={menuItems} />; + }; + return ( - <div className={b('cluster')}> - {clusterStatus ? ( - <ExternalLink href={clusterPath}> - <div - className={b('cluster-status', { - type: clusterStatus && clusterStatus.toLowerCase(), - })} - /> - </ExternalLink> - ) : ( - <div className={b('cluster-status')}> - <HelpPopover - content={ - <span className={b('tooltip-content')}> - {row.cluster?.error || i18n('tooltip_no-cluster-data')} - </span> - } - offset={{left: 0}} - /> + <Flex alignItems={'center'} justifyContent={'space-between'}> + <Flex alignItems={'center'}> + {clusterStatus ? ( + <ExternalLink href={clusterPath}> + <div + className={b('cluster-status', { + type: clusterStatus && clusterStatus.toLowerCase(), + })} + /> + </ExternalLink> + ) : ( + <div className={b('cluster-status')}> + <HelpPopover + content={ + <span className={b('tooltip-content')}> + {row.cluster?.error || i18n('tooltip_no-cluster-data')} + </span> + } + offset={{left: 0}} + /> + </div> + )} + <div className={b('cluster-name')}> + <ExternalLink href={clusterPath}>{row.title || row.name}</ExternalLink> </div> - )} - <div className={b('cluster-name')}> - <ExternalLink href={clusterPath}>{row.title}</ExternalLink> - </div> - </div> + </Flex> + {renderActions()} + </Flex> ); }, - defaultOrder: DataTable.ASCENDING, - }, + } satisfies Column<PreparedCluster>; +} + +const CLUSTERS_COLUMNS: Column<PreparedCluster>[] = [ { name: COLUMNS_NAMES.VERSIONS, header: COLUMNS_TITLES[COLUMNS_NAMES.VERSIONS], @@ -294,3 +346,7 @@ export const CLUSTERS_COLUMNS: Column<PreparedCluster>[] = [ }, }, ]; + +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( + <Button view={'flat'} onClick={() => uiFactory.onAddCluster?.()}> + <Icon data={CirclePlus} /> + {headerKeyset('add-cluster')} + </Button>, + ); + } + if (isDatabasePage && database) { elements.push( <Button view={'flat'} onClick={() => getConnectToDBDialog({database})}> @@ -70,7 +76,7 @@ function Header({mainPage}: HeaderProps) { ); } - if (isUserAllowedToMakeChanges) { + if (!isClustersPage && isUserAllowedToMakeChanges) { elements.push( <Button view="flat" href={createDeveloperUIInternalPageHref()} target="_blank"> {DEVELOPER_UI_TITLE} diff --git a/src/containers/Header/breadcrumbs.tsx b/src/containers/Header/breadcrumbs.tsx index 1b9c144ff..e295e6053 100644 --- a/src/containers/Header/breadcrumbs.tsx +++ b/src/containers/Header/breadcrumbs.tsx @@ -6,10 +6,11 @@ import { } from '@gravity-ui/icons'; import {TabletIcon} from '../../components/TabletIcon/TabletIcon'; -import {getPDiskPagePath} from '../../routes'; +import routes, {getPDiskPagePath} from '../../routes'; import type { BreadcrumbsOptions, ClusterBreadcrumbsOptions, + ClustersBreadcrumbsOptions, NodeBreadcrumbsOptions, PDiskBreadcrumbsOptions, Page, @@ -37,7 +38,7 @@ export interface RawBreadcrumbItem { } interface GetBreadcrumbs<T, U = AnyRecord> { - (options: T, query?: U): RawBreadcrumbItem[]; + (options: T & {singleClusterMode: boolean}, query?: U): RawBreadcrumbItem[]; } const getQueryForTenant = (type: 'nodes' | 'tablets') => ({ @@ -45,18 +46,33 @@ const getQueryForTenant = (type: 'nodes' | 'tablets') => ({ [TenantTabsGroups.diagnosticsTab]: TENANT_DIAGNOSTICS_TABS_IDS[type], }); -const getClusterBreadcrumbs: GetBreadcrumbs<ClusterBreadcrumbsOptions> = (options, query = {}) => { - const {clusterName, clusterTab} = options; - +const getClustersBreadcrumbs: GetBreadcrumbs<ClustersBreadcrumbsOptions> = () => { return [ { - text: clusterName || CLUSTER_DEFAULT_TITLE, - link: getClusterPath(clusterTab, query), - icon: <ClusterIcon />, + text: headerKeyset('breadcrumbs.clusters'), + link: routes.clusters, }, ]; }; +const getClusterBreadcrumbs: GetBreadcrumbs<ClusterBreadcrumbsOptions> = (options, query = {}) => { + const {clusterName, clusterTab, singleClusterMode} = options; + + let breadcrumbs: RawBreadcrumbItem[] = []; + + if (!singleClusterMode) { + breadcrumbs = getClustersBreadcrumbs(options, query); + } + + breadcrumbs.push({ + text: clusterName || CLUSTER_DEFAULT_TITLE, + link: getClusterPath(clusterTab, query), + icon: <ClusterIcon />, + }); + + return breadcrumbs; +}; + const getTenantBreadcrumbs: GetBreadcrumbs<TenantBreadcrumbsOptions> = (options, query = {}) => { const {tenantName} = options; @@ -191,6 +207,7 @@ const getTabletBreadcrumbs: GetBreadcrumbs<TabletBreadcrumbsOptions> = (options, }; const mapPageToGetter = { + clusters: getClustersBreadcrumbs, cluster: getClusterBreadcrumbs, node: getNodeBreadcrumbs, pDisk: getPDiskBreadcrumbs, @@ -202,7 +219,7 @@ const mapPageToGetter = { export const getBreadcrumbs = ( page: Page, - options: BreadcrumbsOptions, + options: BreadcrumbsOptions & {singleClusterMode: boolean}, rawBreadcrumbs: RawBreadcrumbItem[] = [], query = {}, ) => { diff --git a/src/containers/Header/i18n/en.json b/src/containers/Header/i18n/en.json index 2ffec2e21..a78cae8d8 100644 --- a/src/containers/Header/i18n/en.json +++ b/src/containers/Header/i18n/en.json @@ -1,4 +1,5 @@ { + "breadcrumbs.clusters": "All clusters", "breadcrumbs.tenant": "Tenant", "breadcrumbs.node": "Node", "breadcrumbs.pDisk": "PDisk", @@ -7,5 +8,6 @@ "breadcrumbs.tablets": "Tablets", "breadcrumbs.storageGroup": "Storage Group", - "connect": "Connect" + "connect": "Connect", + "add-cluster": "Add Cluster" } diff --git a/src/store/reducers/capabilities/hooks.ts b/src/store/reducers/capabilities/hooks.ts index d14e92264..bf2354f71 100644 --- a/src/store/reducers/capabilities/hooks.ts +++ b/src/store/reducers/capabilities/hooks.ts @@ -123,3 +123,13 @@ export const useEditDatabaseFeatureAvailable = () => { export const useDeleteDatabaseFeatureAvailable = () => { return useGetMetaFeatureVersion('/meta/delete_database') >= 1; }; + +export const useAddClusterFeatureAvailable = () => { + return useGetMetaFeatureVersion('/meta/create_cluster') >= 1; +}; +export const useEditClusterFeatureAvailable = () => { + return useGetMetaFeatureVersion('/meta/update_cluster') >= 1; +}; +export const useDeleteClusterFeatureAvailable = () => { + return useGetMetaFeatureVersion('/meta/delete_cluster') >= 1; +}; diff --git a/src/store/reducers/clusters/selectors.ts b/src/store/reducers/clusters/selectors.ts index 1f16e08db..5325b6e72 100644 --- a/src/store/reducers/clusters/selectors.ts +++ b/src/store/reducers/clusters/selectors.ts @@ -1,13 +1,6 @@ import escapeRegExp from 'lodash/escapeRegExp'; -import {isNumeric} from '../../../utils/utils'; - -import type { - ClusterDataAggregation, - ClustersFilters, - ClustersStateSlice, - PreparedCluster, -} from './types'; +import type {ClustersFilters, ClustersStateSlice, PreparedCluster} from './types'; // ==== Simple selectors ==== @@ -85,41 +78,3 @@ export function filterClusters(clusters: PreparedCluster[], filters: ClustersFil ); }); } - -export function aggregateClustersInfo(clusters: PreparedCluster[]): ClusterDataAggregation { - let NodesTotal = 0, - NodesAlive = 0, - LoadAverage = 0, - NumberOfCpus = 0, - StorageUsed = 0, - StorageTotal = 0, - Tenants = 0; - const Hosts = new Set(); - - const filteredClusters = clusters.filter(({cluster}) => !cluster?.error); - - filteredClusters.forEach(({cluster, hosts = {}}) => { - NodesTotal += cluster?.NodesTotal || 0; - NodesAlive += cluster?.NodesAlive || 0; - Object.keys(hosts).forEach((host) => Hosts.add(host)); - Tenants += Number(cluster?.Tenants) || 0; - LoadAverage += Number(cluster?.LoadAverage) || 0; - NumberOfCpus += isNumeric(cluster?.RealNumberOfCpus) - ? cluster?.RealNumberOfCpus - : cluster?.NumberOfCpus || 0; - - StorageUsed += cluster?.StorageUsed ? Math.floor(parseInt(cluster.StorageUsed, 10)) : 0; - StorageTotal += cluster?.StorageTotal ? Math.floor(parseInt(cluster.StorageTotal, 10)) : 0; - }); - - return { - NodesTotal, - NodesAlive, - Hosts: Hosts.size, - Tenants, - LoadAverage, - NumberOfCpus, - StorageUsed, - StorageTotal, - }; -} diff --git a/src/store/reducers/clusters/types.ts b/src/store/reducers/clusters/types.ts index 32bf2e5db..a22c855c7 100644 --- a/src/store/reducers/clusters/types.ts +++ b/src/store/reducers/clusters/types.ts @@ -6,17 +6,6 @@ export interface PreparedCluster extends MetaExtendedClusterInfo { preparedBackend?: string; } -export interface ClusterDataAggregation { - NodesTotal: number; - NodesAlive: number; - Hosts: number; - Tenants: number; - LoadAverage: number; - NumberOfCpus: number; - StorageUsed: number; - StorageTotal: number; -} - export interface ClustersFilters { status: string[]; service: string[]; diff --git a/src/store/reducers/header/types.ts b/src/store/reducers/header/types.ts index e4e01f912..c1f1ce60a 100644 --- a/src/store/reducers/header/types.ts +++ b/src/store/reducers/header/types.ts @@ -5,6 +5,7 @@ import type {EType} from '../../../types/api/tablet'; import type {setHeaderBreadcrumbs} from './header'; export type Page = + | 'clusters' | 'cluster' | 'tenant' | 'node' @@ -14,7 +15,9 @@ export type Page = | 'storageGroup' | undefined; -export interface ClusterBreadcrumbsOptions { +export interface ClustersBreadcrumbsOptions {} + +export interface ClusterBreadcrumbsOptions extends ClustersBreadcrumbsOptions { clusterName?: string; clusterTab?: ClusterTab; } diff --git a/src/types/api/capabilities.ts b/src/types/api/capabilities.ts index a5df893b0..14738fbf6 100644 --- a/src/types/api/capabilities.ts +++ b/src/types/api/capabilities.ts @@ -39,4 +39,7 @@ export type MetaCapability = | '/meta/delete_database' | '/meta/simulate_database' | '/meta/start_database' - | '/meta/stop_database'; + | '/meta/stop_database' + | '/meta/create_cluster' + | '/meta/update_cluster' + | '/meta/delete_cluster'; diff --git a/src/uiFactory/types.ts b/src/uiFactory/types.ts index 98c349c79..a739a9cee 100644 --- a/src/uiFactory/types.ts +++ b/src/uiFactory/types.ts @@ -5,6 +5,7 @@ import type { } from '../containers/Tenant/Healthcheck/shared'; import type {IssuesTree} from '../store/reducers/healthcheckInfo/types'; import type {PreparedTenant} from '../store/reducers/tenants/types'; +import type {MetaBaseClusterInfo} from '../types/api/meta'; import type {GetLogsLink} from '../utils/logs'; import type {GetMonitoringClusterLink, GetMonitoringLink} from '../utils/monitoring'; @@ -13,6 +14,10 @@ export interface UIFactory { onEditDB?: HandleEditDB; onDeleteDB?: HandleDeleteDB; + onAddCluster?: HandleAddCluster; + onEditCluster?: HandleEditCluster; + onDeleteCluster?: HandleDeleteCluster; + getLogsLink?: GetLogsLink; getMonitoringLink?: GetMonitoringLink; getMonitoringClusterLink?: GetMonitoringClusterLink; @@ -37,3 +42,9 @@ export type HandleDeleteDB = (params: { clusterName: string; databaseData: PreparedTenant; }) => Promise<boolean>; + +export type HandleAddCluster = () => Promise<boolean>; + +export type HandleEditCluster = (params: {clusterData: MetaBaseClusterInfo}) => Promise<boolean>; + +export type HandleDeleteCluster = (params: {clusterData: MetaBaseClusterInfo}) => Promise<boolean>;