Skip to content

Commit 824d4b3

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 ccbb51f commit 824d4b3

26 files changed

Lines changed: 363 additions & 279 deletions

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: 122 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import BAIPanelItem from './BAIPanelItem';
1010
import { useUpdateEffect } from 'ahooks';
1111
import { Badge, theme, Tooltip, Typography } from 'antd';
1212
import { createStyles } from 'antd-style';
13-
import { BAICard, BAICardProps, BAIRowWrapWithDividers } from 'backend.ai-ui';
13+
import {
14+
BAIBoardItemTitle,
15+
BAIFlex,
16+
BAIFlexProps,
17+
BAIRowWrapWithDividers,
18+
} from 'backend.ai-ui';
1419
import * as _ from 'lodash-es';
1520
import React, { useDeferredValue } from 'react';
1621
import { useTranslation } from 'react-i18next';
@@ -30,17 +35,22 @@ const useStyles = createStyles(({ css, token }) => ({
3035
`,
3136
}));
3237

33-
interface StorageStatusPanelProps extends BAICardProps {
38+
interface StorageStatusPanelProps extends BAIFlexProps {
3439
fetchKey?: string;
3540
onRequestBadgeClick?: () => void;
3641
}
3742

3843
const PANEL_ITEM_MAX_WIDTH = 90; // Adjusted max width for panel items
3944

45+
// Dashboard board-item body for folder-status counts. Uses the shared
46+
// `BAIBoardItemTitle` so the title reserves space for the board drag handle
47+
// and sits consistently with peer items (`MyResource`, etc.) — wrapping in a
48+
// `BAICard` here caused the title to overlap the handle on the left edge.
4049
const StorageStatusPanelCard: React.FC<StorageStatusPanelProps> = ({
4150
fetchKey,
4251
onRequestBadgeClick,
43-
...cardProps
52+
style,
53+
...flexProps
4454
}) => {
4555
const { t } = useTranslation();
4656
const { token } = theme.useToken();
@@ -69,6 +79,23 @@ const StorageStatusPanelCard: React.FC<StorageStatusPanelProps> = ({
6979
);
7080
};
7181

82+
// TODO(FR-2691 v2-migration): the counts below are derived from the legacy
83+
// REST call `baiClient.vfolder.list()` and then filtered in JS. Migrate to
84+
// the Strawberry V2 GraphQL `myVfolders` / `projectVfolders` connections
85+
// (with `filter` + `count`) so the server returns the counts directly.
86+
//
87+
// Scoping notes for the V2 rewrite:
88+
// - ProjectFolders (`projectCount`): must be scoped to the project
89+
// currently selected in the global header project selector (i.e.
90+
// `currentProject.id`). `projectVfolders(projectId: …, filter: { ... })`
91+
// already takes a required `projectId`, so the count must use that —
92+
// not an aggregate across every project the user can see.
93+
// - InvitedFolders (`invitedCount`): defer migration. The V2 vfolder
94+
// filter input does not yet expose an "invited only / received-share"
95+
// predicate, so we cannot reproduce the current `!is_owner &&
96+
// ownership_type === 'user'` filter with a single server-side count.
97+
// Keep this one on the legacy REST list until the backend adds the
98+
// filter field; revisit in a follow-up issue.
7299
const { data: vfolders } = useSuspenseTanQuery({
73100
queryKey: ['vfolders', { deferredFetchKey, id: currentProject.id }],
74101
queryFn: () => {
@@ -97,6 +124,9 @@ const StorageStatusPanelCard: React.FC<StorageStatusPanelProps> = ({
97124
!isExcludedCount(item.status),
98125
).length;
99126

127+
// TODO(FR-2691 v2-migration): `user_resource_policy` and
128+
// `project_resource_policy` are legacy (V1) root fields. Port this to the V2
129+
// schema once it exposes per-user / per-project vfolder count limits.
100130
const { user_resource_policy, project_resource_policy } =
101131
useLazyLoadQuery<StorageStatusPanelCardQuery>(
102132
graphql`
@@ -115,92 +145,100 @@ const StorageStatusPanelCard: React.FC<StorageStatusPanelProps> = ({
115145
);
116146

117147
return (
118-
<>
119-
<BAICard {...cardProps} title={t('data.FolderStatus')}>
120-
<BAIRowWrapWithDividers
121-
rowGap={token.marginXL}
122-
columnGap={token.marginXL}
123-
dividerColor={token.colorBorder}
124-
dividerInset={token.marginXS}
125-
dividerWidth={token.lineWidth}
126-
>
127-
<BAIPanelItem
128-
title={t('data.MyFolders')}
129-
value={createdCount}
130-
unit={
131-
user_resource_policy?.max_vfolder_count
132-
? `/ ${user_resource_policy?.max_vfolder_count}`
133-
: undefined
134-
}
135-
style={{
136-
maxWidth: PANEL_ITEM_MAX_WIDTH,
137-
}}
138-
color={token.colorText}
139-
/>
140-
<BAIPanelItem
141-
title={t('data.ProjectFolders')}
142-
value={projectCount}
143-
unit={
144-
project_resource_policy?.max_vfolder_count
145-
? `/ ${project_resource_policy?.max_vfolder_count}`
146-
: undefined
147-
}
148-
style={{
149-
maxWidth: PANEL_ITEM_MAX_WIDTH,
150-
}}
151-
color={token.colorText}
152-
/>
153-
<BAIPanelItem
154-
title={
155-
invitationCount > 0 ? (
156-
// Add <a></a> to make tooltip clickable
148+
<BAIFlex
149+
direction="column"
150+
align="stretch"
151+
style={{
152+
paddingInline: token.paddingXL,
153+
paddingBottom: token.padding,
154+
...style,
155+
}}
156+
{...flexProps}
157+
>
158+
<BAIBoardItemTitle title={t('data.FolderStatus')} />
159+
<BAIRowWrapWithDividers
160+
rowGap={token.marginXL}
161+
columnGap={token.marginXL}
162+
dividerColor={token.colorBorder}
163+
dividerInset={token.marginXS}
164+
dividerWidth={token.lineWidth}
165+
>
166+
<BAIPanelItem
167+
title={t('data.MyFolders')}
168+
value={createdCount}
169+
unit={
170+
user_resource_policy?.max_vfolder_count
171+
? `/ ${user_resource_policy?.max_vfolder_count}`
172+
: undefined
173+
}
174+
style={{
175+
maxWidth: PANEL_ITEM_MAX_WIDTH,
176+
}}
177+
color={token.colorText}
178+
/>
179+
<BAIPanelItem
180+
title={t('data.ProjectFolders')}
181+
value={projectCount}
182+
unit={
183+
project_resource_policy?.max_vfolder_count
184+
? `/ ${project_resource_policy?.max_vfolder_count}`
185+
: undefined
186+
}
187+
style={{
188+
maxWidth: PANEL_ITEM_MAX_WIDTH,
189+
}}
190+
color={token.colorText}
191+
/>
192+
<BAIPanelItem
193+
title={
194+
invitationCount > 0 ? (
195+
// Add <a></a> to make tooltip clickable
157196

158-
<a
159-
onClick={() => {
160-
onRequestBadgeClick?.();
161-
}}
197+
<a
198+
onClick={() => {
199+
onRequestBadgeClick?.();
200+
}}
201+
>
202+
<Tooltip
203+
title={t('data.InvitedFoldersTooltip', {
204+
count: invitationCount,
205+
})}
206+
rootClassName={styles.invitationTooltip}
207+
placement="topRight"
162208
>
163-
<Tooltip
164-
title={t('data.InvitedFoldersTooltip', {
165-
count: invitationCount,
166-
})}
167-
rootClassName={styles.invitationTooltip}
168-
placement="topRight"
209+
<Badge
210+
count={`+${invitationCount}`}
211+
offset={[-`${token.sizeXS}`, -`${token.sizeXS}`]}
169212
>
170-
<Badge
171-
count={`+${invitationCount}`}
172-
offset={[-`${token.sizeXS}`, -`${token.sizeXS}`]}
213+
<Typography.Text
214+
style={{ fontSize: token.fontSizeHeading5 }}
173215
>
174-
<Typography.Text
175-
style={{ fontSize: token.fontSizeHeading5 }}
176-
>
177-
{t('data.InvitedFolders')}
178-
</Typography.Text>
179-
</Badge>
180-
</Tooltip>
181-
</a>
182-
) : (
183-
<Typography.Text style={{ fontSize: token.fontSizeHeading5 }}>
184-
{t('data.InvitedFolders')}
185-
</Typography.Text>
186-
)
187-
}
188-
value={
189-
<Typography.Text
190-
style={{
191-
fontSize: token.fontSizeHeading1,
192-
}}
193-
>
194-
{invitedCount}
216+
{t('data.InvitedFolders')}
217+
</Typography.Text>
218+
</Badge>
219+
</Tooltip>
220+
</a>
221+
) : (
222+
<Typography.Text style={{ fontSize: token.fontSizeHeading5 }}>
223+
{t('data.InvitedFolders')}
195224
</Typography.Text>
196-
}
197-
style={{
198-
maxWidth: PANEL_ITEM_MAX_WIDTH,
199-
}}
200-
/>
201-
</BAIRowWrapWithDividers>
202-
</BAICard>
203-
</>
225+
)
226+
}
227+
value={
228+
<Typography.Text
229+
style={{
230+
fontSize: token.fontSizeHeading1,
231+
}}
232+
>
233+
{invitedCount}
234+
</Typography.Text>
235+
}
236+
style={{
237+
maxWidth: PANEL_ITEM_MAX_WIDTH,
238+
}}
239+
/>
240+
</BAIRowWrapWithDividers>
241+
</BAIFlex>
204242
);
205243
};
206244

0 commit comments

Comments
 (0)