diff --git a/react/src/components/QuotaPerStorageVolumeDashboardItem.tsx b/react/src/components/QuotaPerStorageVolumeDashboardItem.tsx new file mode 100644 index 0000000000..3ec5394915 --- /dev/null +++ b/react/src/components/QuotaPerStorageVolumeDashboardItem.tsx @@ -0,0 +1,65 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { useSuspendedBackendaiClient } from '../hooks'; +import { useSuspenseTanQuery } from '../hooks/reactQueryAlias'; +import QuotaPerStorageVolumePanelCard, { + type VolumeInfo, +} from './QuotaPerStorageVolumePanelCard'; +import { Empty, theme } from 'antd'; +import { BAIBoardItemTitle, BAIFlex } from 'backend.ai-ui'; +import * as _ from 'lodash-es'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +// Dashboard wrapper around `QuotaPerStorageVolumePanelCard`. Resolves the +// first quota-supporting host up-front so the panel opens on a meaningful +// scope instead of the inner card's "does not support" state. Renders an +// `Empty` placeholder when no host advertises `quota` capability — the +// dashboard slot is always shown (unlike the row-level modal trigger that +// hides itself entirely when no quota host exists). +const QuotaPerStorageVolumeDashboardItem: React.FC = () => { + 'use memo'; + const { t } = useTranslation(); + const { token } = theme.useToken(); + const baiClient = useSuspendedBackendaiClient(); + + const { data: vhostInfo } = useSuspenseTanQuery<{ + volume_info?: Record>; + } | null>({ + queryKey: ['vhostInfo'], + queryFn: () => baiClient.vfolder.list_hosts(), + }); + + const quotaSupportedEntry = _.find( + _.entries(vhostInfo?.volume_info ?? {}), + ([, info]) => _.includes(info?.capabilities, 'quota'), + ); + const defaultVolumeInfo: VolumeInfo | undefined = quotaSupportedEntry + ? { id: quotaSupportedEntry[0], ...quotaSupportedEntry[1] } + : undefined; + + return ( + + + {defaultVolumeInfo ? ( + + ) : ( + + )} + + ); +}; + +export default QuotaPerStorageVolumeDashboardItem; diff --git a/react/src/components/QuotaPerStorageVolumePanelCard.tsx b/react/src/components/QuotaPerStorageVolumePanelCard.tsx index 95ec9e9db4..82f958f6e9 100644 --- a/react/src/components/QuotaPerStorageVolumePanelCard.tsx +++ b/react/src/components/QuotaPerStorageVolumePanelCard.tsx @@ -8,13 +8,11 @@ import { addQuotaScopeTypePrefix, convertToDecimalUnit } from '../helper'; import { useCurrentDomainValue, useSuspendedBackendaiClient } from '../hooks'; import { useCurrentProjectValue } from '../hooks/useCurrentProject'; import BAIProgress from './BAIProgress'; -import FlexActivityIndicator from './FlexActivityIndicator'; import StorageSelect from './StorageSelect'; -import { QuestionCircleOutlined } from '@ant-design/icons'; -import { Col, Empty, Row, theme, Tooltip, Typography } from 'antd'; -import { BAICard, BAICardProps, BAIFlex } from 'backend.ai-ui'; +import { Col, Empty, Row, Skeleton, theme, Typography } from 'antd'; +import { BAIFlex } from 'backend.ai-ui'; import * as _ from 'lodash-es'; -import React, { useDeferredValue, useState } from 'react'; +import React, { Suspense, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { graphql, useLazyLoadQuery } from 'react-relay'; @@ -22,21 +20,39 @@ export type VolumeInfo = { id: string; backend: string; capabilities: string[]; - usage: { - percentage: number; + // `usage` is optional because `vfolder.list_hosts()` only attaches it for + // hosts that can report capacity; `usage.percentage` is optional because + // even a reporting host may omit the percentage (rendered as "Unknown"). + usage?: { + percentage?: number; }; sftp_scaling_groups: string[]; }; -interface QuotaPerStorageVolumePanelCardProps extends BAICardProps {} +interface QuotaPerStorageVolumePanelCardProps { + /** + * Pre-selects a volume so the content renders that host's quota immediately + * (e.g. when opened from a specific folder row). When provided, the built-in + * usage-based auto-select is disabled; users can still switch volumes via + * the inline `StorageSelect`. + */ + defaultVolumeInfo?: VolumeInfo; +} -const QuotaPerStorageVolumePanelCard: React.FC< - QuotaPerStorageVolumePanelCardProps -> = ({ ...baiCardProps }) => { +interface QuotaScopeContentProps { + selectedVolumeInfo: VolumeInfo | undefined; +} + +// Body of the panel: fetches and renders project / user quota scope for the +// selected volume. Wrapped in a Suspense boundary by the parent so switching +// to an uncached host shows a loading indicator while in flight, while cache +// hits commit synchronously without any spinner flash. +const QuotaScopeContent: React.FC = ({ + selectedVolumeInfo, +}) => { + 'use memo'; const { t } = useTranslation(); const { token } = theme.useToken(); - const [selectedVolumeInfo, setSelectedVolumeInfo] = useState(); - const deferredSelectedVolumeInfo = useDeferredValue(selectedVolumeInfo); const currentProject = useCurrentProjectValue(); const baiClient = useSuspendedBackendaiClient(); @@ -92,14 +108,24 @@ const QuotaPerStorageVolumePanelCard: React.FC< currentProject?.id || '', ), user_quota_scope_id: addQuotaScopeTypePrefix('user', user?.id || ''), - storage_host_name: deferredSelectedVolumeInfo?.id || '', + storage_host_name: selectedVolumeInfo?.id || '', skipQuotaScope: currentProject?.id === undefined || user?.id === undefined || - !deferredSelectedVolumeInfo?.id, + !selectedVolumeInfo?.id, }, ); + if (!selectedVolumeInfo?.capabilities?.includes('quota')) { + return ( + + ); + } + const projectUsageBytes = _.toFinite( project_quota_scope?.details?.usage_bytes, ); @@ -121,118 +147,114 @@ const QuotaPerStorageVolumePanelCard: React.FC< : 0; return ( - - {t('data.QuotaPerStorageVolume')} - - - - - } - extra={ - - { - setSelectedVolumeInfo(vInfo); - }} - autoSelectType="usage" - showUsageStatus - showSearch - variant="borderless" - /> - - } - styles={{ - body: { - paddingTop: token.paddingLG, - }, - }} - > - {selectedVolumeInfo !== deferredSelectedVolumeInfo ? ( - - ) : selectedVolumeInfo?.capabilities?.includes('quota') ? ( - - - - - {t('data.Project')} - - - {currentProject?.name} - - - } - percent={projectPercent} - used={ - projectUsageBytes === 0 - ? '' - : `${convertToDecimalUnit(_.toString(projectUsageBytes), 'g')?.displayValue}` - } - total={ - projectHardLimitBytes === 0 - ? '' - : `${convertToDecimalUnit(_.toString(projectHardLimitBytes), 'g')?.displayValue}` - } - /> - - - - - {t('data.User')} - - - {baiClient?.full_name} - - - } - used={ - userUsageBytes === 0 - ? '' - : convertToDecimalUnit(_.toString(userUsageBytes), 'auto') - ?.displayValue - } - total={ - userHardLimitBytes === 0 - ? '' - : convertToDecimalUnit(_.toString(userHardLimitBytes), 'auto') - ?.displayValue - } - /> - - - ) : ( - + + + + {t('data.Project')} + + + {currentProject?.name} + + + } + percent={projectPercent} + used={ + projectUsageBytes === 0 + ? '' + : `${convertToDecimalUnit(_.toString(projectUsageBytes), 'g')?.displayValue}` + } + total={ + projectHardLimitBytes === 0 + ? '' + : `${convertToDecimalUnit(_.toString(projectHardLimitBytes), 'g')?.displayValue}` + } + /> + + + + + {t('data.User')} + + + {baiClient?.full_name} + + + } + used={ + userUsageBytes === 0 + ? '' + : convertToDecimalUnit(_.toString(userUsageBytes), 'auto') + ?.displayValue + } + total={ + userHardLimitBytes === 0 + ? '' + : convertToDecimalUnit(_.toString(userHardLimitBytes), 'auto') + ?.displayValue + } /> - )} - + + + ); +}; + +// Modal-body view for per-volume quota. Intentionally not wrapped in a BAICard +// — the consuming Modal provides its own title and chrome, so a nested card +// would duplicate the header and inflate the modal visually. +const QuotaPerStorageVolumePanelCard: React.FC< + QuotaPerStorageVolumePanelCardProps +> = ({ defaultVolumeInfo }) => { + 'use memo'; + const [selectedVolumeInfo, setSelectedVolumeInfo] = useState< + VolumeInfo | undefined + >(defaultVolumeInfo); + // Reset the inline selection when the consumer passes a different + // `defaultVolumeInfo` while the panel stays mounted (e.g., reopened for a + // different host). Compare ids only — following the + // "storing info from previous renders" pattern + // (https://react.dev/reference/react/useState#storing-information-from-previous-renders), + // so the badge reflects the latest prop without an effect. + const [prevDefaultVolumeId, setPrevDefaultVolumeId] = useState( + defaultVolumeInfo?.id, + ); + if (prevDefaultVolumeId !== defaultVolumeInfo?.id) { + setPrevDefaultVolumeId(defaultVolumeInfo?.id); + setSelectedVolumeInfo(defaultVolumeInfo); + } + + return ( + + { + setSelectedVolumeInfo(vInfo); + }} + autoSelectType={defaultVolumeInfo ? undefined : 'usage'} + showUsageStatus + showSearch + style={{ alignSelf: 'flex-start', minWidth: 240 }} + /> + }> + + + ); }; diff --git a/react/src/components/StorageSelect.tsx b/react/src/components/StorageSelect.tsx index 0b8b74fb71..da7a3fdc92 100644 --- a/react/src/components/StorageSelect.tsx +++ b/react/src/components/StorageSelect.tsx @@ -21,8 +21,11 @@ export type VolumeInfo = { id: string; backend: string; capabilities: string[]; - usage: { - percentage: number; + // `usage` is optional because `vfolder.list_hosts()` only attaches it for + // hosts that can report capacity; `usage.percentage` is optional because + // even a reporting host may omit the percentage. + usage?: { + percentage?: number; }; sftp_scaling_groups: string[]; }; @@ -118,38 +121,42 @@ const StorageSelect: React.FC = ({ } } optionLabelProp={showUsageStatus ? 'label' : 'value'} - options={_.map(vhostInfo?.allowed, (host) => ({ - label: showUsageStatus ? ( - - {vhostInfo?.volume_info?.[host]?.usage && ( - - - {/* Use   instead of Flex gap to fix Tooltip */} -    - - )} - - {host} - - {/* TODO: uncomment after implementing click action */} - {/*