@@ -16,23 +16,23 @@ import { ReloadOutlined } from '@ant-design/icons';
1616import {
1717 App ,
1818 AutoComplete ,
19- Button ,
2019 Form ,
2120 FormInstance ,
2221 InputNumber ,
2322 Segmented ,
2423 Select ,
25- Spin ,
2624 Typography ,
25+ theme ,
2726} from 'antd' ;
2827import {
28+ BAIButton ,
2929 BAIModal ,
3030 BAIModalProps ,
3131 toLocalId ,
3232 useBAILogger ,
3333} from 'backend.ai-ui' ;
3434import * as _ from 'lodash-es' ;
35- import React , { useRef , useState } from 'react' ;
35+ import React , { useRef , useState , useTransition } from 'react' ;
3636import { useTranslation } from 'react-i18next' ;
3737import {
3838 graphql ,
@@ -77,18 +77,16 @@ const METRIC_NAMES_MAP: Partial<
7777} ;
7878
7979/**
80- * Inline preview component that fetches and displays the current Prometheus
81- * metric value for a selected preset. Uses useLazyLoadQuery with fetchKey
82- * to support manual refresh without useEffect + setState .
80+ * Inner component: fetches and renders only the metric value text.
81+ * Isolated so that React.Suspense covers just this text node during refresh,
82+ * leaving the "Current value:" label and refresh button always visible .
8383 */
84- const PrometheusPresetPreview : React . FC < {
85- presetGlobalId : string ;
86- } > = ( { presetGlobalId } ) => {
84+ const PreviewValue : React . FC < {
85+ presetRawId : string ;
86+ fetchKey : number ;
87+ } > = ( { presetRawId, fetchKey } ) => {
8788 'use memo' ;
8889 const { t } = useTranslation ( ) ;
89- const [ fetchKey , setFetchKey ] = useState ( 0 ) ;
90-
91- const presetRawId = toLocalId ( presetGlobalId ) ;
9290
9391 const data = useLazyLoadQuery < AutoScalingRuleEditorModalPresetResultQuery > (
9492 graphql `
@@ -125,12 +123,10 @@ const PrometheusPresetPreview: React.FC<{
125123 const results = data . prometheusQueryPresetResult . result ;
126124
127125 let displayValue : string | null = null ;
128- if ( results . length === 0 ) {
129- displayValue = null ;
130- } else if ( results . length === 1 ) {
126+ if ( results . length === 1 ) {
131127 const values = results [ 0 ] . values ;
132128 displayValue = values . length > 0 ? values [ values . length - 1 ] . value : null ;
133- } else {
129+ } else if ( results . length > 1 ) {
134130 const firstValues = results [ 0 ] . values ;
135131 const latestValue =
136132 firstValues . length > 0 ? firstValues [ firstValues . length - 1 ] . value : null ;
@@ -143,20 +139,54 @@ const PrometheusPresetPreview: React.FC<{
143139 : null ;
144140 }
145141
142+ return displayValue != null ? (
143+ < Typography . Text strong > { displayValue } </ Typography . Text >
144+ ) : (
145+ < Typography . Text type = "secondary" >
146+ { t ( 'autoScalingRule.NoDataAvailable' ) }
147+ </ Typography . Text >
148+ ) ;
149+ } ;
150+
151+ /**
152+ * Inline preview component for a selected Prometheus preset.
153+ * The label and refresh button are always visible; only the value area
154+ * shows a loading spinner during fetch/refresh.
155+ */
156+ const PrometheusPresetPreview : React . FC < {
157+ presetGlobalId : string ;
158+ } > = ( { presetGlobalId } ) => {
159+ 'use memo' ;
160+ const { t } = useTranslation ( ) ;
161+ const { token } = theme . useToken ( ) ;
162+ const [ fetchKey , setFetchKey ] = useState ( 0 ) ;
163+ const [ isPending , startTransition ] = useTransition ( ) ;
164+ const presetRawId = toLocalId ( presetGlobalId ) ;
165+
146166 return (
147167 < span >
148- < Typography . Text type = "secondary" style = { { marginRight : 4 } } >
168+ < Typography . Text
169+ type = "secondary"
170+ style = { { marginRight : token . marginXXS } }
171+ >
149172 { t ( 'autoScalingRule.CurrentValue' ) } :{ ' ' }
150173 </ Typography . Text >
151- < Typography . Text strong >
152- { displayValue ?? t ( 'autoScalingRule.NoDataAvailable' ) }
153- </ Typography . Text >
154- < Button
174+ < ErrorBoundaryWithNullFallback >
175+ { /* null fallback: initial load shows nothing until data arrives.
176+ On refresh via startTransition, React keeps the previous value
177+ visible and never commits this fallback. */ }
178+ < React . Suspense fallback = { null } >
179+ < PreviewValue presetRawId = { presetRawId } fetchKey = { fetchKey } />
180+ </ React . Suspense >
181+ </ ErrorBoundaryWithNullFallback >
182+ < BAIButton
155183 type = "link"
156184 size = "small"
157185 icon = { < ReloadOutlined /> }
158- onClick = { ( ) => setFetchKey ( ( k ) => k + 1 ) }
186+ loading = { isPending }
187+ onClick = { ( ) => startTransition ( ( ) => setFetchKey ( ( k ) => k + 1 ) ) }
159188 title = { t ( 'autoScalingRule.RefreshPreview' ) }
189+ aria-label = { t ( 'autoScalingRule.RefreshPreview' ) }
160190 />
161191 </ span >
162192 ) ;
@@ -189,6 +219,7 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
189219} ) => {
190220 'use memo' ;
191221 const { t } = useTranslation ( ) ;
222+ const { token } = theme . useToken ( ) ;
192223 const { message } = App . useApp ( ) ;
193224 const { logger } = useBAILogger ( ) ;
194225
@@ -585,6 +616,11 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
585616 } ,
586617 } ,
587618 ] }
619+ extra = {
620+ selectedPreset ? (
621+ < PrometheusPresetPreview presetGlobalId = { selectedPreset . id } />
622+ ) : undefined
623+ }
588624 >
589625 < Select
590626 value = { selectedPresetId }
@@ -616,35 +652,6 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
616652 onClear = { ( ) => setSelectedPresetId ( undefined ) }
617653 />
618654 </ Form . Item >
619- { selectedPreset && (
620- < Form . Item
621- label = { t ( 'autoScalingRule.QueryTemplate' ) }
622- extra = {
623- < ErrorBoundaryWithNullFallback >
624- < React . Suspense
625- fallback = {
626- < Spin size = "small" style = { { marginRight : 8 } } />
627- }
628- >
629- < PrometheusPresetPreview
630- presetGlobalId = { selectedPreset . id }
631- />
632- </ React . Suspense >
633- </ ErrorBoundaryWithNullFallback >
634- }
635- >
636- < Typography . Text
637- code
638- style = { {
639- display : 'block' ,
640- whiteSpace : 'pre-wrap' ,
641- wordBreak : 'break-all' ,
642- } }
643- >
644- { selectedPreset . queryTemplate }
645- </ Typography . Text >
646- </ Form . Item >
647- ) }
648655 </ >
649656 ) }
650657
@@ -669,26 +676,32 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
669676 onChange = { ( value ) => {
670677 setConditionMode ( value as ConditionMode ) ;
671678 } }
672- style = { { marginBottom : 12 } }
679+ style = { { marginBottom : token . marginSM } }
673680 />
674681 </ Form . Item >
675682
676683 { conditionMode === 'single' ? (
677- < div style = { { display : 'flex' , gap : 8 , alignItems : 'start' } } >
684+ < div
685+ style = { {
686+ display : 'flex' ,
687+ gap : token . marginXS ,
688+ alignItems : 'center' ,
689+ } }
690+ >
678691 < Form . Item
679692 name = { 'direction' }
680693 noStyle
681694 rules = { [ { required : true } ] }
682695 >
683696 < Select
684- style = { { width : 120 } }
697+ style = { { width : 100 } }
685698 options = { [
686699 {
687- label : t ( 'autoScalingRule.Upper' ) ,
700+ label : 'Metric >' ,
688701 value : 'upper' ,
689702 } ,
690703 {
691- label : t ( 'autoScalingRule.Lower' ) ,
704+ label : 'Metric <' ,
692705 value : 'lower' ,
693706 } ,
694707 ] }
@@ -705,7 +718,7 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
705718 {
706719 type : 'number' ,
707720 min : 0 ,
708- message : t ( 'autoScalingRule.ThresholdMustBePositive ' ) ,
721+ message : t ( 'autoScalingRule.ThresholdMustBeNonNegative ' ) ,
709722 } ,
710723 ] }
711724 >
@@ -717,7 +730,13 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
717730 </ Form . Item >
718731 </ div >
719732 ) : (
720- < div style = { { display : 'flex' , gap : 8 , alignItems : 'start' } } >
733+ < div
734+ style = { {
735+ display : 'flex' ,
736+ gap : token . marginXS ,
737+ alignItems : 'center' ,
738+ } }
739+ >
721740 < Form . Item
722741 name = { 'minThreshold' }
723742 noStyle
@@ -729,7 +748,7 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
729748 {
730749 type : 'number' ,
731750 min : 0 ,
732- message : t ( 'autoScalingRule.ThresholdMustBePositive ' ) ,
751+ message : t ( 'autoScalingRule.ThresholdMustBeNonNegative ' ) ,
733752 } ,
734753 ] }
735754 >
@@ -739,8 +758,8 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
739758 min = { 0 }
740759 />
741760 </ Form . Item >
742- < Typography . Text style = { { lineHeight : '32px' , flexShrink : 0 } } >
743- { '<' } metric { '<' }
761+ < Typography . Text style = { { flexShrink : 0 } } >
762+ { '<' } Metric { '<' }
744763 </ Typography . Text >
745764 < Form . Item
746765 name = { 'maxThreshold' }
@@ -754,7 +773,7 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
754773 {
755774 type : 'number' ,
756775 min : 0 ,
757- message : t ( 'autoScalingRule.ThresholdMustBePositive ' ) ,
776+ message : t ( 'autoScalingRule.ThresholdMustBeNonNegative ' ) ,
758777 } ,
759778 ( { getFieldValue } ) => ( {
760779 validator ( _ , value ) {
0 commit comments