@@ -8,35 +8,51 @@ import { addQuotaScopeTypePrefix, convertToDecimalUnit } from '../helper';
88import { useCurrentDomainValue , useSuspendedBackendaiClient } from '../hooks' ;
99import { useCurrentProjectValue } from '../hooks/useCurrentProject' ;
1010import BAIProgress from './BAIProgress' ;
11- import FlexActivityIndicator from './FlexActivityIndicator' ;
1211import 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' ;
1614import * as _ from 'lodash-es' ;
17- import React , { useDeferredValue , useState } from 'react' ;
15+ import React , { Suspense , useState } from 'react' ;
1816import { useTranslation } from 'react-i18next' ;
1917import { graphql , useLazyLoadQuery } from 'react-relay' ;
2018
2119export 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