diff --git a/react/src/components/AutoScalingRuleEditorModal.tsx b/react/src/components/AutoScalingRuleEditorModal.tsx index 0521faa7e4..bc91b00bc0 100644 --- a/react/src/components/AutoScalingRuleEditorModal.tsx +++ b/react/src/components/AutoScalingRuleEditorModal.tsx @@ -21,7 +21,7 @@ import { InputNumber, Segmented, Select, - Spin, + Skeleton, Typography, theme, } from 'antd'; @@ -30,12 +30,20 @@ import { BAIFlex, BAIModal, BAIModalProps, + INITIAL_FETCH_KEY, toLocalId, useBAILogger, - useFetchKey, + useUpdatableState, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; -import React, { useRef, useState, useTransition } from 'react'; +import React, { + RefObject, + useEffect, + useEffectEvent, + useRef, + useState, + useTransition, +} from 'react'; import { useTranslation } from 'react-i18next'; import { graphql, @@ -87,7 +95,8 @@ const METRIC_NAMES_MAP: Partial< const PreviewValue: React.FC<{ presetRawId: string; fetchKey: string; -}> = ({ presetRawId, fetchKey }) => { + onLoaded?: () => void; +}> = ({ presetRawId, fetchKey, onLoaded }) => { 'use memo'; const { t } = useTranslation(); @@ -125,6 +134,13 @@ const PreviewValue: React.FC<{ const results = data.prometheusQueryPresetResult.result; + const onLoadedEvent = useEffectEvent(() => { + onLoaded?.(); + }); + useEffect(() => { + onLoadedEvent(); + }, []); + const formatValue = (raw: string) => { const num = parseFloat(raw); return isNaN(num) ? raw : (Math.round(num * 100) / 100).toString(); @@ -168,8 +184,9 @@ const PrometheusPresetPreview: React.FC<{ 'use memo'; const { t } = useTranslation(); const { token } = theme.useToken(); - const [fetchKey, updateFetchKey] = useFetchKey(); + const [fetchKey, updateFetchKey] = useUpdatableState(INITIAL_FETCH_KEY); const [isPending, startTransition] = useTransition(); + const [isInitialLoading, setIsInitialLoading] = useState(true); const presetRawId = toLocalId(presetGlobalId); return ( @@ -180,16 +197,18 @@ const PrometheusPresetPreview: React.FC<{ > {t('autoScalingRule.CurrentValue')}:{' '} - - }> - - - + + setIsInitialLoading(false)} + /> + } - loading={isPending} + loading={isPending || isInitialLoading} onClick={() => startTransition(() => updateFetchKey())} title={t('autoScalingRule.RefreshPreview')} aria-label={t('autoScalingRule.RefreshPreview')} @@ -224,36 +243,18 @@ const getInitialConditionState = ( return { mode: 'single', direction: 'lower' }; }; -const AutoScalingRuleEditorModal: React.FC = ({ - onRequestClose, - onComplete, - modelDeploymentId, - autoScalingRuleFrgmt, - ...baiModalProps -}) => { +/** + * Inner form content — contains the presetsQuery (which may suspend on first load) + * and all form UI. Wrapped in a Suspense boundary by the outer modal component + * so Suspense does not bubble up to the page-level boundary. + */ +const AutoScalingRuleEditorModalContent: React.FC<{ + autoScalingRule: AutoScalingRuleEditorModalFragment$data | null; + formRef: RefObject>; +}> = ({ autoScalingRule, formRef }) => { 'use memo'; const { t } = useTranslation(); const { token } = theme.useToken(); - const { message } = App.useApp(); - const { logger } = useBAILogger(); - - const autoScalingRule = useFragment( - graphql` - fragment AutoScalingRuleEditorModalFragment on AutoScalingRule { - id - metricSource - metricName - minThreshold - maxThreshold - stepSize - timeWindow - minReplicas - maxReplicas - prometheusQueryPresetId - } - `, - autoScalingRuleFrgmt ?? null, - ); const { prometheusQueryPresets } = useLazyLoadQuery( @@ -287,11 +288,13 @@ const AutoScalingRuleEditorModal: React.FC = ({ const [conditionMode, setConditionMode] = useState( initialCondition.mode, ); + const [direction, setDirection] = useState( + initialCondition.direction, + ); const [selectedMetricSource, setSelectedMetricSource] = useState( autoScalingRule?.metricSource || 'KERNEL', ); const [selectedPresetId, setSelectedPresetId] = useState( - // Match existing rule's prometheusQueryPresetId to a preset's Relay global ID autoScalingRule?.prometheusQueryPresetId ? presetNodes.find( (p) => toLocalId(p.id) === autoScalingRule.prometheusQueryPresetId, @@ -328,6 +331,514 @@ const AutoScalingRuleEditorModal: React.FC = ({ description: preset.description, })); + // Build initial form values from existing rule data + const getInitialValues = (): Partial => { + if (autoScalingRule) { + const condition = getInitialConditionState(autoScalingRule); + let threshold: number | undefined; + if (condition.mode === 'single') { + // 'lower' ('<') → maxThreshold; 'upper' ('>') → minThreshold + threshold = + condition.direction === 'lower' + ? autoScalingRule.maxThreshold != null + ? Number(autoScalingRule.maxThreshold) + : undefined + : autoScalingRule.minThreshold != null + ? Number(autoScalingRule.minThreshold) + : undefined; + } + return { + metricSource: + autoScalingRule.metricSource as AutoScalingRuleFormValues['metricSource'], + metricName: autoScalingRule.metricName, + prometheusQueryPresetId: selectedPresetId, + conditionMode: condition.mode, + direction: condition.direction, + threshold, + minThreshold: + autoScalingRule.minThreshold != null + ? Number(autoScalingRule.minThreshold) + : undefined, + maxThreshold: + autoScalingRule.maxThreshold != null + ? Number(autoScalingRule.maxThreshold) + : undefined, + stepSize: Math.abs(autoScalingRule.stepSize), + timeWindow: autoScalingRule.timeWindow, + minReplicas: autoScalingRule.minReplicas ?? undefined, + maxReplicas: autoScalingRule.maxReplicas ?? undefined, + }; + } + return { + metricSource: 'KERNEL', + conditionMode: 'single', + direction: 'lower', + stepSize: 1, + timeWindow: 300, + minReplicas: 0, + maxReplicas: 5, + }; + }; + + const isPrometheus = selectedMetricSource === 'PROMETHEUS'; + + return ( +
+ {/* Metric Source */} + + { + setSelectedPresetId(value); + const preset = presetNodes.find((p) => p.id === value); + if (preset) { + // Auto-fill metricName + formRef.current?.setFieldsValue({ + metricName: preset.metricName, + }); + // Auto-apply timeWindow from preset only when the preset + // provides a valid value; otherwise keep the existing value + // (e.g. the default 300) to avoid unexpected clearing. + const tw = + preset.timeWindow != null + ? Number(preset.timeWindow) + : undefined; + if (tw != null && !isNaN(tw)) { + formRef.current?.setFieldsValue({ timeWindow: tw }); + } + } + }} + placeholder={t('autoScalingRule.SelectPrometheusPreset')} + showSearch={{ + filterOption: (input, option) => + String(option?.label ?? '') + .toLowerCase() + .includes(input.toLowerCase()), + }} + options={presetOptions} + optionRender={(option) => ( + + {option.label} + {option.data.description && ( + + {option.data.description} + + )} + + )} + allowClear + onClear={() => setSelectedPresetId(undefined)} + /> + + + )} + + {/* Condition Mode (Single / Range) */} + + + { + setConditionMode(value as ConditionMode); + }} + style={{ marginBottom: token.marginSM }} + /> + + + {conditionMode === 'single' ? ( +
+ + {t('autoScalingRule.Metric')} + + + { - setSelectedMetricSource(value); - // Clear metricName whenever source changes (issue: stale name from previous source) - formRef.current?.setFieldsValue({ metricName: undefined }); - if (value !== 'PROMETHEUS') { - setNameOptions( - METRIC_NAMES_MAP[value as keyof typeof METRIC_NAMES_MAP] || - [], - ); - setSelectedPresetId(undefined); - } else { - // Restore selectedPresetId state from form value when switching back to PROMETHEUS, - // otherwise the preview won't appear even after a preset was previously chosen. - const existingPresetId = formRef.current?.getFieldValue( - 'prometheusQueryPresetId', - ); - if (existingPresetId) { - setSelectedPresetId(existingPresetId); - } - } - }} - options={[ - { - label: t('autoScalingRule.MetricSourceKernel'), - value: 'KERNEL', - }, - { - label: t('autoScalingRule.MetricSourceInferenceFramework'), - value: 'INFERENCE_FRAMEWORK', - }, - { - label: t('autoScalingRule.MetricSourcePrometheus'), - value: 'PROMETHEUS', - }, - ]} - /> - - - {/* Metric Name (KERNEL / INFERENCE_FRAMEWORK) */} - {!isPrometheus && ( - - ({ - label: name, - value: name, - }))} - showSearch={{ - onSearch: (text) => { - const source = (formRef.current?.getFieldValue( - 'metricSource', - ) || 'KERNEL') as keyof typeof METRIC_NAMES_MAP; - setNameOptions( - _.filter(METRIC_NAMES_MAP[source] || [], (name) => - name.includes(text), - ), - ); - }, - }} - allowClear - popupMatchSelectWidth={false} - /> - - )} - - {/* Prometheus Preset (PROMETHEUS only) */} - {isPrometheus && ( - <> - - ) : undefined - } - > - ', - value: 'upper', - }, - { - label: '<', - value: 'lower', - }, - ]} - /> - - - - -
- ) : ( -
- - - - - {'<'} {t('autoScalingRule.Metric')} {'<'} - - ({ - validator(_, value) { - const min = getFieldValue('minThreshold'); - if (min != null && value != null && min >= value) { - return Promise.reject( - new Error(t('autoScalingRule.MinMustBeLessThanMax')), - ); - } - return Promise.resolve(); - }, - }), - ]} - > - - -
- )} -
- - {/* Step Size */} - { - if (value % 1 !== 0) { - return Promise.reject( - new Error(t('error.OnlyPositiveIntegersAreAllowed')), - ); - } - return Promise.resolve(); - }, - }, - ]} - > - - - - {/* Time Window (seconds) */} - { - if (value % 1 !== 0) { - return Promise.reject( - new Error(t('error.OnlyPositiveIntegersAreAllowed')), - ); - } - return Promise.resolve(); - }, - }, - ]} - tooltip={t('autoScalingRule.TimeWindowTooltip')} - > - - - - {/* Min Replicas */} - { - if (value != null && value % 1 !== 0) { - return Promise.reject( - new Error(t('error.OnlyPositiveIntegersAreAllowed')), - ); - } - return Promise.resolve(); - }, - }, - ]} - > - - - - {/* Max Replicas */} - { - if (value != null && value % 1 !== 0) { - return Promise.reject( - new Error(t('error.OnlyPositiveIntegersAreAllowed')), - ); - } - return Promise.resolve(); - }, - }, - ]} - > - + }> + > + } /> - -
+ + ); }; diff --git a/react/src/components/AutoScalingRuleList.tsx b/react/src/components/AutoScalingRuleList.tsx index a2dab613e8..8fc7b22d28 100644 --- a/react/src/components/AutoScalingRuleList.tsx +++ b/react/src/components/AutoScalingRuleList.tsx @@ -23,6 +23,7 @@ import { BAIGraphQLPropertyFilter, BAINameActionCell, BAITable, + BAIUnmountAfterClose, filterOutNullAndUndefined, toLocalId, useFetchKey, @@ -62,18 +63,15 @@ const renderCondition = ( : rule.metricName; const minThreshold = rule.minThreshold; const maxThreshold = rule.maxThreshold; - const suffix = rule.metricSource === 'KERNEL' ? '%' : ''; if (minThreshold != null && maxThreshold != null) { return ( {minThreshold} - {suffix} {'<'} {tagLabel} {'<'} {maxThreshold} - {suffix} ); } @@ -84,7 +82,6 @@ const renderCondition = ( {tagLabel} {'<'} {maxThreshold} - {suffix} ); } @@ -93,7 +90,6 @@ const renderCondition = ( return ( {minThreshold} - {suffix} {'<'} {tagLabel} @@ -579,23 +575,27 @@ const AutoScalingRuleList: React.FC = ({ onDeleteRule={handleDeleteRule} /> - r.id === editingRuleId) ?? null) - : null - } - onRequestClose={(success) => { - setIsOpenEditorModal(false); - setEditingRuleId(null); - if (success) { - handleRefetch(); + + r.id === editingRuleId) ?? + null) + : null } - }} - /> + onRequestClose={(success) => { + setIsOpenEditorModal(false); + if (success) { + handleRefetch(); + } + }} + afterClose={() => { + setEditingRuleId(null); + }} + /> + ); };