diff --git a/src/containers/VDiskPage/VDiskPage.scss b/src/containers/VDiskPage/VDiskPage.scss index 5af1027dc3..7bbc87f02f 100644 --- a/src/containers/VDiskPage/VDiskPage.scss +++ b/src/containers/VDiskPage/VDiskPage.scss @@ -12,7 +12,7 @@ &__title, &__controls, &__info, - &__storage-title { + &__tabs { position: sticky; left: 0; @@ -29,8 +29,7 @@ gap: var(--g-spacing-2); } - &__storage-title { - margin-bottom: 0; - @include mixins.header-1-typography(); + &__tablets-content { + margin-top: var(--g-spacing-4); } } diff --git a/src/containers/VDiskPage/VDiskPage.tsx b/src/containers/VDiskPage/VDiskPage.tsx index c9371c001a..35155c5fa2 100644 --- a/src/containers/VDiskPage/VDiskPage.tsx +++ b/src/containers/VDiskPage/VDiskPage.tsx @@ -1,17 +1,20 @@ import React from 'react'; import {ArrowsOppositeToDots} from '@gravity-ui/icons'; -import {Icon} from '@gravity-ui/uikit'; +import {Icon, Tab, TabList, TabProvider} from '@gravity-ui/uikit'; import {skipToken} from '@reduxjs/toolkit/query'; import {Helmet} from 'react-helmet-async'; import {StringParam, useQueryParams} from 'use-query-params'; +import {z} from 'zod'; import {ButtonWithConfirmDialog} from '../../components/ButtonWithConfirmDialog/ButtonWithConfirmDialog'; import {EntityPageTitle} from '../../components/EntityPageTitle/EntityPageTitle'; import {ResponseError} from '../../components/Errors/ResponseError'; import {InfoViewerSkeleton} from '../../components/InfoViewerSkeleton/InfoViewerSkeleton'; +import {InternalLink} from '../../components/InternalLink/InternalLink'; import {PageMetaWithAutorefresh} from '../../components/PageMeta/PageMeta'; import {VDiskInfo} from '../../components/VDiskInfo/VDiskInfo'; +import {getVDiskPagePath} from '../../routes'; import {api} from '../../store/reducers/api'; import {useDiskPagesAvailable} from '../../store/reducers/capabilities/hooks'; import {setHeaderBreadcrumbs} from '../../store/reducers/header/header'; @@ -25,12 +28,35 @@ import {useAutoRefreshInterval, useTypedDispatch} from '../../utils/hooks'; import {useIsUserAllowedToMakeChanges} from '../../utils/hooks/useIsUserAllowedToMakeChanges'; import {PaginatedStorage} from '../Storage/PaginatedStorage'; +import {VDiskTablets} from './VDiskTablets'; import {vDiskPageKeyset} from './i18n'; import './VDiskPage.scss'; const vDiskPageCn = cn('ydb-vdisk-page'); +const VDISK_TABS_IDS = { + storage: 'storage', + tablets: 'tablets', +} as const; + +const VDISK_PAGE_TABS = [ + { + id: VDISK_TABS_IDS.storage, + get title() { + return vDiskPageKeyset('storage'); + }, + }, + { + id: VDISK_TABS_IDS.tablets, + get title() { + return vDiskPageKeyset('tablets'); + }, + }, +]; + +const vDiskTabSchema = z.nativeEnum(VDISK_TABS_IDS).catch(VDISK_TABS_IDS.storage); + export function VDiskPage() { const dispatch = useTypedDispatch(); @@ -38,13 +64,16 @@ export function VDiskPage() { const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const newDiskApiAvailable = useDiskPagesAvailable(); - const [{nodeId, pDiskId, vDiskSlotId, vDiskId: vDiskIdParam}] = useQueryParams({ + const [{nodeId, pDiskId, vDiskSlotId, vDiskId: vDiskIdParam, activeTab}] = useQueryParams({ nodeId: StringParam, pDiskId: StringParam, vDiskSlotId: StringParam, vDiskId: StringParam, + activeTab: StringParam, }); + const vDiskTab = vDiskTabSchema.parse(activeTab); + React.useEffect(() => { dispatch(setHeaderBreadcrumbs('vDisk', {nodeId, pDiskId, vDiskSlotId})); }, [dispatch, nodeId, pDiskId, vDiskSlotId]); @@ -185,24 +214,67 @@ export function VDiskPage() { return ; }; + const renderTabs = () => { + const vDiskParamsDefined = + valueIsDefined(nodeId) && valueIsDefined(pDiskId) && valueIsDefined(vDiskSlotId); + + return ( +
+ + + {VDISK_PAGE_TABS.map(({id, title}) => { + const path = vDiskParamsDefined + ? getVDiskPagePath({nodeId, pDiskId, vDiskSlotId}, {activeTab: id}) + : undefined; + return ( + + + {title} + + + ); + })} + + +
+ ); + }; + + const renderTabsContent = () => { + switch (vDiskTab) { + case 'storage': { + return renderStorageInfo(); + } + case 'tablets': { + return ( + + ); + } + default: + return null; + } + }; + const renderStorageInfo = () => { if (valueIsDefined(GroupID) && valueIsDefined(nodeId)) { return ( - -
{vDiskPageKeyset('storage')}
- -
+ ); } @@ -218,7 +290,8 @@ export function VDiskPage() { {error ? : null} {renderInfo()} - {renderStorageInfo()} + {renderTabs()} + {renderTabsContent()} ); }; diff --git a/src/containers/VDiskPage/VDiskTablets/VDiskTablets.scss b/src/containers/VDiskPage/VDiskTablets/VDiskTablets.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/containers/VDiskPage/VDiskTablets/VDiskTablets.tsx b/src/containers/VDiskPage/VDiskTablets/VDiskTablets.tsx new file mode 100644 index 0000000000..5905113bfc --- /dev/null +++ b/src/containers/VDiskPage/VDiskTablets/VDiskTablets.tsx @@ -0,0 +1,99 @@ +import React from 'react'; + +import DataTable from '@gravity-ui/react-data-table'; +import {skipToken} from '@reduxjs/toolkit/query'; + +import {PageError} from '../../../components/Errors/PageError/PageError'; +import {InfoViewerSkeleton} from '../../../components/InfoViewerSkeleton/InfoViewerSkeleton'; +import {ResizeableDataTable} from '../../../components/ResizeableDataTable/ResizeableDataTable'; +import {vDiskApi} from '../../../store/reducers/vdisk/vdisk'; +import type {VDiskBlobIndexItem} from '../../../types/api/vdiskBlobIndex'; +import {DEFAULT_TABLE_SETTINGS} from '../../../utils/constants'; +import {useAutoRefreshInterval} from '../../../utils/hooks'; +import {safeParseNumber} from '../../../utils/utils'; +import {vDiskPageKeyset} from '../i18n'; + +import {getColumns} from './columns'; + +const VDISK_TABLETS_COLUMNS_WIDTH_LS_KEY = 'vdiskTabletsColumnsWidth'; + +const columns = getColumns(); + +interface VDiskTabletsProps { + nodeId?: string | number; + pDiskId?: string | number; + vDiskSlotId?: string | number; + className?: string; +} + +export function VDiskTablets({nodeId, pDiskId, vDiskSlotId, className}: VDiskTabletsProps) { + const [autoRefreshInterval] = useAutoRefreshInterval(); + + const params = nodeId && pDiskId && vDiskSlotId ? {nodeId, pDiskId, vDiskSlotId} : skipToken; + + const {currentData, isFetching, error} = vDiskApi.useGetVDiskBlobIndexStatQuery(params, { + pollingInterval: autoRefreshInterval, + }); + + const loading = isFetching && currentData === undefined; + + const tableData: VDiskBlobIndexItem[] = React.useMemo(() => { + if (!currentData) { + return []; + } + + // Check if we have the expected structure: {stat: {tablets: [...]}} + const stat = currentData.stat; + if (!stat || !Array.isArray(stat.tablets)) { + return []; + } + + // Transform the nested structure into flat table rows + const flatData: VDiskBlobIndexItem[] = []; + + stat.tablets.forEach((tablet) => { + const tabletId = tablet.tablet_id; + if (!tabletId || !Array.isArray(tablet.channels)) { + return; // Skip tablets without ID or channels + } + + tablet.channels.forEach((channel, channelIndex) => { + // Only include channels that have count and data_size + if (channel.count && channel.data_size) { + flatData.push({ + TabletId: tabletId, + ChannelId: channelIndex, + Count: safeParseNumber(channel.count), + Size: safeParseNumber(channel.data_size), + }); + } + }); + }); + + return flatData; + }, [currentData]); + + if (error) { + return ; + } + + if (loading) { + return ; + } + + return ( +
+ +
+ ); +} diff --git a/src/containers/VDiskPage/VDiskTablets/columns.tsx b/src/containers/VDiskPage/VDiskTablets/columns.tsx new file mode 100644 index 0000000000..bbc3e2fb4a --- /dev/null +++ b/src/containers/VDiskPage/VDiskTablets/columns.tsx @@ -0,0 +1,65 @@ +import type {Column} from '@gravity-ui/react-data-table'; +import DataTable from '@gravity-ui/react-data-table'; +import {isNil} from 'lodash'; + +import {InternalLink} from '../../../components/InternalLink/InternalLink'; +import {getTabletPagePath} from '../../../routes'; +import type {VDiskBlobIndexItem} from '../../../types/api/vdiskBlobIndex'; +import {EMPTY_DATA_PLACEHOLDER} from '../../../utils/constants'; +import {formatBytes, formatNumber} from '../../../utils/dataFormatters/dataFormatters'; +import {safeParseNumber} from '../../../utils/utils'; + +import {COLUMNS_NAMES, COLUMNS_TITLES} from './constants'; + +export function getColumns(): Column[] { + return [ + { + name: COLUMNS_NAMES.TABLET_ID, + header: COLUMNS_TITLES[COLUMNS_NAMES.TABLET_ID], + render: ({row}) => { + const tabletId = row.TabletId; + if (!tabletId) { + return EMPTY_DATA_PLACEHOLDER; + } + return ( + {tabletId} + ); + }, + width: 220, + }, + { + name: COLUMNS_NAMES.CHANNEL_ID, + header: COLUMNS_TITLES[COLUMNS_NAMES.CHANNEL_ID], + align: DataTable.RIGHT, + render: ({row}) => row.ChannelId ?? EMPTY_DATA_PLACEHOLDER, + width: 130, + sortable: true, + }, + { + name: COLUMNS_NAMES.COUNT, + header: COLUMNS_TITLES[COLUMNS_NAMES.COUNT], + align: DataTable.RIGHT, + render: ({row}) => { + if (isNil(row.Count)) { + return EMPTY_DATA_PLACEHOLDER; + } + return formatNumber(row.Count); + }, + width: 100, + }, + { + name: COLUMNS_NAMES.SIZE, + header: COLUMNS_TITLES[COLUMNS_NAMES.SIZE], + align: DataTable.RIGHT, + render: ({row}) => { + const size = row.Size; + if (isNil(size)) { + return EMPTY_DATA_PLACEHOLDER; + } + const numericSize = safeParseNumber(size); + return formatBytes(numericSize); + }, + width: 120, + }, + ]; +} diff --git a/src/containers/VDiskPage/VDiskTablets/constants.ts b/src/containers/VDiskPage/VDiskTablets/constants.ts new file mode 100644 index 0000000000..605a91be35 --- /dev/null +++ b/src/containers/VDiskPage/VDiskTablets/constants.ts @@ -0,0 +1,15 @@ +import {vDiskPageKeyset} from '../i18n'; + +export const COLUMNS_NAMES = { + TABLET_ID: 'TabletId', + CHANNEL_ID: 'ChannelId', + COUNT: 'Count', + SIZE: 'Size', +} as const; + +export const COLUMNS_TITLES = { + [COLUMNS_NAMES.TABLET_ID]: vDiskPageKeyset('tablet-id'), + [COLUMNS_NAMES.CHANNEL_ID]: vDiskPageKeyset('channel-id'), + [COLUMNS_NAMES.COUNT]: vDiskPageKeyset('count'), + [COLUMNS_NAMES.SIZE]: vDiskPageKeyset('size'), +} as const; diff --git a/src/containers/VDiskPage/VDiskTablets/index.ts b/src/containers/VDiskPage/VDiskTablets/index.ts new file mode 100644 index 0000000000..9e3955d879 --- /dev/null +++ b/src/containers/VDiskPage/VDiskTablets/index.ts @@ -0,0 +1 @@ +export {VDiskTablets} from './VDiskTablets'; diff --git a/src/containers/VDiskPage/i18n/en.json b/src/containers/VDiskPage/i18n/en.json index fe10fe8f47..1348157388 100644 --- a/src/containers/VDiskPage/i18n/en.json +++ b/src/containers/VDiskPage/i18n/en.json @@ -4,6 +4,12 @@ "pdisk": "PDisk", "vdisk": "VDisk", "storage": "Storage", + "tablets": "Tablets", + + "tablet-id": "Tablet ID", + "channel-id": "Channel ID", + "count": "Count", + "size": "Size", "evict-vdisk-button": "Evict VDisk", "force-evict-vdisk-button": "Evict anyway", diff --git a/src/services/api/viewer.ts b/src/services/api/viewer.ts index 3ff38a1665..45c2c0745a 100644 --- a/src/services/api/viewer.ts +++ b/src/services/api/viewer.ts @@ -34,6 +34,7 @@ import type { import type {TTenantInfo, TTenants} from '../../types/api/tenant'; import type {DescribeTopicResult, TopicDataRequest, TopicDataResponse} from '../../types/api/topic'; import type {TEvVDiskStateResponse} from '../../types/api/vdisk'; +import type {VDiskBlobIndexResponse} from '../../types/api/vdiskBlobIndex'; import type {TUserToken} from '../../types/api/whoami'; import type {TabletsApiRequestParams} from '../../types/store/tablets'; import {BINARY_DATA_IN_PLAIN_TEXT_DISPLAY} from '../../utils/constants'; @@ -536,6 +537,29 @@ export class ViewerAPI extends BaseYdbAPI { ); } + getVDiskBlobIndexStat( + { + vDiskSlotId, + pDiskId, + nodeId, + }: { + vDiskSlotId: string | number; + pDiskId: string | number; + nodeId: string | number; + }, + {concurrentId, signal}: AxiosOptions = {}, + ) { + return this.get( + this.getPath('/vdisk/blobindexstat'), + { + node_id: nodeId, + pdisk_id: pDiskId, + vslot_id: vDiskSlotId, + }, + {concurrentId, requestConfig: {signal}}, + ); + } + getNodeWhiteboardPDiskInfo( {nodeId, pDiskId}: {nodeId: string | number; pDiskId: string | number}, {concurrentId, signal}: AxiosOptions = {}, diff --git a/src/store/reducers/api.ts b/src/store/reducers/api.ts index 441d28f22f..627c4d3e39 100644 --- a/src/store/reducers/api.ts +++ b/src/store/reducers/api.ts @@ -18,6 +18,7 @@ export const api = createApi({ 'Tablet', 'UserData', 'VDiskData', + 'VDiskBlobIndexStat', 'AccessRights', 'Backups', 'BackupsSchedule', diff --git a/src/store/reducers/vdisk/vdisk.ts b/src/store/reducers/vdisk/vdisk.ts index 4f5964a8c0..10e65703d2 100644 --- a/src/store/reducers/vdisk/vdisk.ts +++ b/src/store/reducers/vdisk/vdisk.ts @@ -33,6 +33,26 @@ export const vDiskApi = api.injectEndpoints({ }, ], }), + getVDiskBlobIndexStat: build.query({ + queryFn: async ({nodeId, pDiskId, vDiskSlotId}: VDiskDataRequestParams, {signal}) => { + try { + const response = await window.api.viewer.getVDiskBlobIndexStat( + {nodeId, pDiskId, vDiskSlotId}, + {signal}, + ); + return {data: response}; + } catch (error) { + return {error}; + } + }, + providesTags: (_result, _error, arg) => [ + 'All', + { + type: 'VDiskBlobIndexStat', + id: getVDiskSlotBasedId(arg.nodeId, arg.pDiskId, arg.vDiskSlotId), + }, + ], + }), }), overrideExisting: 'throw', }); diff --git a/src/types/api/vdiskBlobIndex.ts b/src/types/api/vdiskBlobIndex.ts new file mode 100644 index 0000000000..97941c18d1 --- /dev/null +++ b/src/types/api/vdiskBlobIndex.ts @@ -0,0 +1,69 @@ +/** + * VDisk Blob Index Statistics API types + * + * endpoint: /vdisk/blobindexstat + */ + +export interface VDiskBlobIndexItem { + /** Tablet ID */ + TabletId?: string | number; + /** Alternative field name for Tablet ID */ + tabletId?: string | number; + /** Channel ID */ + ChannelId?: number; + /** Alternative field name for Channel ID */ + channelId?: number; + /** Count */ + Count?: number; + /** Alternative field name for Count */ + count?: number; + /** Size in bytes */ + Size?: number | string; + /** Alternative field name for Size */ + size?: number | string; + /** Allow for other possible field names */ + [key: string]: any; +} + +export interface VDiskBlobIndexChannel { + /** Channel count */ + count?: string; + /** Channel data size */ + data_size?: string; + /** Channel minimum ID */ + min_id?: string; + /** Channel maximum ID */ + max_id?: string; +} + +export interface VDiskBlobIndexTablet { + /** Tablet identifier */ + tablet_id?: string; + /** Array of tablet channels */ + channels?: VDiskBlobIndexChannel[]; +} + +export interface VDiskBlobIndexStat { + /** Array of tablets */ + tablets?: VDiskBlobIndexTablet[]; + /** Array of channels */ + channels?: VDiskBlobIndexChannel[]; +} + +export interface VDiskBlobIndexResponse { + /** Response status */ + status?: string; + /** Statistics data */ + stat?: VDiskBlobIndexStat; + /** Response time */ + ResponseTime?: string; + /** Response duration */ + ResponseDuration?: number; + /** Alternative response structures for backward compatibility */ + BlobIndexStat?: VDiskBlobIndexItem[]; + blobIndexStat?: VDiskBlobIndexItem[]; + blobindexstat?: VDiskBlobIndexItem[]; + result?: VDiskBlobIndexItem[]; + data?: VDiskBlobIndexItem[]; + [key: string]: any; // Allow for other possible field names +}