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
+}