@@ -16,23 +16,24 @@ 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 ,
33+ useFetchKey ,
3334} from 'backend.ai-ui' ;
3435import * as _ from 'lodash-es' ;
35- import React , { useRef , useState } from 'react' ;
36+ import React , { useRef , useState , useTransition } from 'react' ;
3637import { useTranslation } from 'react-i18next' ;
3738import {
3839 graphql ,
@@ -77,18 +78,16 @@ const METRIC_NAMES_MAP: Partial<
7778} ;
7879
7980/**
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 .
81+ * Inner component: fetches and renders only the metric value text.
82+ * Isolated so that React.Suspense covers just this text node during refresh,
83+ * leaving the "Current value:" label and refresh button always visible .
8384 */
84- const PrometheusPresetPreview : React . FC < {
85- presetGlobalId : string ;
86- } > = ( { presetGlobalId } ) => {
85+ const PreviewValue : React . FC < {
86+ presetRawId : string ;
87+ fetchKey : string ;
88+ } > = ( { presetRawId, fetchKey } ) => {
8789 'use memo' ;
8890 const { t } = useTranslation ( ) ;
89- const [ fetchKey , setFetchKey ] = useState ( 0 ) ;
90-
91- const presetRawId = toLocalId ( presetGlobalId ) ;
9291
9392 const data = useLazyLoadQuery < AutoScalingRuleEditorModalPresetResultQuery > (
9493 graphql `
@@ -124,39 +123,77 @@ const PrometheusPresetPreview: React.FC<{
124123
125124 const results = data . prometheusQueryPresetResult . result ;
126125
126+ const formatValue = ( raw : string ) => {
127+ const num = parseFloat ( raw ) ;
128+ return isNaN ( num ) ? raw : ( Math . round ( num * 100 ) / 100 ) . toString ( ) ;
129+ } ;
130+
127131 let displayValue : string | null = null ;
128- if ( results . length === 0 ) {
129- displayValue = null ;
130- } else if ( results . length === 1 ) {
132+ if ( results . length === 1 ) {
131133 const values = results [ 0 ] . values ;
132- displayValue = values . length > 0 ? values [ values . length - 1 ] . value : null ;
133- } else {
134+ const raw = values . length > 0 ? values [ values . length - 1 ] . value : null ;
135+ displayValue = raw != null ? formatValue ( raw ) : null ;
136+ } else if ( results . length > 1 ) {
134137 const firstValues = results [ 0 ] . values ;
135138 const latestValue =
136139 firstValues . length > 0 ? firstValues [ firstValues . length - 1 ] . value : null ;
137140 displayValue =
138141 latestValue != null
139142 ? t ( 'autoScalingRule.MultipleSeriesResult' , {
140143 count : results . length ,
141- value : latestValue ,
144+ value : formatValue ( latestValue ) ,
142145 } )
143146 : null ;
144147 }
145148
149+ return displayValue != null ? (
150+ < Typography . Text type = "secondary" > { displayValue } </ Typography . Text >
151+ ) : (
152+ < Typography . Text type = "secondary" >
153+ { t ( 'autoScalingRule.NoDataAvailable' ) }
154+ </ Typography . Text >
155+ ) ;
156+ } ;
157+
158+ /**
159+ * Inline preview component for a selected Prometheus preset.
160+ * The label and refresh button are always visible; only the value area
161+ * shows a loading spinner during fetch/refresh.
162+ */
163+ const PrometheusPresetPreview : React . FC < {
164+ presetGlobalId : string ;
165+ } > = ( { presetGlobalId } ) => {
166+ 'use memo' ;
167+ const { t } = useTranslation ( ) ;
168+ const { token } = theme . useToken ( ) ;
169+ const [ fetchKey , updateFetchKey ] = useFetchKey ( ) ;
170+ const [ isPending , startTransition ] = useTransition ( ) ;
171+ const presetRawId = toLocalId ( presetGlobalId ) ;
172+
146173 return (
147174 < span >
148- < Typography . Text type = "secondary" style = { { marginRight : 4 } } >
175+ < Typography . Text
176+ type = "secondary"
177+ style = { { marginRight : token . marginXXS } }
178+ >
149179 { t ( 'autoScalingRule.CurrentValue' ) } :{ ' ' }
150180 </ Typography . Text >
151- < Typography . Text strong >
152- { displayValue ?? t ( 'autoScalingRule.NoDataAvailable' ) }
153- </ Typography . Text >
154- < Button
181+ < ErrorBoundaryWithNullFallback >
182+ { /* null fallback: initial load shows nothing until data arrives.
183+ On refresh via startTransition, React keeps the previous value
184+ visible and never commits this fallback. */ }
185+ < React . Suspense fallback = { null } >
186+ < PreviewValue presetRawId = { presetRawId } fetchKey = { fetchKey } />
187+ </ React . Suspense >
188+ </ ErrorBoundaryWithNullFallback >
189+ < BAIButton
155190 type = "link"
156191 size = "small"
157192 icon = { < ReloadOutlined /> }
158- onClick = { ( ) => setFetchKey ( ( k ) => k + 1 ) }
193+ loading = { isPending }
194+ onClick = { ( ) => startTransition ( ( ) => updateFetchKey ( ) ) }
159195 title = { t ( 'autoScalingRule.RefreshPreview' ) }
196+ aria-label = { t ( 'autoScalingRule.RefreshPreview' ) }
160197 />
161198 </ span >
162199 ) ;
@@ -189,6 +226,7 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
189226} ) => {
190227 'use memo' ;
191228 const { t } = useTranslation ( ) ;
229+ const { token } = theme . useToken ( ) ;
192230 const { message } = App . useApp ( ) ;
193231 const { logger } = useBAILogger ( ) ;
194232
@@ -309,14 +347,6 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
309347 ` ) ;
310348
311349 const handleOk = ( ) => {
312- // Manual validation for Prometheus preset (Form.Item has no name, so
313- // Ant Design form validation does not cover it automatically)
314- const currentMetricSource = formRef . current ?. getFieldValue ( 'metricSource' ) ;
315- if ( currentMetricSource === 'PROMETHEUS' && ! selectedPresetId ) {
316- message . error ( t ( 'autoScalingRule.PrometheusPresetRequired' ) ) ;
317- return ;
318- }
319-
320350 return formRef . current
321351 ?. validateFields ( )
322352 . then ( ( values ) => {
@@ -344,16 +374,16 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
344374
345375 // Determine prometheusQueryPresetId
346376 const prometheusQueryPresetId =
347- values . metricSource === 'PROMETHEUS' && selectedPresetId
348- ? toLocalId ( selectedPresetId )
377+ values . metricSource === 'PROMETHEUS' && values . prometheusQueryPresetId
378+ ? toLocalId ( values . prometheusQueryPresetId )
349379 : null ;
350380
351381 if ( autoScalingRule ) {
352382 // Update existing rule
353383 commitUpdateMutation ( {
354384 variables : {
355385 input : {
356- id : autoScalingRule . id ,
386+ id : toLocalId ( autoScalingRule . id ) ,
357387 metricSource : values . metricSource ,
358388 metricName,
359389 minThreshold :
@@ -395,9 +425,9 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
395425 metricSource : values . metricSource ,
396426 metricName,
397427 minThreshold :
398- minThreshold != null ? String ( minThreshold ) : undefined ,
428+ minThreshold != null ? String ( minThreshold ) : null ,
399429 maxThreshold :
400- maxThreshold != null ? String ( maxThreshold ) : undefined ,
430+ maxThreshold != null ? String ( maxThreshold ) : null ,
401431 stepSize : values . stepSize ,
402432 timeWindow : values . timeWindow ,
403433 minReplicas : values . minReplicas ,
@@ -453,6 +483,7 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
453483 metricSource :
454484 autoScalingRule . metricSource as AutoScalingRuleFormValues [ 'metricSource' ] ,
455485 metricName : autoScalingRule . metricName ,
486+ prometheusQueryPresetId : selectedPresetId ,
456487 conditionMode : condition . mode ,
457488 direction : condition . direction ,
458489 threshold,
@@ -570,24 +601,20 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
570601 < >
571602 < Form . Item
572603 label = { t ( 'autoScalingRule.PrometheusPreset' ) }
573- required
604+ name = "prometheusQueryPresetId"
574605 rules = { [
575606 {
576- validator : ( ) => {
577- if ( ! selectedPresetId ) {
578- return Promise . reject (
579- new Error (
580- t ( 'autoScalingRule.PrometheusPresetRequired' ) ,
581- ) ,
582- ) ;
583- }
584- return Promise . resolve ( ) ;
585- } ,
607+ required : true ,
608+ message : t ( 'autoScalingRule.PrometheusPresetRequired' ) ,
586609 } ,
587610 ] }
611+ extra = {
612+ selectedPreset ? (
613+ < PrometheusPresetPreview presetGlobalId = { selectedPreset . id } />
614+ ) : undefined
615+ }
588616 >
589617 < Select
590- value = { selectedPresetId }
591618 onChange = { ( value ) => {
592619 setSelectedPresetId ( value ) ;
593620 const preset = presetNodes . find ( ( p ) => p . id === value ) ;
@@ -616,35 +643,6 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
616643 onClear = { ( ) => setSelectedPresetId ( undefined ) }
617644 />
618645 </ 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- ) }
648646 </ >
649647 ) }
650648
@@ -669,26 +667,32 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
669667 onChange = { ( value ) => {
670668 setConditionMode ( value as ConditionMode ) ;
671669 } }
672- style = { { marginBottom : 12 } }
670+ style = { { marginBottom : token . marginSM } }
673671 />
674672 </ Form . Item >
675673
676674 { conditionMode === 'single' ? (
677- < div style = { { display : 'flex' , gap : 8 , alignItems : 'start' } } >
675+ < div
676+ style = { {
677+ display : 'flex' ,
678+ gap : token . marginXS ,
679+ alignItems : 'center' ,
680+ } }
681+ >
678682 < Form . Item
679683 name = { 'direction' }
680684 noStyle
681685 rules = { [ { required : true } ] }
682686 >
683687 < Select
684- style = { { width : 120 } }
688+ style = { { width : 100 } }
685689 options = { [
686690 {
687- label : t ( 'autoScalingRule.Upper' ) ,
691+ label : 'Metric >' ,
688692 value : 'upper' ,
689693 } ,
690694 {
691- label : t ( 'autoScalingRule.Lower' ) ,
695+ label : 'Metric <' ,
692696 value : 'lower' ,
693697 } ,
694698 ] }
@@ -705,7 +709,7 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
705709 {
706710 type : 'number' ,
707711 min : 0 ,
708- message : t ( 'autoScalingRule.ThresholdMustBePositive ' ) ,
712+ message : t ( 'autoScalingRule.ThresholdMustBeNonNegative ' ) ,
709713 } ,
710714 ] }
711715 >
@@ -717,7 +721,13 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
717721 </ Form . Item >
718722 </ div >
719723 ) : (
720- < div style = { { display : 'flex' , gap : 8 , alignItems : 'start' } } >
724+ < div
725+ style = { {
726+ display : 'flex' ,
727+ gap : token . marginXS ,
728+ alignItems : 'center' ,
729+ } }
730+ >
721731 < Form . Item
722732 name = { 'minThreshold' }
723733 noStyle
@@ -729,7 +739,7 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
729739 {
730740 type : 'number' ,
731741 min : 0 ,
732- message : t ( 'autoScalingRule.ThresholdMustBePositive ' ) ,
742+ message : t ( 'autoScalingRule.ThresholdMustBeNonNegative ' ) ,
733743 } ,
734744 ] }
735745 >
@@ -739,8 +749,8 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
739749 min = { 0 }
740750 />
741751 </ Form . Item >
742- < Typography . Text style = { { lineHeight : '32px' , flexShrink : 0 } } >
743- { '<' } metric { '<' }
752+ < Typography . Text style = { { flexShrink : 0 } } >
753+ { '<' } Metric { '<' }
744754 </ Typography . Text >
745755 < Form . Item
746756 name = { 'maxThreshold' }
@@ -754,7 +764,7 @@ const AutoScalingRuleEditorModal: React.FC<AutoScalingRuleEditorModalProps> = ({
754764 {
755765 type : 'number' ,
756766 min : 0 ,
757- message : t ( 'autoScalingRule.ThresholdMustBePositive ' ) ,
767+ message : t ( 'autoScalingRule.ThresholdMustBeNonNegative ' ) ,
758768 } ,
759769 ( { getFieldValue } ) => ( {
760770 validator ( _ , value ) {
0 commit comments