diff --git a/src/components/EmptyState/EmptyState.tsx b/src/components/EmptyState/EmptyState.tsx index b7d44d3fa..f04f03f0c 100644 --- a/src/components/EmptyState/EmptyState.tsx +++ b/src/components/EmptyState/EmptyState.tsx @@ -49,3 +49,20 @@ export const EmptyState = ({ ); }; + +interface EmptyStateWrapperProps extends EmptyStateProps { + isEmpty?: boolean; + children: React.ReactNode; + className?: string; +} + +export function EmptyStateWrapper({isEmpty, children, className, ...rest}: EmptyStateWrapperProps) { + if (isEmpty) { + return ( +
+ +
+ ); + } + return children; +} diff --git a/src/components/InfoViewerSkeleton/InfoViewerSkeleton.tsx b/src/components/InfoViewerSkeleton/InfoViewerSkeleton.tsx index eaae8966c..cb10221f0 100644 --- a/src/components/InfoViewerSkeleton/InfoViewerSkeleton.tsx +++ b/src/components/InfoViewerSkeleton/InfoViewerSkeleton.tsx @@ -1,6 +1,9 @@ +import React from 'react'; + import {Skeleton} from '@gravity-ui/uikit'; import {cn} from '../../utils/cn'; +import {useDelayed} from '../../utils/hooks/useDelayed'; import './InfoViewerSkeleton.scss'; @@ -16,15 +19,27 @@ const SkeletonLabel = () => ( interface InfoViewerSkeletonProps { className?: string; rows?: number; + delay?: number; } -export const InfoViewerSkeleton = ({rows = 8, className}: InfoViewerSkeletonProps) => ( -
- {[...new Array(rows)].map((_, index) => ( -
- - -
- ))} -
-); +export const InfoViewerSkeleton = ({rows = 8, className, delay = 600}: InfoViewerSkeletonProps) => { + const show = useDelayed(delay); + let skeletons: React.ReactNode = ( + + + + + ); + if (!show) { + skeletons = null; + } + return ( +
+ {[...new Array(rows)].map((_, index) => ( +
+ {skeletons} +
+ ))} +
+ ); +}; diff --git a/src/components/Tablet/Tablet.tsx b/src/components/Tablet/Tablet.tsx index 6d48a2c5d..3e2713acf 100644 --- a/src/components/Tablet/Tablet.tsx +++ b/src/components/Tablet/Tablet.tsx @@ -1,4 +1,4 @@ -import routes, {createHref} from '../../routes'; +import {getTabletPagePath} from '../../routes'; import type {TTabletStateInfo} from '../../types/api/tablet'; import {cn} from '../../utils/cn'; import {getTabletLabel} from '../../utils/constants'; @@ -20,8 +20,7 @@ export const Tablet = ({tablet = {}, tenantName}: TabletProps) => { const {TabletId: id, NodeId, Type} = tablet; const status = tablet.Overall?.toLowerCase(); - const tabletPath = - id && createHref(routes.tablet, {id}, {nodeId: NodeId, tenantName, type: Type}); + const tabletPath = id && getTabletPagePath(id, {nodeId: NodeId, tenantName, type: Type}); return ( {state}; +} diff --git a/src/containers/Tablet/Tablet.scss b/src/containers/Tablet/Tablet.scss index 0c0d91783..6b13f19c1 100644 --- a/src/containers/Tablet/Tablet.scss +++ b/src/containers/Tablet/Tablet.scss @@ -1,27 +1,10 @@ @import '../../styles/mixins.scss'; -.tablet-page { +.ydb-tablet-page { $_: &; - display: flex; - flex-direction: column; - + height: 100%; padding: 20px; - - &__tenant { - margin-bottom: 20px; - } - - &__pane-wrapper { - display: flex; - } - - &__left-pane { - margin-right: 70px; - } - &__history-title { - margin-bottom: 15px; - @include body-2-typography(); - } + @include body-2-typography(); &__placeholder { display: flex; @@ -30,67 +13,7 @@ align-items: center; } - &__row { - display: flex; - align-items: center; - - &_header { - margin-bottom: 20px; - - & #{$_}__link { - margin: 0 10px 0 5px; - } - } - } - - &__title { - margin-right: 16px; - - font-weight: 500; - text-transform: uppercase; - - @include body-2-typography(); - } - &__loader { - width: 25px; - } - - .info-viewer__items { - grid-template-columns: auto; - } - - &__link { - @extend .link; - } - - &__controls { - margin: 20px 0 15px; - } - - &__control { - margin-right: 15px; - } - - &__links { - display: flex; - - margin: 5px 0 10px; - padding: 0; - - list-style-type: none; - - & > * { - margin: 0 10px 0 0; - } - } - - &__top-label { - margin-right: 16px; - - font-weight: 500; - text-transform: uppercase; - - @include body-2-typography(); + margin-left: var(--g-spacing-2); } } diff --git a/src/containers/Tablet/Tablet.tsx b/src/containers/Tablet/Tablet.tsx index fdcdf0e29..a8498c6e2 100644 --- a/src/containers/Tablet/Tablet.tsx +++ b/src/containers/Tablet/Tablet.tsx @@ -1,42 +1,74 @@ import React from 'react'; -import {ArrowUpRightFromSquare} from '@gravity-ui/icons'; -import {Link as ExternalLink, Icon} from '@gravity-ui/uikit'; +import {Flex, Tabs} from '@gravity-ui/uikit'; import {skipToken} from '@reduxjs/toolkit/query'; import {Helmet} from 'react-helmet-async'; import {useLocation, useParams} from 'react-router-dom'; +import {StringParam, useQueryParams} from 'use-query-params'; +import {z} from 'zod'; -import {EmptyState} from '../../components/EmptyState'; -import {EntityStatus} from '../../components/EntityStatus/EntityStatus'; +import {EmptyStateWrapper} from '../../components/EmptyState'; +import {EntityPageTitle} from '../../components/EntityPageTitle/EntityPageTitle'; import {ResponseError} from '../../components/Errors/ResponseError'; -import {Loader} from '../../components/Loader'; -import {Tag} from '../../components/Tag'; -import {parseQuery} from '../../routes'; -import {backend} from '../../store'; +import {InternalLink} from '../../components/InternalLink'; +import {LoaderWrapper} from '../../components/LoaderWrapper/LoaderWrapper'; +import {PageMetaWithAutorefresh} from '../../components/PageMeta/PageMeta'; +import {getTabletPagePath, parseQuery} from '../../routes'; +import {selectIsUserAllowedToMakeChanges} from '../../store/reducers/authentication/authentication'; import {setHeaderBreadcrumbs} from '../../store/reducers/header/header'; import {tabletApi} from '../../store/reducers/tablet'; +import {EFlag} from '../../types/api/enums'; import type {EType} from '../../types/api/tablet'; import {cn} from '../../utils/cn'; -import {CLUSTER_DEFAULT_TITLE, DEVELOPER_UI_TITLE} from '../../utils/constants'; -import {useAutoRefreshInterval, useTypedDispatch} from '../../utils/hooks'; +import {CLUSTER_DEFAULT_TITLE} from '../../utils/constants'; +import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../utils/hooks'; -import {TabletControls} from './TabletControls'; -import {TabletInfo} from './TabletInfo'; -import {TabletTable} from './TabletTable'; +import {TabletControls} from './components/TabletControls'; +import {TabletInfo} from './components/TabletInfo'; +import {TabletStorageInfo} from './components/TabletStorageInfo/TabletStorageInfo'; +import {TabletTable} from './components/TabletTable'; import i18n from './i18n'; +import {hasHive} from './utils'; import './Tablet.scss'; -export const b = cn('tablet-page'); +export const b = cn('ydb-tablet-page'); + +const TABLET_TABS_IDS = { + history: 'history', + channels: 'channels', +} as const; + +const TABLET_PAGE_TABS = [ + { + id: TABLET_TABS_IDS.history, + get title() { + return i18n('label_tablet-history'); + }, + }, + { + id: TABLET_TABS_IDS.channels, + get title() { + return i18n('label_tablet-channels'); + }, + isAdvanced: true, + }, +]; + +const tabletTabSchema = z.nativeEnum(TABLET_TABS_IDS).catch(TABLET_TABS_IDS.history); export const Tablet = () => { - const isFirstDataFetchRef = React.useRef(true); - const dispatch = useTypedDispatch(); const location = useLocation(); + const isUserAllowedToMakeChanges = useTypedSelector(selectIsUserAllowedToMakeChanges); + + const {id} = useParams<{id: string}>(); + + const [{activeTab}, setParams] = useQueryParams({ + activeTab: StringParam, + }); - const params = useParams<{id: string}>(); - const {id} = params; + const tabletTab = tabletTabSchema.parse(activeTab); const { nodeId: queryNodeId, @@ -47,7 +79,7 @@ export const Tablet = () => { const [autoRefreshInterval] = useAutoRefreshInterval(); const {currentData, isFetching, error, refetch} = tabletApi.useGetTabletQuery( - {id}, + {id, database: queryTenantName?.toString()}, {pollingInterval: autoRefreshInterval}, ); @@ -58,8 +90,38 @@ export const Tablet = () => { tablet.TenantId ? {tenantId: tablet.TenantId} : skipToken, ); + const hasHiveId = hasHive(tablet.HiveId); + + const noAdvancedInfo = !isUserAllowedToMakeChanges || !hasHiveId; + + React.useEffect(() => { + if (loading) { + return; + } + if (noAdvancedInfo && TABLET_PAGE_TABS.find(({id}) => id === tabletTab)?.isAdvanced) { + setParams({activeTab: TABLET_TABS_IDS.history}); + } + }, [noAdvancedInfo, tabletTab, setParams, loading]); + + const { + currentData: advancedData, + refetch: refetchAdvancedInfo, + isFetching: isFetchingAdvancedData, + } = tabletApi.useGetAdvancedTableInfoQuery( + {id, hiveId: tablet.HiveId}, + { + pollingInterval: autoRefreshInterval, + skip: noAdvancedInfo || activeTab !== 'channels', + }, + ); + + const refetchTabletInfo = React.useCallback(async () => { + await Promise.all([refetch(), refetchAdvancedInfo()]); + }, [refetch, refetchAdvancedInfo]); + const nodeId = tablet.NodeId?.toString() || queryNodeId?.toString(); const tenantName = tenantPath || queryTenantName?.toString(); + const type = tablet.Type || (queryTabletType?.toString() as EType | undefined); React.useEffect(() => { @@ -73,80 +135,106 @@ export const Tablet = () => { ); }, [dispatch, tenantName, id, nodeId, type]); - const renderExternalLinks = (link: {name: string; path: string}, index: number) => { + const renderPageMeta = () => { + const {Leader, Type} = tablet; + const database = tenantName ? `${i18n('tablet.meta-database')}: ${tenantName}` : undefined; + const type = Type ? Type : undefined; + const follower = Leader === false ? i18n('tablet.meta-follower').toUpperCase() : undefined; + + return ; + }; + + const renderHelmet = () => { + return ( + + {`${id} — ${i18n('tablet.header')} — ${ + tenantName || queryClusterName || CLUSTER_DEFAULT_TITLE + }`} + + ); + }; + + const renderTabs = () => { return ( -
  • - - {link.name} - -
  • + //block wrapper for tabs +
    + + isAdvanced ? !noAdvancedInfo : true, + )} + activeTab={tabletTab} + wrapTo={({id}, tabNode) => { + const path = tabletId + ? getTabletPagePath(tabletId, {activeTab: id}) + : undefined; + return ( + + {tabNode} + + ); + }} + /> +
    ); }; - const renderView = () => { - if (loading && id !== tabletId && isFirstDataFetchRef.current) { - return ; + const renderTabsContent = () => { + switch (tabletTab) { + case 'channels': { + return ( + + + + ); + } + case 'history': { + return ; + } + default: + return null; } + }; + const renderView = () => { if (error && !currentData) { return ; } - if (!tablet || !Object.keys(tablet).length) { - return ( -
    - -
    - ); - } - - const {TabletId, Overall, Leader} = tablet; - - const externalLinks = [ - { - name: `${DEVELOPER_UI_TITLE} - tablet`, - path: `/tablets?TabletID=${TabletId}`, - }, - ]; - + const {TabletId, Overall} = tablet; return ( -
    + {error ? : null} -
    -
    -
      {externalLinks.map(renderExternalLinks)}
    -
    - {i18n('tablet.header')} - - - - - {Leader && } - {loading && } -
    - - -
    -
    - -
    -
    -
    + + + + + + ); }; return ( - - - {`${id} — ${i18n('tablet.header')} — ${ - tenantName || queryClusterName || CLUSTER_DEFAULT_TITLE - }`} - - {renderView()} - + + {renderHelmet()} + {renderPageMeta()} + + + {renderView()} + {renderTabs()} + {renderTabsContent()} + + + ); }; diff --git a/src/containers/Tablet/TabletInfo/TabletInfo.tsx b/src/containers/Tablet/TabletInfo/TabletInfo.tsx deleted file mode 100644 index 9577cf95e..000000000 --- a/src/containers/Tablet/TabletInfo/TabletInfo.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import {Link as UIKitLink} from '@gravity-ui/uikit'; -import {Link} from 'react-router-dom'; - -import type {InfoViewerItem} from '../../../components/InfoViewer'; -import {InfoViewer} from '../../../components/InfoViewer'; -import routes, {createHref} from '../../../routes'; -import {ETabletState} from '../../../types/api/tablet'; -import type {TTabletStateInfo} from '../../../types/api/tablet'; -import {calcUptime} from '../../../utils/dataFormatters/dataFormatters'; -import {getDefaultNodePath} from '../../Node/NodePages'; -import {b} from '../Tablet'; - -interface TabletInfoProps { - tablet: TTabletStateInfo; - tenantPath?: string; -} - -export const TabletInfo = ({tablet, tenantPath}: TabletInfoProps) => { - const { - ChangeTime, - Generation, - FollowerId, - NodeId, - HiveId, - State, - Type, - TenantId: {SchemeShard} = {}, - } = tablet; - - const hasHiveId = HiveId && HiveId !== '0'; - const hasUptime = State === ETabletState.Active; - - const tabletInfo: InfoViewerItem[] = [{label: 'Database', value: tenantPath || '-'}]; - - if (hasHiveId) { - tabletInfo.push({ - label: 'HiveId', - value: ( - - {HiveId} - - ), - }); - } - - if (SchemeShard) { - tabletInfo.push({ - label: 'SchemeShard', - value: ( - - {SchemeShard} - - ), - }); - } - - tabletInfo.push({label: 'Type', value: Type}, {label: 'State', value: State}); - - if (hasUptime) { - tabletInfo.push({label: 'Uptime', value: calcUptime(ChangeTime)}); - } - - tabletInfo.push( - {label: 'Generation', value: Generation}, - { - label: 'Node', - value: ( - - {NodeId} - - ), - }, - ); - - if (FollowerId) { - tabletInfo.push({label: 'Follower', value: FollowerId}); - } - - return ; -}; diff --git a/src/containers/Tablet/TabletControls/TabletControls.tsx b/src/containers/Tablet/components/TabletControls/TabletControls.tsx similarity index 74% rename from src/containers/Tablet/TabletControls/TabletControls.tsx rename to src/containers/Tablet/components/TabletControls/TabletControls.tsx index 20ceb6ab9..46f26418a 100644 --- a/src/containers/Tablet/TabletControls/TabletControls.tsx +++ b/src/containers/Tablet/components/TabletControls/TabletControls.tsx @@ -1,12 +1,15 @@ import React from 'react'; -import {ButtonWithConfirmDialog} from '../../../components/ButtonWithConfirmDialog/ButtonWithConfirmDialog'; -import {selectIsUserAllowedToMakeChanges} from '../../../store/reducers/authentication/authentication'; -import {ETabletState} from '../../../types/api/tablet'; -import type {TTabletStateInfo} from '../../../types/api/tablet'; -import {useTypedSelector} from '../../../utils/hooks'; -import {b} from '../Tablet'; -import i18n from '../i18n'; +import {ArrowRotateLeft, StopFill, TriangleRightFill} from '@gravity-ui/icons'; +import {Flex, Icon} from '@gravity-ui/uikit'; + +import {ButtonWithConfirmDialog} from '../../../../components/ButtonWithConfirmDialog/ButtonWithConfirmDialog'; +import {selectIsUserAllowedToMakeChanges} from '../../../../store/reducers/authentication/authentication'; +import {ETabletState} from '../../../../types/api/tablet'; +import type {TTabletStateInfo} from '../../../../types/api/tablet'; +import {useTypedSelector} from '../../../../utils/hooks'; +import i18n from '../../i18n'; +import {hasHive} from '../../utils'; interface TabletControlsProps { tablet: TTabletStateInfo; @@ -28,9 +31,7 @@ export const TabletControls = ({tablet, fetchData}: TabletControlsProps) => { return window.api.resumeTablet(TabletId, HiveId); }; - const hasHiveId = () => { - return HiveId && HiveId !== '0'; - }; + const hasHiveId = hasHive(HiveId); const isDisabledRestart = tablet.State === ETabletState.Stopped; @@ -41,50 +42,53 @@ export const TabletControls = ({tablet, fetchData}: TabletControlsProps) => { tablet.State === ETabletState.Stopped || tablet.State === ETabletState.Deleted; return ( -
    + + {i18n('controls.kill')} - {hasHiveId() ? ( + {hasHiveId && ( + {i18n('controls.stop')} + {i18n('controls.resume')} - ) : null} -
    + )} + ); }; diff --git a/src/containers/Tablet/TabletControls/index.ts b/src/containers/Tablet/components/TabletControls/index.ts similarity index 100% rename from src/containers/Tablet/TabletControls/index.ts rename to src/containers/Tablet/components/TabletControls/index.ts diff --git a/src/containers/Tablet/components/TabletInfo/TabletInfo.scss b/src/containers/Tablet/components/TabletInfo/TabletInfo.scss new file mode 100644 index 000000000..b464aa735 --- /dev/null +++ b/src/containers/Tablet/components/TabletInfo/TabletInfo.scss @@ -0,0 +1,12 @@ +@import '../../../../styles/mixins.scss'; + +.ydb-tablet-info { + &__link { + @extend .link; + } + + &__section-title { + margin: var(--g-spacing-1) 0 var(--g-spacing-3); + @include text-subheader-2(); + } +} diff --git a/src/containers/Tablet/components/TabletInfo/TabletInfo.tsx b/src/containers/Tablet/components/TabletInfo/TabletInfo.tsx new file mode 100644 index 000000000..1f1d491b9 --- /dev/null +++ b/src/containers/Tablet/components/TabletInfo/TabletInfo.tsx @@ -0,0 +1,136 @@ +import {Flex} from '@gravity-ui/uikit'; +import {Link} from 'react-router-dom'; + +import type {InfoViewerItem} from '../../../../components/InfoViewer'; +import {InfoViewer} from '../../../../components/InfoViewer'; +import {LinkWithIcon} from '../../../../components/LinkWithIcon/LinkWithIcon'; +import {TabletState} from '../../../../components/TabletState/TabletState'; +import {getTabletPagePath} from '../../../../routes'; +import {selectIsUserAllowedToMakeChanges} from '../../../../store/reducers/authentication/authentication'; +import {ETabletState} from '../../../../types/api/tablet'; +import type {TTabletStateInfo} from '../../../../types/api/tablet'; +import {cn} from '../../../../utils/cn'; +import {calcUptime} from '../../../../utils/dataFormatters/dataFormatters'; +import {createTabletDeveloperUIHref} from '../../../../utils/developerUI/developerUI'; +import {useTypedSelector} from '../../../../utils/hooks'; +import {getDefaultNodePath} from '../../../Node/NodePages'; +import {hasHive} from '../../utils'; + +import {tabletInfoKeyset} from './i18n'; + +const b = cn('ydb-tablet-info'); + +import './TabletInfo.scss'; + +interface TabletInfoProps { + tablet: TTabletStateInfo; +} + +export const TabletInfo = ({tablet}: TabletInfoProps) => { + const isUserAllowedToMakeChanges = useTypedSelector(selectIsUserAllowedToMakeChanges); + + const { + ChangeTime, + Generation, + FollowerId, + NodeId, + HiveId, + State, + TenantId: {SchemeShard} = {}, + TabletId, + } = tablet; + + const hasHiveId = hasHive(HiveId); + const hasUptime = State === ETabletState.Active; + + const tabletInfo: InfoViewerItem[] = []; + + if (hasHiveId) { + tabletInfo.push({ + label: tabletInfoKeyset('field_hive'), + value: ( + + {HiveId} + + ), + }); + } + + if (SchemeShard) { + tabletInfo.push({ + label: tabletInfoKeyset('field_scheme-shard'), + value: ( + + {SchemeShard} + + ), + }); + } + + tabletInfo.push({label: tabletInfoKeyset('field_state'), value: }); + + if (hasUptime) { + tabletInfo.push({label: tabletInfoKeyset('field_uptime'), value: calcUptime(ChangeTime)}); + } + + tabletInfo.push( + {label: tabletInfoKeyset('field_generation'), value: Generation}, + { + label: tabletInfoKeyset('field_node'), + value: ( + + {NodeId} + + ), + }, + ); + + if (FollowerId) { + tabletInfo.push({label: tabletInfoKeyset('field_follower'), value: FollowerId}); + } + + const renderTabletInfo = () => { + return ( +
    +
    {tabletInfoKeyset('title_info')}
    + +
    + ); + }; + + const renderLinks = () => { + if (!isUserAllowedToMakeChanges || !TabletId) { + return null; + } + return ( +
    +
    {tabletInfoKeyset('title_links')}
    + + + + + + +
    + ); + }; + + return ( + + {renderTabletInfo()} + {renderLinks()} + + ); +}; diff --git a/src/containers/Tablet/components/TabletInfo/i18n/en.json b/src/containers/Tablet/components/TabletInfo/i18n/en.json new file mode 100644 index 000000000..2f4b5bb44 --- /dev/null +++ b/src/containers/Tablet/components/TabletInfo/i18n/en.json @@ -0,0 +1,16 @@ +{ + "field_scheme-shard": "SchemeShard", + "field_follower": "Follower", + "field_generation": "Generation", + "field_hive": "HiveId", + "field_state": "State", + "field_uptime": "Uptime", + "field_node": "Node", + "field_links": "Links", + "field_developer-ui-app": "App", + "field_developer-ui-counters": "Counters", + "field_developer-ui-executor": "Executor DB internals", + "field_developer-ui-state": "State Storage", + "title_info": "Info", + "title_links": "Links" +} diff --git a/src/containers/Tablet/components/TabletInfo/i18n/index.ts b/src/containers/Tablet/components/TabletInfo/i18n/index.ts new file mode 100644 index 000000000..a60df0b98 --- /dev/null +++ b/src/containers/Tablet/components/TabletInfo/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../../../utils/i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-tablet-info'; + +export const tabletInfoKeyset = registerKeysets(COMPONENT, {en}); diff --git a/src/containers/Tablet/TabletInfo/index.ts b/src/containers/Tablet/components/TabletInfo/index.ts similarity index 100% rename from src/containers/Tablet/TabletInfo/index.ts rename to src/containers/Tablet/components/TabletInfo/index.ts diff --git a/src/containers/Tablet/components/TabletStorageInfo/TabletStorageInfo.scss b/src/containers/Tablet/components/TabletStorageInfo/TabletStorageInfo.scss new file mode 100644 index 000000000..48dd54865 --- /dev/null +++ b/src/containers/Tablet/components/TabletStorageInfo/TabletStorageInfo.scss @@ -0,0 +1,78 @@ +@import '../../../../styles/mixins.scss'; + +.ydb-tablet-storage-info { + $block: &; + &__table { + table-layout: fixed; + border-spacing: 0px; + border-collapse: collapse; + tr { + &:hover { + background-color: var(--g-color-base-generic-hover) !important; + } + } + tr:nth-of-type(2n + 1) { + background-color: var(--g-color-base-generic-ultralight); + } + :is(#{$block}__table-header-cell) { + height: 40px; + padding: 0; + + text-align: left; + + background-color: var(--g-color-base-background); + @include text-subheader-2(); + } + :is(#{$block}__table-cell) { + height: 40px; + padding: 0; + @include text-body-2(); + } + } + + &__table-header-cell { + &_align_right { + #{$block}__table-header-content { + justify-content: flex-end; + + text-align: right; + } + } + } + + &__table-header-content { + display: flex; + align-items: center; + + height: 100%; + padding: var(--g-spacing-1) var(--g-spacing-2); + + vertical-align: middle; + + border-bottom: 1px solid var(--g-color-line-generic); + } + :is(#{$block}__table-cell) { + height: 40px; + padding: 0; + @include text-body-2(); + } + &__table-cell { + &_align_right { + text-align: right; + } + } + &__metrics-cell { + padding: var(--g-spacing-1) var(--g-spacing-2); + + white-space: nowrap; + } + &__name-wrapper { + padding: var(--g-spacing-1) var(--g-spacing-2); + } + &__with-padding { + padding-left: calc(var(--g-spacing-2) + var(--g-spacing-6)); + } + &__name-content_no-control { + padding-left: var(--g-spacing-6); + } +} diff --git a/src/containers/Tablet/components/TabletStorageInfo/TabletStorageInfo.tsx b/src/containers/Tablet/components/TabletStorageInfo/TabletStorageInfo.tsx new file mode 100644 index 000000000..719face4a --- /dev/null +++ b/src/containers/Tablet/components/TabletStorageInfo/TabletStorageInfo.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import {Table, useTable} from '@gravity-ui/table'; +import type {ExpandedState} from '@tanstack/react-table'; + +import type {TTabletHiveResponse} from '../../../../types/api/tablet'; + +import {getColumns} from './columns'; +import {b} from './shared'; +import {prepareData} from './utils'; + +import './TabletStorageInfo.scss'; + +interface TabletStorageInfoProps { + data?: TTabletHiveResponse | null; +} + +export function TabletStorageInfo({data}: TabletStorageInfoProps) { + const [expanded, setExpanded] = React.useState({}); + const tree = React.useMemo(() => prepareData(data), [data]); + const isExpandable = React.useMemo(() => tree.some((item) => item.children?.length), [tree]); + const columns = React.useMemo(() => getColumns(isExpandable), [isExpandable]); + const table = useTable({ + columns, + data: tree, + getSubRows: (item) => item.children, + enableExpanding: true, + onExpandedChange: setExpanded, + state: { + expanded, + }, + }); + return ( + //block wrapper for table +
    + { + const align = column.columnDef.meta?.align; + return b('table-header-cell', {align}); + }} + cellClassName={(cell) => { + const align = cell?.column.columnDef.meta?.align; + const verticalAlign = cell?.column.columnDef.meta?.verticalAlign; + return b('table-cell', {align, 'vertical-align': verticalAlign}); + }} + className={b('table')} + /> + + ); +} diff --git a/src/containers/Tablet/components/TabletStorageInfo/columns.tsx b/src/containers/Tablet/components/TabletStorageInfo/columns.tsx new file mode 100644 index 000000000..54d9c9b0c --- /dev/null +++ b/src/containers/Tablet/components/TabletStorageInfo/columns.tsx @@ -0,0 +1,100 @@ +import {ArrowToggle, Button, Flex} from '@gravity-ui/uikit'; +import type {CellContext, ColumnDef, Row} from '@tanstack/react-table'; + +import {formatTimestamp} from '../../../../utils/dataFormatters/dataFormatters'; + +import {tabletInfoKeyset} from './i18n'; +import {b} from './shared'; +import type {TabletStorageItem} from './types'; + +interface ColumnHeaderProps { + name: string; + className?: string; +} + +function ColumnHeader({name, className}: ColumnHeaderProps) { + return
    {name}
    ; +} + +function metricsCell( + info: CellContext, + formatter?: (value: string | number) => string | number, +) { + const value = info.getValue(); + const formattedValue = typeof formatter === 'function' ? formatter(value) : value; + + return
    {formattedValue}
    ; +} + +interface GroupIdCellProps { + row: Row; + name?: string; + hasExpand?: boolean; +} + +function GroupIdCell({row, name, hasExpand}: GroupIdCellProps) { + const isExpandable = row.getCanExpand(); + return ( + + {isExpandable && ( + + )} +
    + {name} +
    +
    + ); +} + +export function getColumns(hasExpand?: boolean) { + const columns: ColumnDef[] = [ + { + accessorKey: 'channelIndex', + header: () => , + size: 50, + cell: metricsCell, + meta: {align: 'right'}, + }, + { + accessorKey: 'storagePoolName', + header: () => , + size: 200, + cell: metricsCell, + }, + { + accessorKey: 'GroupID', + header: () => ( + + ), + size: 100, + cell: (info) => ( + ()} hasExpand={hasExpand} /> + ), + }, + { + accessorKey: 'FromGeneration', + header: () => , + size: 100, + cell: metricsCell, + meta: {align: 'right'}, + }, + { + accessorKey: 'Timestamp', + header: () => , + size: 200, + cell: (info) => metricsCell(info, formatTimestamp), + meta: {align: 'right'}, + }, + ]; + return columns; +} diff --git a/src/containers/Tablet/components/TabletStorageInfo/i18n/en.json b/src/containers/Tablet/components/TabletStorageInfo/i18n/en.json new file mode 100644 index 000000000..12b9114bb --- /dev/null +++ b/src/containers/Tablet/components/TabletStorageInfo/i18n/en.json @@ -0,0 +1,7 @@ +{ + "label_channel-index": "Channel", + "label_storage-pool": "Storage Pool Name", + "label_group-id": "Group ID", + "label_generation": "From generation", + "label_timestamp": "Timestamp" +} diff --git a/src/containers/Tablet/components/TabletStorageInfo/i18n/index.ts b/src/containers/Tablet/components/TabletStorageInfo/i18n/index.ts new file mode 100644 index 000000000..01ebbd1a5 --- /dev/null +++ b/src/containers/Tablet/components/TabletStorageInfo/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../../../utils/i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-tablet-storage-info'; + +export const tabletInfoKeyset = registerKeysets(COMPONENT, {en}); diff --git a/src/containers/Tablet/components/TabletStorageInfo/shared.ts b/src/containers/Tablet/components/TabletStorageInfo/shared.ts new file mode 100644 index 000000000..c219bf359 --- /dev/null +++ b/src/containers/Tablet/components/TabletStorageInfo/shared.ts @@ -0,0 +1,3 @@ +import {cn} from '../../../../utils/cn'; + +export const b = cn('ydb-tablet-storage-info'); diff --git a/src/containers/Tablet/components/TabletStorageInfo/types.ts b/src/containers/Tablet/components/TabletStorageInfo/types.ts new file mode 100644 index 000000000..f2358378f --- /dev/null +++ b/src/containers/Tablet/components/TabletStorageInfo/types.ts @@ -0,0 +1,6 @@ +import type {TTabletStorageInfoChannelHistory} from '../../../../types/api/tablet'; + +export interface TabletStorageItem extends TTabletStorageInfoChannelHistory { + storagePoolName?: string; + children?: TabletStorageItem[]; +} diff --git a/src/containers/Tablet/components/TabletStorageInfo/utils.ts b/src/containers/Tablet/components/TabletStorageInfo/utils.ts new file mode 100644 index 000000000..272362e5f --- /dev/null +++ b/src/containers/Tablet/components/TabletStorageInfo/utils.ts @@ -0,0 +1,36 @@ +import type {TTabletHiveResponse} from '../../../../types/api/tablet'; + +import type {TabletStorageItem} from './types'; + +export function prepareData(data?: TTabletHiveResponse | null): TabletStorageItem[] { + if (!data) { + return []; + } + const {BoundChannels, TabletStorageInfo = {}} = data; + + const Channels = TabletStorageInfo.Channels ?? []; + + const result = []; + + for (const channel of Channels) { + const channelIndex = channel.Channel; + const channelHistory = channel.History; + if (!channelIndex || !channelHistory || !channelHistory.length) { + continue; + } + const copiedChannelHistory = [...channelHistory]; + + copiedChannelHistory.reverse(); + const [latest, ...rest] = copiedChannelHistory; + const storagePoolName = BoundChannels?.[channelIndex]?.StoragePoolName; + + const params = { + ...latest, + storagePoolName, + channelIndex, + children: rest, + }; + result.push(params); + } + return result; +} diff --git a/src/containers/Tablet/TabletTable/TabletTable.tsx b/src/containers/Tablet/components/TabletTable/TabletTable.tsx similarity index 74% rename from src/containers/Tablet/TabletTable/TabletTable.tsx rename to src/containers/Tablet/components/TabletTable/TabletTable.tsx index 5e096b1a7..588e472e9 100644 --- a/src/containers/Tablet/TabletTable/TabletTable.tsx +++ b/src/containers/Tablet/components/TabletTable/TabletTable.tsx @@ -1,12 +1,13 @@ import type {Column} from '@gravity-ui/react-data-table'; import DataTable from '@gravity-ui/react-data-table'; -import {EntityStatus} from '../../../components/EntityStatus/EntityStatus'; -import {InternalLink} from '../../../components/InternalLink/InternalLink'; -import {ResizeableDataTable} from '../../../components/ResizeableDataTable/ResizeableDataTable'; -import type {ITabletPreparedHistoryItem} from '../../../types/store/tablet'; -import {calcUptime} from '../../../utils/dataFormatters/dataFormatters'; -import {getDefaultNodePath} from '../../Node/NodePages'; +import {EntityStatus} from '../../../../components/EntityStatus/EntityStatus'; +import {InternalLink} from '../../../../components/InternalLink/InternalLink'; +import {ResizeableDataTable} from '../../../../components/ResizeableDataTable/ResizeableDataTable'; +import {TabletState} from '../../../../components/TabletState/TabletState'; +import type {ITabletPreparedHistoryItem} from '../../../../types/store/tablet'; +import {calcUptime} from '../../../../utils/dataFormatters/dataFormatters'; +import {getDefaultNodePath} from '../../../Node/NodePages'; const TABLET_COLUMNS_WIDTH_LS_KEY = 'tabletTableColumnsWidth'; @@ -25,7 +26,7 @@ const columns: Column[] = [ { name: 'State', sortable: false, - render: ({row}) => row.state, + render: ({row}) => , }, { name: 'Follower ID', diff --git a/src/containers/Tablet/TabletTable/index.ts b/src/containers/Tablet/components/TabletTable/index.ts similarity index 100% rename from src/containers/Tablet/TabletTable/index.ts rename to src/containers/Tablet/components/TabletTable/index.ts diff --git a/src/containers/Tablet/i18n/en.json b/src/containers/Tablet/i18n/en.json index 2bea6d463..d24385939 100644 --- a/src/containers/Tablet/i18n/en.json +++ b/src/containers/Tablet/i18n/en.json @@ -1,6 +1,9 @@ { "tablet.header": "Tablet", + "tablet.meta-database": "Database", + "tablet.meta-follower": "Follower", + "controls.kill": "Restart", "controls.stop": "Stop", "controls.resume": "Resume", @@ -13,5 +16,8 @@ "dialog.stop": "The tablet will be stopped. Do you want to proceed?", "dialog.resume": "The tablet will be resumed. Do you want to proceed?", - "emptyState": "The tablet was not found" + "emptyState": "The tablet was not found", + + "label_tablet-history": "Tablets", + "label_tablet-channels": "Storage" } diff --git a/src/containers/Tablet/utils.ts b/src/containers/Tablet/utils.ts new file mode 100644 index 000000000..d1c002eb6 --- /dev/null +++ b/src/containers/Tablet/utils.ts @@ -0,0 +1,3 @@ +export function hasHive(id?: string): id is string { + return Boolean(id && id !== '0'); +} diff --git a/src/containers/Tablets/Tablets.tsx b/src/containers/Tablets/Tablets.tsx index 7d6b2b194..270df62df 100644 --- a/src/containers/Tablets/Tablets.tsx +++ b/src/containers/Tablets/Tablets.tsx @@ -1,6 +1,6 @@ import {ArrowsRotateRight} from '@gravity-ui/icons'; import type {Column as DataTableColumn} from '@gravity-ui/react-data-table'; -import {Icon, Label, Text} from '@gravity-ui/uikit'; +import {Icon, Text} from '@gravity-ui/uikit'; import {skipToken} from '@reduxjs/toolkit/query'; import {ButtonWithConfirmDialog} from '../../components/ButtonWithConfirmDialog/ButtonWithConfirmDialog'; @@ -10,6 +10,7 @@ import {ResponseError} from '../../components/Errors/ResponseError'; import {InternalLink} from '../../components/InternalLink'; import {ResizeableDataTable} from '../../components/ResizeableDataTable/ResizeableDataTable'; import {TableSkeleton} from '../../components/TableSkeleton/TableSkeleton'; +import {TabletState} from '../../components/TabletState/TabletState'; import {getTabletPagePath} from '../../routes'; import {selectIsUserAllowedToMakeChanges} from '../../store/reducers/authentication/authentication'; import {selectTabletsWithFqdn, tabletsApi} from '../../store/reducers/tablets'; @@ -21,7 +22,6 @@ import {DEFAULT_TABLE_SETTINGS, EMPTY_DATA_PLACEHOLDER} from '../../utils/consta import {calcUptime} from '../../utils/dataFormatters/dataFormatters'; import {createTabletDeveloperUIHref} from '../../utils/developerUI/developerUI'; import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../utils/hooks'; -import {mapTabletStateToLabelTheme} from '../../utils/tablet'; import {getDefaultNodePath} from '../Node/NodePages'; import i18n from './i18n'; @@ -78,7 +78,7 @@ const columns: DataTableColumn[] = [ return i18n('State'); }, render: ({row}) => { - return ; + return ; }, }, { diff --git a/src/services/api.ts b/src/services/api.ts index 1d3c5c1e0..d4a927fed 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -38,6 +38,7 @@ import type {TEvSystemStateResponse} from '../types/api/systemState'; import type { TDomainKey, TEvTabletStateResponse, + TTabletHiveResponse, UnmergedTEvTabletStateResponse, } from '../types/api/tablet'; import type {TTenantInfo, TTenants} from '../types/api/tenant'; @@ -389,11 +390,15 @@ export class YdbEmbeddedAPI extends AxiosWrapper { {concurrentId: concurrentId || 'getConsumer', requestConfig: {signal}}, ); } - getTablet({id}: {id?: string}, {concurrentId, signal}: AxiosOptions = {}) { + getTablet( + {id, database}: {id?: string; database?: string}, + {concurrentId, signal}: AxiosOptions = {}, + ) { return this.get( this.getPath(`/viewer/json/tabletinfo?filter=(TabletId=${id})`), { enums: true, + database, }, { concurrentId, @@ -401,12 +406,16 @@ export class YdbEmbeddedAPI extends AxiosWrapper { }, ); } - getTabletHistory({id}: {id?: string}, {concurrentId, signal}: AxiosOptions = {}) { + getTabletHistory( + {id, database}: {id?: string; database?: string}, + {concurrentId, signal}: AxiosOptions = {}, + ) { return this.get( this.getPath(`/viewer/json/tabletinfo?filter=(TabletId=${id})`), { enums: true, merge: false, + database, }, { concurrentId, @@ -586,6 +595,23 @@ export class YdbEmbeddedAPI extends AxiosWrapper { {requestConfig: {'axios-retry': {retries: 0}}}, ); } + getTabletFromHive( + {id, hiveId}: {id?: string; hiveId?: string}, + {concurrentId, signal}: AxiosOptions = {}, + ) { + return this.get>( + this.getPath('/tablets/app'), + { + TabletID: hiveId, + page: 'TabletInfo', + tablet: id, + }, + { + concurrentId, + requestConfig: {signal}, + }, + ); + } getTabletDescribe(tenantId: TDomainKey, {concurrentId, signal}: AxiosOptions = {}) { return this.get>( this.getPath('/viewer/json/describe'), diff --git a/src/store/reducers/tablet.ts b/src/store/reducers/tablet.ts index 046768fc6..faa6f5d2b 100644 --- a/src/store/reducers/tablet.ts +++ b/src/store/reducers/tablet.ts @@ -7,11 +7,11 @@ import {api} from './api'; export const tabletApi = api.injectEndpoints({ endpoints: (build) => ({ getTablet: build.query({ - queryFn: async ({id}: {id: string}, {signal}) => { + queryFn: async ({id, database}: {id: string; database?: string}, {signal}) => { try { const [tabletResponseData, historyResponseData, nodesList] = await Promise.all([ - window.api.getTablet({id}, {signal}), - window.api.getTabletHistory({id}, {signal}), + window.api.getTablet({id, database}, {signal}), + window.api.getTabletHistory({id, database}, {signal}), window.api.getNodesList({signal}), ]); const nodesMap = prepareNodesMap(nodesList); @@ -29,15 +29,17 @@ export const tabletApi = api.injectEndpoints({ const fqdn = nodesMap && nodeId ? nodesMap.get(Number(nodeId)) : undefined; - list.push({ - nodeId, - generation: Generation, - changeTime: ChangeTime, - state: State, - leader: Leader, - followerId: FollowerId, - fqdn, - }); + if (State !== 'Dead') { + list.push({ + nodeId, + generation: Generation, + changeTime: ChangeTime, + state: State, + leader: Leader, + followerId: FollowerId, + fqdn, + }); + } } return list; }, []); @@ -67,6 +69,21 @@ export const tabletApi = api.injectEndpoints({ }, providesTags: ['All'], }), + getAdvancedTableInfo: build.query({ + queryFn: async ({id, hiveId}: {id: string; hiveId?: string}, {signal}) => { + try { + const tabletResponseData = await window.api.getTabletFromHive( + {id, hiveId}, + {signal}, + ); + + return {data: tabletResponseData}; + } catch (error) { + return {error}; + } + }, + providesTags: ['All'], + }), }), overrideExisting: 'throw', }); diff --git a/src/types/api/tablet.ts b/src/types/api/tablet.ts index 76dcf5498..7d5f49175 100644 --- a/src/types/api/tablet.ts +++ b/src/types/api/tablet.ts @@ -119,3 +119,27 @@ export enum ETabletState { 'Deleted' = 'Deleted', 'Stopped' = 'Stopped', } + +interface TBoundChannel { + IOPS?: number; + StoragePoolName?: string; + Throughput?: number; + Size?: number; +} + +export interface TTabletStorageInfoChannelHistory { + GroupID?: number; + FromGeneration?: number; + Timestamp?: string; +} + +interface TTabletStorageInfoChannel { + Channel?: number; + History?: TTabletStorageInfoChannelHistory[]; +} + +//tablet data from hive +export interface TTabletHiveResponse { + BoundChannels?: TBoundChannel[]; + TabletStorageInfo?: {Channels?: TTabletStorageInfoChannel[]; Version?: number}; +} diff --git a/src/types/store/tablets.ts b/src/types/store/tablets.ts index fd74ef908..befbff9bb 100644 --- a/src/types/store/tablets.ts +++ b/src/types/store/tablets.ts @@ -8,6 +8,7 @@ export interface TabletsState { export interface TabletsApiRequestParams { nodes?: string[]; path?: string; + database?: string; } export interface TabletsRootStateSlice { diff --git a/src/utils/dataFormatters/dataFormatters.ts b/src/utils/dataFormatters/dataFormatters.ts index 9a6b64cbd..cea9b2cc4 100644 --- a/src/utils/dataFormatters/dataFormatters.ts +++ b/src/utils/dataFormatters/dataFormatters.ts @@ -136,6 +136,11 @@ export const formatDateTime = (value?: number | string, defaultValue = '') => { return formattedData ?? defaultValue; }; +export const formatTimestamp = (value?: string | number, defaultValue = '') => { + const formattedData = dateTimeParse(value)?.format('YYYY-MM-DD HH:mm:ss.SSS'); + + return formattedData ?? defaultValue; +}; export const calcUptimeInSeconds = (milliseconds: number | string) => { const currentDate = new Date(); diff --git a/src/utils/developerUI/developerUI.ts b/src/utils/developerUI/developerUI.ts index 1a77e82ac..414fc4044 100644 --- a/src/utils/developerUI/developerUI.ts +++ b/src/utils/developerUI/developerUI.ts @@ -41,6 +41,12 @@ export const createVDiskDeveloperUILink = ({ return createDeveloperUILinkWithNodeId(nodeId, host) + vdiskPath; }; -export function createTabletDeveloperUIHref(tabletId: number | string, host = backend) { - return `${host}/tablets?TabletID=${tabletId}`; +export function createTabletDeveloperUIHref( + tabletId: number | string, + tabletPage?: string, + searchParam = 'TabletID', + host = backend, +) { + const subPage = tabletPage ? `/${tabletPage}` : ''; + return `${host}/tablets${subPage}?${searchParam}=${tabletId}`; }