Skip to content

Commit 4ca86da

Browse files
CopilotadameatRaubzeug
authored
feat(vdisk): add tablet usage statistics to vdisk page (#2577)
Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: adameat <[email protected]> Co-authored-by: Raubzeug <[email protected]> Co-authored-by: Elena Makarova <[email protected]>
1 parent ff85ffc commit 4ca86da

File tree

12 files changed

+394
-22
lines changed

12 files changed

+394
-22
lines changed

src/containers/VDiskPage/VDiskPage.scss

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
&__title,
1313
&__controls,
1414
&__info,
15-
&__storage-title {
15+
&__tabs {
1616
position: sticky;
1717
left: 0;
1818

@@ -29,8 +29,7 @@
2929
gap: var(--g-spacing-2);
3030
}
3131

32-
&__storage-title {
33-
margin-bottom: 0;
34-
@include mixins.header-1-typography();
32+
&__tablets-content {
33+
margin-top: var(--g-spacing-4);
3534
}
3635
}

src/containers/VDiskPage/VDiskPage.tsx

Lines changed: 91 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import React from 'react';
22

33
import {ArrowsOppositeToDots} from '@gravity-ui/icons';
4-
import {Icon} from '@gravity-ui/uikit';
4+
import {Icon, Tab, TabList, TabProvider} from '@gravity-ui/uikit';
55
import {skipToken} from '@reduxjs/toolkit/query';
66
import {Helmet} from 'react-helmet-async';
77
import {StringParam, useQueryParams} from 'use-query-params';
8+
import {z} from 'zod';
89

910
import {ButtonWithConfirmDialog} from '../../components/ButtonWithConfirmDialog/ButtonWithConfirmDialog';
1011
import {EntityPageTitle} from '../../components/EntityPageTitle/EntityPageTitle';
1112
import {ResponseError} from '../../components/Errors/ResponseError';
1213
import {InfoViewerSkeleton} from '../../components/InfoViewerSkeleton/InfoViewerSkeleton';
14+
import {InternalLink} from '../../components/InternalLink/InternalLink';
1315
import {PageMetaWithAutorefresh} from '../../components/PageMeta/PageMeta';
1416
import {VDiskInfo} from '../../components/VDiskInfo/VDiskInfo';
17+
import {getVDiskPagePath} from '../../routes';
1518
import {api} from '../../store/reducers/api';
1619
import {useDiskPagesAvailable} from '../../store/reducers/capabilities/hooks';
1720
import {setHeaderBreadcrumbs} from '../../store/reducers/header/header';
@@ -25,26 +28,52 @@ import {useAutoRefreshInterval, useTypedDispatch} from '../../utils/hooks';
2528
import {useIsUserAllowedToMakeChanges} from '../../utils/hooks/useIsUserAllowedToMakeChanges';
2629
import {PaginatedStorage} from '../Storage/PaginatedStorage';
2730

31+
import {VDiskTablets} from './VDiskTablets';
2832
import {vDiskPageKeyset} from './i18n';
2933

3034
import './VDiskPage.scss';
3135

3236
const vDiskPageCn = cn('ydb-vdisk-page');
3337

38+
const VDISK_TABS_IDS = {
39+
storage: 'storage',
40+
tablets: 'tablets',
41+
} as const;
42+
43+
const VDISK_PAGE_TABS = [
44+
{
45+
id: VDISK_TABS_IDS.storage,
46+
get title() {
47+
return vDiskPageKeyset('storage');
48+
},
49+
},
50+
{
51+
id: VDISK_TABS_IDS.tablets,
52+
get title() {
53+
return vDiskPageKeyset('tablets');
54+
},
55+
},
56+
];
57+
58+
const vDiskTabSchema = z.nativeEnum(VDISK_TABS_IDS).catch(VDISK_TABS_IDS.storage);
59+
3460
export function VDiskPage() {
3561
const dispatch = useTypedDispatch();
3662

3763
const containerRef = React.useRef<HTMLDivElement>(null);
3864
const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges();
3965
const newDiskApiAvailable = useDiskPagesAvailable();
4066

41-
const [{nodeId, pDiskId, vDiskSlotId, vDiskId: vDiskIdParam}] = useQueryParams({
67+
const [{nodeId, pDiskId, vDiskSlotId, vDiskId: vDiskIdParam, activeTab}] = useQueryParams({
4268
nodeId: StringParam,
4369
pDiskId: StringParam,
4470
vDiskSlotId: StringParam,
4571
vDiskId: StringParam,
72+
activeTab: StringParam,
4673
});
4774

75+
const vDiskTab = vDiskTabSchema.parse(activeTab);
76+
4877
React.useEffect(() => {
4978
dispatch(setHeaderBreadcrumbs('vDisk', {nodeId, pDiskId, vDiskSlotId}));
5079
}, [dispatch, nodeId, pDiskId, vDiskSlotId]);
@@ -185,24 +214,67 @@ export function VDiskPage() {
185214
return <VDiskInfo data={vDiskData} className={vDiskPageCn('info')} wrap />;
186215
};
187216

217+
const renderTabs = () => {
218+
const vDiskParamsDefined =
219+
valueIsDefined(nodeId) && valueIsDefined(pDiskId) && valueIsDefined(vDiskSlotId);
220+
221+
return (
222+
<div className={vDiskPageCn('tabs')}>
223+
<TabProvider value={vDiskTab}>
224+
<TabList size="l">
225+
{VDISK_PAGE_TABS.map(({id, title}) => {
226+
const path = vDiskParamsDefined
227+
? getVDiskPagePath({nodeId, pDiskId, vDiskSlotId}, {activeTab: id})
228+
: undefined;
229+
return (
230+
<Tab key={id} value={id}>
231+
<InternalLink as="tab" to={path}>
232+
{title}
233+
</InternalLink>
234+
</Tab>
235+
);
236+
})}
237+
</TabList>
238+
</TabProvider>
239+
</div>
240+
);
241+
};
242+
243+
const renderTabsContent = () => {
244+
switch (vDiskTab) {
245+
case 'storage': {
246+
return renderStorageInfo();
247+
}
248+
case 'tablets': {
249+
return (
250+
<VDiskTablets
251+
nodeId={nodeId ?? undefined}
252+
pDiskId={pDiskId ?? undefined}
253+
vDiskSlotId={vDiskSlotId ?? undefined}
254+
className={vDiskPageCn('tablets-content')}
255+
/>
256+
);
257+
}
258+
default:
259+
return null;
260+
}
261+
};
262+
188263
const renderStorageInfo = () => {
189264
if (valueIsDefined(GroupID) && valueIsDefined(nodeId)) {
190265
return (
191-
<React.Fragment>
192-
<div className={vDiskPageCn('storage-title')}>{vDiskPageKeyset('storage')}</div>
193-
<PaginatedStorage
194-
groupId={GroupID}
195-
nodeId={nodeId}
196-
pDiskId={pDiskId ?? undefined}
197-
scrollContainerRef={containerRef}
198-
viewContext={{
199-
groupId: GroupID?.toString(),
200-
nodeId: nodeId?.toString(),
201-
pDiskId: pDiskId?.toString(),
202-
vDiskSlotId: vDiskSlotId?.toString(),
203-
}}
204-
/>
205-
</React.Fragment>
266+
<PaginatedStorage
267+
groupId={GroupID}
268+
nodeId={nodeId}
269+
pDiskId={pDiskId ?? undefined}
270+
scrollContainerRef={containerRef}
271+
viewContext={{
272+
groupId: GroupID?.toString(),
273+
nodeId: nodeId?.toString(),
274+
pDiskId: pDiskId?.toString(),
275+
vDiskSlotId: vDiskSlotId?.toString(),
276+
}}
277+
/>
206278
);
207279
}
208280

@@ -218,7 +290,8 @@ export function VDiskPage() {
218290
<React.Fragment>
219291
{error ? <ResponseError error={error} /> : null}
220292
{renderInfo()}
221-
{renderStorageInfo()}
293+
{renderTabs()}
294+
{renderTabsContent()}
222295
</React.Fragment>
223296
);
224297
};

src/containers/VDiskPage/VDiskTablets/VDiskTablets.scss

Whitespace-only changes.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React from 'react';
2+
3+
import DataTable from '@gravity-ui/react-data-table';
4+
import {skipToken} from '@reduxjs/toolkit/query';
5+
6+
import {PageError} from '../../../components/Errors/PageError/PageError';
7+
import {InfoViewerSkeleton} from '../../../components/InfoViewerSkeleton/InfoViewerSkeleton';
8+
import {ResizeableDataTable} from '../../../components/ResizeableDataTable/ResizeableDataTable';
9+
import {vDiskApi} from '../../../store/reducers/vdisk/vdisk';
10+
import type {VDiskBlobIndexItem} from '../../../types/api/vdiskBlobIndex';
11+
import {DEFAULT_TABLE_SETTINGS} from '../../../utils/constants';
12+
import {useAutoRefreshInterval} from '../../../utils/hooks';
13+
import {safeParseNumber} from '../../../utils/utils';
14+
import {vDiskPageKeyset} from '../i18n';
15+
16+
import {getColumns} from './columns';
17+
18+
const VDISK_TABLETS_COLUMNS_WIDTH_LS_KEY = 'vdiskTabletsColumnsWidth';
19+
20+
const columns = getColumns();
21+
22+
interface VDiskTabletsProps {
23+
nodeId?: string | number;
24+
pDiskId?: string | number;
25+
vDiskSlotId?: string | number;
26+
className?: string;
27+
}
28+
29+
export function VDiskTablets({nodeId, pDiskId, vDiskSlotId, className}: VDiskTabletsProps) {
30+
const [autoRefreshInterval] = useAutoRefreshInterval();
31+
32+
const params = nodeId && pDiskId && vDiskSlotId ? {nodeId, pDiskId, vDiskSlotId} : skipToken;
33+
34+
const {currentData, isFetching, error} = vDiskApi.useGetVDiskBlobIndexStatQuery(params, {
35+
pollingInterval: autoRefreshInterval,
36+
});
37+
38+
const loading = isFetching && currentData === undefined;
39+
40+
const tableData: VDiskBlobIndexItem[] = React.useMemo(() => {
41+
if (!currentData) {
42+
return [];
43+
}
44+
45+
// Check if we have the expected structure: {stat: {tablets: [...]}}
46+
const stat = currentData.stat;
47+
if (!stat || !Array.isArray(stat.tablets)) {
48+
return [];
49+
}
50+
51+
// Transform the nested structure into flat table rows
52+
const flatData: VDiskBlobIndexItem[] = [];
53+
54+
stat.tablets.forEach((tablet) => {
55+
const tabletId = tablet.tablet_id;
56+
if (!tabletId || !Array.isArray(tablet.channels)) {
57+
return; // Skip tablets without ID or channels
58+
}
59+
60+
tablet.channels.forEach((channel, channelIndex) => {
61+
// Only include channels that have count and data_size
62+
if (channel.count && channel.data_size) {
63+
flatData.push({
64+
TabletId: tabletId,
65+
ChannelId: channelIndex,
66+
Count: safeParseNumber(channel.count),
67+
Size: safeParseNumber(channel.data_size),
68+
});
69+
}
70+
});
71+
});
72+
73+
return flatData;
74+
}, [currentData]);
75+
76+
if (error) {
77+
return <PageError error={error} position="left" size="s" />;
78+
}
79+
80+
if (loading) {
81+
return <InfoViewerSkeleton rows={5} />;
82+
}
83+
84+
return (
85+
<div className={className}>
86+
<ResizeableDataTable
87+
columnsWidthLSKey={VDISK_TABLETS_COLUMNS_WIDTH_LS_KEY}
88+
data={tableData}
89+
columns={columns}
90+
settings={DEFAULT_TABLE_SETTINGS}
91+
loading={loading}
92+
initialSortOrder={{
93+
columnId: vDiskPageKeyset('size'),
94+
order: DataTable.DESCENDING,
95+
}}
96+
/>
97+
</div>
98+
);
99+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type {Column} from '@gravity-ui/react-data-table';
2+
import DataTable from '@gravity-ui/react-data-table';
3+
import {isNil} from 'lodash';
4+
5+
import {InternalLink} from '../../../components/InternalLink/InternalLink';
6+
import {getTabletPagePath} from '../../../routes';
7+
import type {VDiskBlobIndexItem} from '../../../types/api/vdiskBlobIndex';
8+
import {EMPTY_DATA_PLACEHOLDER} from '../../../utils/constants';
9+
import {formatBytes, formatNumber} from '../../../utils/dataFormatters/dataFormatters';
10+
import {safeParseNumber} from '../../../utils/utils';
11+
12+
import {COLUMNS_NAMES, COLUMNS_TITLES} from './constants';
13+
14+
export function getColumns(): Column<VDiskBlobIndexItem>[] {
15+
return [
16+
{
17+
name: COLUMNS_NAMES.TABLET_ID,
18+
header: COLUMNS_TITLES[COLUMNS_NAMES.TABLET_ID],
19+
render: ({row}) => {
20+
const tabletId = row.TabletId;
21+
if (!tabletId) {
22+
return EMPTY_DATA_PLACEHOLDER;
23+
}
24+
return (
25+
<InternalLink to={getTabletPagePath(String(tabletId))}>{tabletId}</InternalLink>
26+
);
27+
},
28+
width: 220,
29+
},
30+
{
31+
name: COLUMNS_NAMES.CHANNEL_ID,
32+
header: COLUMNS_TITLES[COLUMNS_NAMES.CHANNEL_ID],
33+
align: DataTable.RIGHT,
34+
render: ({row}) => row.ChannelId ?? EMPTY_DATA_PLACEHOLDER,
35+
width: 130,
36+
sortable: true,
37+
},
38+
{
39+
name: COLUMNS_NAMES.COUNT,
40+
header: COLUMNS_TITLES[COLUMNS_NAMES.COUNT],
41+
align: DataTable.RIGHT,
42+
render: ({row}) => {
43+
if (isNil(row.Count)) {
44+
return EMPTY_DATA_PLACEHOLDER;
45+
}
46+
return formatNumber(row.Count);
47+
},
48+
width: 100,
49+
},
50+
{
51+
name: COLUMNS_NAMES.SIZE,
52+
header: COLUMNS_TITLES[COLUMNS_NAMES.SIZE],
53+
align: DataTable.RIGHT,
54+
render: ({row}) => {
55+
const size = row.Size;
56+
if (isNil(size)) {
57+
return EMPTY_DATA_PLACEHOLDER;
58+
}
59+
const numericSize = safeParseNumber(size);
60+
return formatBytes(numericSize);
61+
},
62+
width: 120,
63+
},
64+
];
65+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {vDiskPageKeyset} from '../i18n';
2+
3+
export const COLUMNS_NAMES = {
4+
TABLET_ID: 'TabletId',
5+
CHANNEL_ID: 'ChannelId',
6+
COUNT: 'Count',
7+
SIZE: 'Size',
8+
} as const;
9+
10+
export const COLUMNS_TITLES = {
11+
[COLUMNS_NAMES.TABLET_ID]: vDiskPageKeyset('tablet-id'),
12+
[COLUMNS_NAMES.CHANNEL_ID]: vDiskPageKeyset('channel-id'),
13+
[COLUMNS_NAMES.COUNT]: vDiskPageKeyset('count'),
14+
[COLUMNS_NAMES.SIZE]: vDiskPageKeyset('size'),
15+
} as const;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {VDiskTablets} from './VDiskTablets';

src/containers/VDiskPage/i18n/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
"pdisk": "PDisk",
55
"vdisk": "VDisk",
66
"storage": "Storage",
7+
"tablets": "Tablets",
8+
9+
"tablet-id": "Tablet ID",
10+
"channel-id": "Channel ID",
11+
"count": "Count",
12+
"size": "Size",
713

814
"evict-vdisk-button": "Evict VDisk",
915
"force-evict-vdisk-button": "Evict anyway",

0 commit comments

Comments
 (0)