Skip to content

Commit 1fe42af

Browse files
committed
fix(FR-2494): convert Relay global ID to local UUID for updateAutoScalingRule
1 parent 7298348 commit 1fe42af

23 files changed

Lines changed: 671 additions & 115 deletions

react/src/components/AutoScalingRuleEditorModal.tsx

Lines changed: 99 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,24 @@ import { ReloadOutlined } from '@ant-design/icons';
1616
import {
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';
2827
import {
28+
BAIButton,
2929
BAIModal,
3030
BAIModalProps,
3131
toLocalId,
3232
useBAILogger,
33+
useFetchKey,
3334
} from 'backend.ai-ui';
3435
import * as _ from 'lodash-es';
35-
import React, { useRef, useState } from 'react';
36+
import React, { useRef, useState, useTransition } from 'react';
3637
import { useTranslation } from 'react-i18next';
3738
import {
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

Comments
 (0)