Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions react/src/components/QuotaPerStorageVolumeDashboardItem.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Omit<VolumeInfo, 'id'>>;
} | 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 (
<BAIFlex
direction="column"
align="stretch"
style={{
paddingInline: token.paddingXL,
paddingBottom: token.padding,
}}
>
<BAIBoardItemTitle title={t('data.QuotaPerStorageVolume')} />
{defaultVolumeInfo ? (
<QuotaPerStorageVolumePanelCard defaultVolumeInfo={defaultVolumeInfo} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('storageHost.QuotaDoesNotSupported')}
/>
)}
</BAIFlex>
);
};

export default QuotaPerStorageVolumeDashboardItem;
274 changes: 148 additions & 126 deletions react/src/components/QuotaPerStorageVolumePanelCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,51 @@ 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';

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<QuotaScopeContentProps> = ({
selectedVolumeInfo,
}) => {
'use memo';
const { t } = useTranslation();
const { token } = theme.useToken();
const [selectedVolumeInfo, setSelectedVolumeInfo] = useState<VolumeInfo>();
const deferredSelectedVolumeInfo = useDeferredValue(selectedVolumeInfo);
const currentProject = useCurrentProjectValue();
const baiClient = useSuspendedBackendaiClient();

Expand Down Expand Up @@ -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 (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('storageHost.QuotaDoesNotSupported')}
style={{ margin: 'auto 25px' }}
/>
);
}

const projectUsageBytes = _.toFinite(
project_quota_scope?.details?.usage_bytes,
);
Expand All @@ -121,118 +147,114 @@ const QuotaPerStorageVolumePanelCard: React.FC<
: 0;

return (
<BAICard
{...baiCardProps}
title={
<BAIFlex gap={'xs'} align="center">
{t('data.QuotaPerStorageVolume')}
<Tooltip title={t('data.HostDetails')}>
<QuestionCircleOutlined
style={{ color: token.colorTextDescription }}
/>
</Tooltip>
</BAIFlex>
}
extra={
<BAIFlex
style={{
marginRight: -8,
}}
>
<StorageSelect
value={selectedVolumeInfo?.id}
onChange={(__, vInfo) => {
setSelectedVolumeInfo(vInfo);
}}
autoSelectType="usage"
showUsageStatus
showSearch
variant="borderless"
/>
</BAIFlex>
}
styles={{
body: {
paddingTop: token.paddingLG,
},
}}
>
{selectedVolumeInfo !== deferredSelectedVolumeInfo ? (
<FlexActivityIndicator style={{ minHeight: 120 }} />
) : selectedVolumeInfo?.capabilities?.includes('quota') ? (
<Row gutter={[24, 16]}>
<Col
span={12}
style={{
borderRight: `1px solid ${token.colorBorderSecondary}`,
}}
>
<BAIProgress
title={
<BAIFlex direction="column" align="start">
<Typography.Text
type="secondary"
style={{ fontSize: token.fontSizeSM }}
>
{t('data.Project')}
</Typography.Text>
<Typography.Text style={{ fontSize: token.fontSize }}>
{currentProject?.name}
</Typography.Text>
</BAIFlex>
}
percent={projectPercent}
used={
projectUsageBytes === 0
? ''
: `${convertToDecimalUnit(_.toString(projectUsageBytes), 'g')?.displayValue}`
}
total={
projectHardLimitBytes === 0
? ''
: `${convertToDecimalUnit(_.toString(projectHardLimitBytes), 'g')?.displayValue}`
}
/>
</Col>
<Col span={12}>
<BAIProgress
percent={userPercent}
title={
<BAIFlex direction="column" align="start">
<Typography.Text
type="secondary"
style={{ fontSize: token.fontSizeSM }}
>
{t('data.User')}
</Typography.Text>
<Typography.Text style={{ fontSize: token.fontSize }}>
{baiClient?.full_name}
</Typography.Text>
</BAIFlex>
}
used={
userUsageBytes === 0
? ''
: convertToDecimalUnit(_.toString(userUsageBytes), 'auto')
?.displayValue
}
total={
userHardLimitBytes === 0
? ''
: convertToDecimalUnit(_.toString(userHardLimitBytes), 'auto')
?.displayValue
}
/>
</Col>
</Row>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('storageHost.QuotaDoesNotSupported')}
style={{ margin: 'auto 25px' }}
<Row gutter={[24, 16]}>
<Col
span={12}
style={{
borderRight: `1px solid ${token.colorBorderSecondary}`,
}}
>
<BAIProgress
title={
<BAIFlex direction="column" align="start">
<Typography.Text
type="secondary"
style={{ fontSize: token.fontSizeSM }}
>
{t('data.Project')}
</Typography.Text>
<Typography.Text style={{ fontSize: token.fontSize }}>
{currentProject?.name}
</Typography.Text>
</BAIFlex>
}
percent={projectPercent}
used={
projectUsageBytes === 0
? ''
: `${convertToDecimalUnit(_.toString(projectUsageBytes), 'g')?.displayValue}`
}
total={
projectHardLimitBytes === 0
? ''
: `${convertToDecimalUnit(_.toString(projectHardLimitBytes), 'g')?.displayValue}`
}
/>
</Col>
<Col span={12}>
<BAIProgress
percent={userPercent}
title={
<BAIFlex direction="column" align="start">
<Typography.Text
type="secondary"
style={{ fontSize: token.fontSizeSM }}
>
{t('data.User')}
</Typography.Text>
<Typography.Text style={{ fontSize: token.fontSize }}>
{baiClient?.full_name}
</Typography.Text>
</BAIFlex>
}
used={
userUsageBytes === 0
? ''
: convertToDecimalUnit(_.toString(userUsageBytes), 'auto')
?.displayValue
}
total={
userHardLimitBytes === 0
? ''
: convertToDecimalUnit(_.toString(userHardLimitBytes), 'auto')
?.displayValue
}
/>
)}
</BAICard>
</Col>
</Row>
);
};

// 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 (
<BAIFlex direction="column" align="stretch" gap={'md'}>
<StorageSelect
value={selectedVolumeInfo?.id}
onChange={(__, vInfo) => {
setSelectedVolumeInfo(vInfo);
}}
autoSelectType={defaultVolumeInfo ? undefined : 'usage'}
showUsageStatus
showSearch
style={{ alignSelf: 'flex-start', minWidth: 240 }}
/>
<Suspense fallback={<Skeleton active paragraph={{ rows: 0 }} />}>
<QuotaScopeContent selectedVolumeInfo={selectedVolumeInfo} />
</Suspense>
</BAIFlex>
);
};

Expand Down
Loading
Loading