Skip to content

Commit dabf732

Browse files
committed
feat(FR-2691): relocate /data header panels — Dashboard + host-capacity cell
Resolves #6937(FR-2691) Removes the three header panels from the /data VFolder list page (Create Folder action card, StorageStatusPanelCard, QuotaPerStorageVolumePanelCard) and redistributes their responsibilities: - Folder-status counts move to the Dashboard as a new `folderStatus` board item. StorageStatusPanelCard is unchanged functionally; a `TODO(FR-2691 v2-migration)` marker is added alongside its two legacy query surfaces (baiClient.vfolder.list REST call and the V1 user_resource_policy / project_resource_policy GraphQL fields) so a follow-up task can move them to myVfolders / projectVfolders V2 connections with server-side filter+count. - Per-volume quota visibility moves into the folder table's Location column via a new VFolderHostCell: the host name is now clickable and carries a StorageUsageBadge sourced from the shared vhostInfo tan-query cache key used by StorageSelect. Clicking opens QuotaPerStorageVolumePanelCard in a modal, pre-seeded on the clicked host through a new `defaultVolumeInfo` prop (auto-select is disabled when a default is passed; users can still switch volumes via the inline StorageSelect). The Create Folder affordance is retained through the existing primary button inside the folder table card header, so no replacement for the removed action card is needed. The cell suspense is scoped per-row so a cold hosts cache does not suspend the whole page — each row falls back to a plain host label until the shared payload resolves, after which the badge and click handler light up.
1 parent ac4de81 commit dabf732

28 files changed

Lines changed: 680 additions & 396 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
@license
3+
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
4+
*/
5+
import { useSuspendedBackendaiClient } from '../hooks';
6+
import { useSuspenseTanQuery } from '../hooks/reactQueryAlias';
7+
import QuotaPerStorageVolumePanelCard, {
8+
type VolumeInfo,
9+
} from './QuotaPerStorageVolumePanelCard';
10+
import { Empty, theme } from 'antd';
11+
import { BAIBoardItemTitle, BAIFlex } from 'backend.ai-ui';
12+
import * as _ from 'lodash-es';
13+
import React from 'react';
14+
import { useTranslation } from 'react-i18next';
15+
16+
// Dashboard wrapper around `QuotaPerStorageVolumePanelCard`. Resolves the
17+
// first quota-supporting host up-front so the panel opens on a meaningful
18+
// scope instead of the inner card's "does not support" state. Renders an
19+
// `Empty` placeholder when no host advertises `quota` capability — the
20+
// dashboard slot is always shown (unlike the row-level modal trigger that
21+
// hides itself entirely when no quota host exists).
22+
const QuotaPerStorageVolumeDashboardItem: React.FC = () => {
23+
'use memo';
24+
const { t } = useTranslation();
25+
const { token } = theme.useToken();
26+
const baiClient = useSuspendedBackendaiClient();
27+
28+
const { data: vhostInfo } = useSuspenseTanQuery<{
29+
volume_info?: Record<string, Omit<VolumeInfo, 'id'>>;
30+
} | null>({
31+
queryKey: ['vhostInfo'],
32+
queryFn: () => baiClient.vfolder.list_hosts(),
33+
});
34+
35+
const quotaSupportedEntry = _.find(
36+
_.entries(vhostInfo?.volume_info ?? {}),
37+
([, info]) => _.includes(info?.capabilities, 'quota'),
38+
);
39+
const defaultVolumeInfo: VolumeInfo | undefined = quotaSupportedEntry
40+
? { id: quotaSupportedEntry[0], ...quotaSupportedEntry[1] }
41+
: undefined;
42+
43+
return (
44+
<BAIFlex
45+
direction="column"
46+
align="stretch"
47+
style={{
48+
paddingInline: token.paddingXL,
49+
paddingBottom: token.padding,
50+
}}
51+
>
52+
<BAIBoardItemTitle title={t('data.QuotaPerStorageVolume')} />
53+
{defaultVolumeInfo ? (
54+
<QuotaPerStorageVolumePanelCard defaultVolumeInfo={defaultVolumeInfo} />
55+
) : (
56+
<Empty
57+
image={Empty.PRESENTED_IMAGE_SIMPLE}
58+
description={t('storageHost.QuotaDoesNotSupported')}
59+
/>
60+
)}
61+
</BAIFlex>
62+
);
63+
};
64+
65+
export default QuotaPerStorageVolumeDashboardItem;

react/src/components/QuotaPerStorageVolumePanelCard.tsx

Lines changed: 148 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,51 @@ import { addQuotaScopeTypePrefix, convertToDecimalUnit } from '../helper';
88
import { useCurrentDomainValue, useSuspendedBackendaiClient } from '../hooks';
99
import { useCurrentProjectValue } from '../hooks/useCurrentProject';
1010
import BAIProgress from './BAIProgress';
11-
import FlexActivityIndicator from './FlexActivityIndicator';
1211
import StorageSelect from './StorageSelect';
13-
import { QuestionCircleOutlined } from '@ant-design/icons';
14-
import { Col, Empty, Row, theme, Tooltip, Typography } from 'antd';
15-
import { BAICard, BAICardProps, BAIFlex } from 'backend.ai-ui';
12+
import { Col, Empty, Row, Skeleton, theme, Typography } from 'antd';
13+
import { BAIFlex } from 'backend.ai-ui';
1614
import * as _ from 'lodash-es';
17-
import React, { useDeferredValue, useState } from 'react';
15+
import React, { Suspense, useState } from 'react';
1816
import { useTranslation } from 'react-i18next';
1917
import { graphql, useLazyLoadQuery } from 'react-relay';
2018

2119
export type VolumeInfo = {
2220
id: string;
2321
backend: string;
2422
capabilities: string[];
25-
usage: {
26-
percentage: number;
23+
// `usage` is optional because `vfolder.list_hosts()` only attaches it for
24+
// hosts that can report capacity; `usage.percentage` is optional because
25+
// even a reporting host may omit the percentage (rendered as "Unknown").
26+
usage?: {
27+
percentage?: number;
2728
};
2829
sftp_scaling_groups: string[];
2930
};
3031

31-
interface QuotaPerStorageVolumePanelCardProps extends BAICardProps {}
32+
interface QuotaPerStorageVolumePanelCardProps {
33+
/**
34+
* Pre-selects a volume so the content renders that host's quota immediately
35+
* (e.g. when opened from a specific folder row). When provided, the built-in
36+
* usage-based auto-select is disabled; users can still switch volumes via
37+
* the inline `StorageSelect`.
38+
*/
39+
defaultVolumeInfo?: VolumeInfo;
40+
}
3241

33-
const QuotaPerStorageVolumePanelCard: React.FC<
34-
QuotaPerStorageVolumePanelCardProps
35-
> = ({ ...baiCardProps }) => {
42+
interface QuotaScopeContentProps {
43+
selectedVolumeInfo: VolumeInfo | undefined;
44+
}
45+
46+
// Body of the panel: fetches and renders project / user quota scope for the
47+
// selected volume. Wrapped in a Suspense boundary by the parent so switching
48+
// to an uncached host shows a loading indicator while in flight, while cache
49+
// hits commit synchronously without any spinner flash.
50+
const QuotaScopeContent: React.FC<QuotaScopeContentProps> = ({
51+
selectedVolumeInfo,
52+
}) => {
53+
'use memo';
3654
const { t } = useTranslation();
3755
const { token } = theme.useToken();
38-
const [selectedVolumeInfo, setSelectedVolumeInfo] = useState<VolumeInfo>();
39-
const deferredSelectedVolumeInfo = useDeferredValue(selectedVolumeInfo);
4056
const currentProject = useCurrentProjectValue();
4157
const baiClient = useSuspendedBackendaiClient();
4258

@@ -92,14 +108,24 @@ const QuotaPerStorageVolumePanelCard: React.FC<
92108
currentProject?.id || '',
93109
),
94110
user_quota_scope_id: addQuotaScopeTypePrefix('user', user?.id || ''),
95-
storage_host_name: deferredSelectedVolumeInfo?.id || '',
111+
storage_host_name: selectedVolumeInfo?.id || '',
96112
skipQuotaScope:
97113
currentProject?.id === undefined ||
98114
user?.id === undefined ||
99-
!deferredSelectedVolumeInfo?.id,
115+
!selectedVolumeInfo?.id,
100116
},
101117
);
102118

119+
if (!selectedVolumeInfo?.capabilities?.includes('quota')) {
120+
return (
121+
<Empty
122+
image={Empty.PRESENTED_IMAGE_SIMPLE}
123+
description={t('storageHost.QuotaDoesNotSupported')}
124+
style={{ margin: 'auto 25px' }}
125+
/>
126+
);
127+
}
128+
103129
const projectUsageBytes = _.toFinite(
104130
project_quota_scope?.details?.usage_bytes,
105131
);
@@ -121,118 +147,114 @@ const QuotaPerStorageVolumePanelCard: React.FC<
121147
: 0;
122148

123149
return (
124-
<BAICard
125-
{...baiCardProps}
126-
title={
127-
<BAIFlex gap={'xs'} align="center">
128-
{t('data.QuotaPerStorageVolume')}
129-
<Tooltip title={t('data.HostDetails')}>
130-
<QuestionCircleOutlined
131-
style={{ color: token.colorTextDescription }}
132-
/>
133-
</Tooltip>
134-
</BAIFlex>
135-
}
136-
extra={
137-
<BAIFlex
138-
style={{
139-
marginRight: -8,
140-
}}
141-
>
142-
<StorageSelect
143-
value={selectedVolumeInfo?.id}
144-
onChange={(__, vInfo) => {
145-
setSelectedVolumeInfo(vInfo);
146-
}}
147-
autoSelectType="usage"
148-
showUsageStatus
149-
showSearch
150-
variant="borderless"
151-
/>
152-
</BAIFlex>
153-
}
154-
styles={{
155-
body: {
156-
paddingTop: token.paddingLG,
157-
},
158-
}}
159-
>
160-
{selectedVolumeInfo !== deferredSelectedVolumeInfo ? (
161-
<FlexActivityIndicator style={{ minHeight: 120 }} />
162-
) : selectedVolumeInfo?.capabilities?.includes('quota') ? (
163-
<Row gutter={[24, 16]}>
164-
<Col
165-
span={12}
166-
style={{
167-
borderRight: `1px solid ${token.colorBorderSecondary}`,
168-
}}
169-
>
170-
<BAIProgress
171-
title={
172-
<BAIFlex direction="column" align="start">
173-
<Typography.Text
174-
type="secondary"
175-
style={{ fontSize: token.fontSizeSM }}
176-
>
177-
{t('data.Project')}
178-
</Typography.Text>
179-
<Typography.Text style={{ fontSize: token.fontSize }}>
180-
{currentProject?.name}
181-
</Typography.Text>
182-
</BAIFlex>
183-
}
184-
percent={projectPercent}
185-
used={
186-
projectUsageBytes === 0
187-
? ''
188-
: `${convertToDecimalUnit(_.toString(projectUsageBytes), 'g')?.displayValue}`
189-
}
190-
total={
191-
projectHardLimitBytes === 0
192-
? ''
193-
: `${convertToDecimalUnit(_.toString(projectHardLimitBytes), 'g')?.displayValue}`
194-
}
195-
/>
196-
</Col>
197-
<Col span={12}>
198-
<BAIProgress
199-
percent={userPercent}
200-
title={
201-
<BAIFlex direction="column" align="start">
202-
<Typography.Text
203-
type="secondary"
204-
style={{ fontSize: token.fontSizeSM }}
205-
>
206-
{t('data.User')}
207-
</Typography.Text>
208-
<Typography.Text style={{ fontSize: token.fontSize }}>
209-
{baiClient?.full_name}
210-
</Typography.Text>
211-
</BAIFlex>
212-
}
213-
used={
214-
userUsageBytes === 0
215-
? ''
216-
: convertToDecimalUnit(_.toString(userUsageBytes), 'auto')
217-
?.displayValue
218-
}
219-
total={
220-
userHardLimitBytes === 0
221-
? ''
222-
: convertToDecimalUnit(_.toString(userHardLimitBytes), 'auto')
223-
?.displayValue
224-
}
225-
/>
226-
</Col>
227-
</Row>
228-
) : (
229-
<Empty
230-
image={Empty.PRESENTED_IMAGE_SIMPLE}
231-
description={t('storageHost.QuotaDoesNotSupported')}
232-
style={{ margin: 'auto 25px' }}
150+
<Row gutter={[24, 16]}>
151+
<Col
152+
span={12}
153+
style={{
154+
borderRight: `1px solid ${token.colorBorderSecondary}`,
155+
}}
156+
>
157+
<BAIProgress
158+
title={
159+
<BAIFlex direction="column" align="start">
160+
<Typography.Text
161+
type="secondary"
162+
style={{ fontSize: token.fontSizeSM }}
163+
>
164+
{t('data.Project')}
165+
</Typography.Text>
166+
<Typography.Text style={{ fontSize: token.fontSize }}>
167+
{currentProject?.name}
168+
</Typography.Text>
169+
</BAIFlex>
170+
}
171+
percent={projectPercent}
172+
used={
173+
projectUsageBytes === 0
174+
? ''
175+
: `${convertToDecimalUnit(_.toString(projectUsageBytes), 'g')?.displayValue}`
176+
}
177+
total={
178+
projectHardLimitBytes === 0
179+
? ''
180+
: `${convertToDecimalUnit(_.toString(projectHardLimitBytes), 'g')?.displayValue}`
181+
}
182+
/>
183+
</Col>
184+
<Col span={12}>
185+
<BAIProgress
186+
percent={userPercent}
187+
title={
188+
<BAIFlex direction="column" align="start">
189+
<Typography.Text
190+
type="secondary"
191+
style={{ fontSize: token.fontSizeSM }}
192+
>
193+
{t('data.User')}
194+
</Typography.Text>
195+
<Typography.Text style={{ fontSize: token.fontSize }}>
196+
{baiClient?.full_name}
197+
</Typography.Text>
198+
</BAIFlex>
199+
}
200+
used={
201+
userUsageBytes === 0
202+
? ''
203+
: convertToDecimalUnit(_.toString(userUsageBytes), 'auto')
204+
?.displayValue
205+
}
206+
total={
207+
userHardLimitBytes === 0
208+
? ''
209+
: convertToDecimalUnit(_.toString(userHardLimitBytes), 'auto')
210+
?.displayValue
211+
}
233212
/>
234-
)}
235-
</BAICard>
213+
</Col>
214+
</Row>
215+
);
216+
};
217+
218+
// Modal-body view for per-volume quota. Intentionally not wrapped in a BAICard
219+
// — the consuming Modal provides its own title and chrome, so a nested card
220+
// would duplicate the header and inflate the modal visually.
221+
const QuotaPerStorageVolumePanelCard: React.FC<
222+
QuotaPerStorageVolumePanelCardProps
223+
> = ({ defaultVolumeInfo }) => {
224+
'use memo';
225+
const [selectedVolumeInfo, setSelectedVolumeInfo] = useState<
226+
VolumeInfo | undefined
227+
>(defaultVolumeInfo);
228+
// Reset the inline selection when the consumer passes a different
229+
// `defaultVolumeInfo` while the panel stays mounted (e.g., reopened for a
230+
// different host). Compare ids only — following the
231+
// "storing info from previous renders" pattern
232+
// (https://react.dev/reference/react/useState#storing-information-from-previous-renders),
233+
// so the badge reflects the latest prop without an effect.
234+
const [prevDefaultVolumeId, setPrevDefaultVolumeId] = useState(
235+
defaultVolumeInfo?.id,
236+
);
237+
if (prevDefaultVolumeId !== defaultVolumeInfo?.id) {
238+
setPrevDefaultVolumeId(defaultVolumeInfo?.id);
239+
setSelectedVolumeInfo(defaultVolumeInfo);
240+
}
241+
242+
return (
243+
<BAIFlex direction="column" align="stretch" gap={'md'}>
244+
<StorageSelect
245+
value={selectedVolumeInfo?.id}
246+
onChange={(__, vInfo) => {
247+
setSelectedVolumeInfo(vInfo);
248+
}}
249+
autoSelectType={defaultVolumeInfo ? undefined : 'usage'}
250+
showUsageStatus
251+
showSearch
252+
style={{ alignSelf: 'flex-start', minWidth: 240 }}
253+
/>
254+
<Suspense fallback={<Skeleton active paragraph={{ rows: 0 }} />}>
255+
<QuotaScopeContent selectedVolumeInfo={selectedVolumeInfo} />
256+
</Suspense>
257+
</BAIFlex>
236258
);
237259
};
238260

0 commit comments

Comments
 (0)