Skip to content

Commit fbabca7

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 8d4bf6b commit fbabca7

5 files changed

Lines changed: 228 additions & 195 deletions

File tree

react/src/components/QuotaPerStorageVolumePanelCard.tsx

Lines changed: 30 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ import { useCurrentProjectValue } from '../hooks/useCurrentProject';
1010
import BAIProgress from './BAIProgress';
1111
import FlexActivityIndicator from './FlexActivityIndicator';
1212
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';
13+
import { Col, Empty, Row, theme, Typography } from 'antd';
14+
import { BAIFlex } from 'backend.ai-ui';
1615
import * as _ from 'lodash-es';
1716
import React, { useDeferredValue, useState } from 'react';
1817
import { useTranslation } from 'react-i18next';
@@ -28,14 +27,27 @@ export type VolumeInfo = {
2827
sftp_scaling_groups: string[];
2928
};
3029

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

40+
// Modal-body view for per-volume quota. Intentionally not wrapped in a BAICard
41+
// — the consuming Modal provides its own title and chrome, so a nested card
42+
// would duplicate the header and inflate the modal visually.
3343
const QuotaPerStorageVolumePanelCard: React.FC<
3444
QuotaPerStorageVolumePanelCardProps
35-
> = ({ ...baiCardProps }) => {
45+
> = ({ defaultVolumeInfo }) => {
3646
const { t } = useTranslation();
3747
const { token } = theme.useToken();
38-
const [selectedVolumeInfo, setSelectedVolumeInfo] = useState<VolumeInfo>();
48+
const [selectedVolumeInfo, setSelectedVolumeInfo] = useState<
49+
VolumeInfo | undefined
50+
>(defaultVolumeInfo);
3951
const deferredSelectedVolumeInfo = useDeferredValue(selectedVolumeInfo);
4052
const currentProject = useCurrentProjectValue();
4153
const baiClient = useSuspendedBackendaiClient();
@@ -121,42 +133,17 @@ const QuotaPerStorageVolumePanelCard: React.FC<
121133
: 0;
122134

123135
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-
>
136+
<BAIFlex direction="column" align="stretch" gap={'md'}>
137+
<StorageSelect
138+
value={selectedVolumeInfo?.id}
139+
onChange={(__, vInfo) => {
140+
setSelectedVolumeInfo(vInfo);
141+
}}
142+
autoSelectType={defaultVolumeInfo ? undefined : 'usage'}
143+
showUsageStatus
144+
showSearch
145+
style={{ alignSelf: 'flex-start', minWidth: 240 }}
146+
/>
160147
{selectedVolumeInfo !== deferredSelectedVolumeInfo ? (
161148
<FlexActivityIndicator style={{ minHeight: 120 }} />
162149
) : selectedVolumeInfo?.capabilities?.includes('quota') ? (
@@ -232,7 +219,7 @@ const QuotaPerStorageVolumePanelCard: React.FC<
232219
style={{ margin: 'auto 25px' }}
233220
/>
234221
)}
235-
</BAICard>
222+
</BAIFlex>
236223
);
237224
};
238225

react/src/components/StorageStatusPanelCard.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ const StorageStatusPanelCard: React.FC<StorageStatusPanelProps> = ({
6969
);
7070
};
7171

72+
// TODO(FR-2691 v2-migration): the counts below are derived from the legacy
73+
// REST call `baiClient.vfolder.list()` and then filtered in JS. Migrate to
74+
// the Strawberry V2 GraphQL `myVfolders` / `projectVfolders` connections
75+
// (with `filter` + `count`) so the server returns the three counts directly.
7276
const { data: vfolders } = useSuspenseTanQuery({
7377
queryKey: ['vfolders', { deferredFetchKey, id: currentProject.id }],
7478
queryFn: () => {
@@ -97,6 +101,9 @@ const StorageStatusPanelCard: React.FC<StorageStatusPanelProps> = ({
97101
!isExcludedCount(item.status),
98102
).length;
99103

104+
// TODO(FR-2691 v2-migration): `user_resource_policy` and
105+
// `project_resource_policy` are legacy (V1) root fields. Port this to the V2
106+
// schema once it exposes per-user / per-project vfolder count limits.
100107
const { user_resource_policy, project_resource_policy } =
101108
useLazyLoadQuery<StorageStatusPanelCardQuery>(
102109
graphql`

react/src/components/VFolderNodesV2.tsx

Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,26 @@ import {
99
} from '../__generated__/VFolderNodesV2Fragment.graphql';
1010
import { VFolderNodesV2PurgeMutation } from '../__generated__/VFolderNodesV2PurgeMutation.graphql';
1111
import { VFolderNodesV2RestoreMutation } from '../__generated__/VFolderNodesV2RestoreMutation.graphql';
12-
import { useWebUINavigate } from '../hooks';
12+
import { useSuspendedBackendaiClient, useWebUINavigate } from '../hooks';
1313
import { useCurrentUserInfo } from '../hooks/backendai';
14+
import { useSuspenseTanQuery } from '../hooks/reactQueryAlias';
1415
import { useSetBAINotification } from '../hooks/useBAINotification';
1516
import { isDeletedCategory } from '../pages/VFolderNodeListPage';
1617
import { useFolderExplorerOpener } from './FolderExplorerOpener';
1718
import InviteFolderSettingModal from './InviteFolderSettingModal';
19+
import QuotaPerStorageVolumePanelCard, {
20+
type VolumeInfo,
21+
} from './QuotaPerStorageVolumePanelCard';
1822
import SharedFolderPermissionInfoModal from './SharedFolderPermissionInfoModal';
1923
import VFolderDeployModal from './VFolderDeployModal';
2024
import VFolderNodeIdenticon from './VFolderNodeIdenticon';
2125
import VFolderPermissionCell from './VFolderPermissionCell';
22-
import { UserOutlined } from '@ant-design/icons';
23-
import { Alert, App, theme, Typography } from 'antd';
26+
import { QuestionCircleOutlined, UserOutlined } from '@ant-design/icons';
27+
import { useToggle } from 'ahooks';
28+
import { Alert, App, Modal, Skeleton, theme, Tooltip, Typography } from 'antd';
2429
import {
2530
filterOutNullAndUndefined,
31+
BAIAlertIconWithTooltip,
2632
BAIEndpointsIcon,
2733
BAIRestoreIcon,
2834
BAIShareAltIcon,
@@ -39,11 +45,12 @@ import {
3945
BAIConfirmModalWithInput,
4046
BAITag,
4147
bytesToGB,
48+
StorageUsageBadge,
4249
} from 'backend.ai-ui';
4350
import type { BAINameActionCellAction } from 'backend.ai-ui';
4451
import dayjs from 'dayjs';
4552
import * as _ from 'lodash-es';
46-
import React, { useState } from 'react';
53+
import React, { Suspense, useState } from 'react';
4754
import { useTranslation } from 'react-i18next';
4855
import { graphql, useFragment, useMutation } from 'react-relay';
4956

@@ -204,6 +211,138 @@ const VFolderNameCell: React.FC<VFolderNameCellProps> = ({
204211
);
205212
};
206213

214+
interface VFolderHostCellProps {
215+
host?: string | null;
216+
}
217+
218+
// Renders a Location column cell: usage badge (if the backend reports one)
219+
// plus the host name. The badge's data comes from `vfolder.list_hosts()`,
220+
// cached under the same tan-query key that `StorageSelect` uses, so the
221+
// payload is fetched once and shared across rows + selects.
222+
const VFolderHostCellInner: React.FC<{ host: string }> = ({ host }) => {
223+
'use memo';
224+
const { t } = useTranslation();
225+
const baiClient = useSuspendedBackendaiClient();
226+
227+
const { data: vhostInfo } = useSuspenseTanQuery<{
228+
volume_info?: Record<string, Omit<VolumeInfo, 'id'>>;
229+
} | null>({
230+
queryKey: ['vhostInfo'],
231+
queryFn: () => baiClient.vfolder.list_hosts(),
232+
});
233+
234+
const volumeInfo = vhostInfo?.volume_info?.[host];
235+
const usagePercent = volumeInfo?.usage?.percentage;
236+
const usageLabel =
237+
usagePercent === undefined
238+
? undefined
239+
: usagePercent < 70
240+
? t('data.usage.Adequate')
241+
: usagePercent < 90
242+
? t('data.usage.Caution')
243+
: t('data.usage.Insufficient');
244+
245+
return (
246+
<BAIFlex gap={'xs'} align="center">
247+
{usagePercent !== undefined ? (
248+
<Tooltip
249+
title={`${t('data.Host')} ${t('data.usage.Status')}: ${usageLabel ?? ''}`}
250+
>
251+
<StorageUsageBadge percent={usagePercent} />
252+
</Tooltip>
253+
) : null}
254+
<Typography.Text>{host}</Typography.Text>
255+
</BAIFlex>
256+
);
257+
};
258+
259+
const VFolderHostCell: React.FC<VFolderHostCellProps> = ({ host }) => {
260+
'use memo';
261+
if (!host) return null;
262+
// Wrap in Suspense so the first render after a cache miss falls back to a
263+
// plain host label for this cell only, instead of suspending the whole
264+
// page while `vfolder.list_hosts()` resolves.
265+
return (
266+
<Suspense fallback={<Typography.Text>{host}</Typography.Text>}>
267+
<VFolderHostCellInner host={host} />
268+
</Suspense>
269+
);
270+
};
271+
272+
// Column-header affordance that opens `QuotaPerStorageVolumePanelCard` in a
273+
// modal, pre-selected on a quota-supporting host so the empty "does not
274+
// support" state is not the first thing users see. Renders nothing when no
275+
// volume reports `quota` in its capabilities — removing the modal entry
276+
// point entirely rather than opening a dialog with no useful content.
277+
const HostQuotaTriggerInner: React.FC = () => {
278+
'use memo';
279+
const { t } = useTranslation();
280+
const { token } = theme.useToken();
281+
const baiClient = useSuspendedBackendaiClient();
282+
const [isOpenModal, { toggle: toggleModal }] = useToggle(false);
283+
284+
const { data: vhostInfo } = useSuspenseTanQuery<{
285+
volume_info?: Record<string, Omit<VolumeInfo, 'id'>>;
286+
} | null>({
287+
queryKey: ['vhostInfo'],
288+
queryFn: () => baiClient.vfolder.list_hosts(),
289+
});
290+
291+
const quotaSupportedEntry = _.find(
292+
_.entries(vhostInfo?.volume_info ?? {}),
293+
([, info]) => _.includes(info?.capabilities, 'quota'),
294+
);
295+
if (!quotaSupportedEntry) return null;
296+
297+
const [host, info] = quotaSupportedEntry;
298+
const defaultVolumeInfo: VolumeInfo = { id: host, ...info };
299+
300+
return (
301+
<>
302+
<BAIAlertIconWithTooltip
303+
title={t('data.QuotaPerStorageVolume')}
304+
iconProps={{
305+
size: 14,
306+
onClick: (e) => {
307+
e.stopPropagation();
308+
toggleModal();
309+
},
310+
style: { cursor: 'pointer' },
311+
}}
312+
/>
313+
<Modal
314+
open={isOpenModal}
315+
onCancel={toggleModal}
316+
footer={null}
317+
title={
318+
<BAIFlex gap={'xs'} align="center">
319+
{t('data.QuotaPerStorageVolume')}
320+
<Tooltip title={t('data.HostDetails')}>
321+
<QuestionCircleOutlined
322+
style={{ color: token.colorTextDescription }}
323+
/>
324+
</Tooltip>
325+
</BAIFlex>
326+
}
327+
width={640}
328+
destroyOnHidden
329+
>
330+
<Suspense fallback={<Skeleton active />}>
331+
<QuotaPerStorageVolumePanelCard
332+
defaultVolumeInfo={defaultVolumeInfo}
333+
/>
334+
</Suspense>
335+
</Modal>
336+
</>
337+
);
338+
};
339+
340+
const HostQuotaTrigger: React.FC = () => (
341+
<Suspense fallback={null}>
342+
<HostQuotaTriggerInner />
343+
</Suspense>
344+
);
345+
207346
interface VFolderNodesV2Props extends Omit<
208347
BAITableProps<VFolderNodeInList>,
209348
'dataSource' | 'columns'
@@ -490,8 +629,16 @@ const VFolderNodesV2: React.FC<VFolderNodesV2Props> = ({
490629
},
491630
{
492631
key: 'host',
493-
title: t('data.folders.Location'),
632+
title: (
633+
<BAIFlex gap={'xs'} align="center">
634+
{t('data.Host')}
635+
<HostQuotaTrigger />
636+
</BAIFlex>
637+
),
494638
dataIndex: 'host',
639+
render: (host: string | null | undefined) => (
640+
<VFolderHostCell host={host} />
641+
),
495642
sorter: isEnableSorter('host'),
496643
},
497644
{

react/src/pages/DashboardPage.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import MyResource from '../components/MyResource';
88
import MyResourceWithinResourceGroup from '../components/MyResourceWithinResourceGroup';
99
import RecentlyCreatedSession from '../components/RecentlyCreatedSession';
1010
import SessionCountDashboardItem from '../components/SessionCountDashboardItem';
11+
import StorageStatusPanelCard from '../components/StorageStatusPanelCard';
1112
import TotalResourceWithinResourceGroup, {
1213
useIsAvailableTotalResourceWithinResourceGroup,
1314
} from '../components/TotalResourceWithinResourceGroup';
14-
import { useSuspendedBackendaiClient } from '../hooks';
15+
import { useSuspendedBackendaiClient, useWebUINavigate } from '../hooks';
1516
import { useBAISettingUserState } from '../hooks/useBAISetting';
1617
import {
1718
useCurrentProjectValue,
@@ -41,6 +42,7 @@ const DashboardPage: React.FC = () => {
4142
const currentResourceGroup = useCurrentResourceGroupValue();
4243
const userRole = useCurrentUserRole();
4344
const baiClient = useSuspendedBackendaiClient();
45+
const webuiNavigate = useWebUINavigate();
4446

4547
const [fetchKey, updateFetchKey] = useFetchKey();
4648
const [isPendingIntervalRefetch, startIntervalRefetchTransition] =
@@ -169,6 +171,39 @@ const DashboardPage: React.FC = () => {
169171
),
170172
},
171173
},
174+
{
175+
id: 'folderStatus',
176+
rowSpan: 2,
177+
columnSpan: 2,
178+
definition: {
179+
minRowSpan: 2,
180+
minColumnSpan: 2,
181+
},
182+
data: {
183+
content: (
184+
<BAIBoardItemErrorBoundary
185+
title={t('data.FolderStatus')}
186+
status="error"
187+
>
188+
<Suspense
189+
fallback={<Skeleton active style={{ padding: token.marginMD }} />}
190+
>
191+
<StorageStatusPanelCard
192+
fetchKey={fetchKey}
193+
onRequestBadgeClick={() => {
194+
webuiNavigate({
195+
pathname: '/data',
196+
search: new URLSearchParams({
197+
invitation: 'true',
198+
}).toString(),
199+
});
200+
}}
201+
/>
202+
</Suspense>
203+
</BAIBoardItemErrorBoundary>
204+
),
205+
},
206+
},
172207
isAvailableTotalResourcePanel && {
173208
id: 'totalResourceWithinResourceGroup',
174209
rowSpan: 2,

0 commit comments

Comments
 (0)