diff --git a/react/src/components/AutoScalingRuleList.tsx b/react/src/components/AutoScalingRuleList.tsx new file mode 100644 index 0000000000..0c5c57a1c2 --- /dev/null +++ b/react/src/components/AutoScalingRuleList.tsx @@ -0,0 +1,220 @@ +import QuestionIconWithTooltip from './QuestionIconWithTooltip'; +import { DeleteOutlined, SettingOutlined } from '@ant-design/icons'; +import { Button, ConfigProvider, Empty, theme, Typography } from 'antd'; +import { + BAITable, + filterOutNullAndUndefined, + filterOutEmpty, + BAIColumnType, + BAIFlex, +} from 'backend.ai-ui'; +import dayjs from 'dayjs'; +import { useTranslation } from 'react-i18next'; +import { graphql, useFragment } from 'react-relay'; +import { + AutoScalingRuleListFragment$key, + AutoScalingRuleListFragment$data, +} from 'src/__generated__/AutoScalingRuleListFragment.graphql'; +import { formatDuration } from 'src/helper'; + +export type AutoScalingRuleInList = NonNullable< + AutoScalingRuleListFragment$data[number] +>; + +interface AutoScalingRuleListProps { + autoScalingRulesFrgmt?: AutoScalingRuleListFragment$key; + onRequestSettingAutoScalingRule: (record?: AutoScalingRuleInList) => void; + onRequestDelete: (record: AutoScalingRuleInList) => void; +} + +const AutoScalingRuleList: React.FC = ({ + autoScalingRulesFrgmt, + onRequestSettingAutoScalingRule, + onRequestDelete, +}) => { + const { t } = useTranslation(); + const { token } = theme.useToken(); + + const autoScalingRules = useFragment( + graphql` + fragment AutoScalingRuleListFragment on AutoScalingRule + @relay(plural: true) { + id @required(action: THROW) + metricSource + metricName + minThreshold + maxThreshold + stepSize + timeWindow + minReplicas + maxReplicas + createdAt + lastTriggeredAt + } + `, + autoScalingRulesFrgmt, + ); + + const filteredAutoScalingRules = filterOutNullAndUndefined(autoScalingRules); + + const columns = filterOutEmpty>([ + { + key: 'metricSource', + dataIndex: 'metricSource', + title: t('deployment.MetricSource'), + fixed: 'left', + render: (metricSource) => ( + + {metricSource === 'KERNEL' ? 'Kernel' : 'Inference Framework'} + + ), + }, + { + key: 'metricName', + dataIndex: 'metricName', + title: t('deployment.MetricName'), + render: (metricName) => {metricName}, + }, + { + key: 'controls', + title: t('general.Control'), + render: (_, record) => ( + + + + )} + > + + + ); +}; + +export default AutoScalingRuleList; diff --git a/react/src/components/AutoScalingRuleSettingModal.tsx b/react/src/components/AutoScalingRuleSettingModal.tsx new file mode 100644 index 0000000000..5ee23a2a05 --- /dev/null +++ b/react/src/components/AutoScalingRuleSettingModal.tsx @@ -0,0 +1,355 @@ +import BAIModal, { BAIModalProps } from './BAIModal'; +import { + Form, + Input, + InputNumber, + Select, + FormInstance, + App, + Card, +} from 'antd'; +import { BAIFlex } from 'backend.ai-ui'; +import _ from 'lodash'; +import { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useFragment, useMutation } from 'react-relay'; +import { + AutoScalingRuleSettingModalCreateMutation, + AutoScalingRuleSettingModalCreateMutation$variables, +} from 'src/__generated__/AutoScalingRuleSettingModalCreateMutation.graphql'; +import { AutoScalingRuleSettingModalFragment$key } from 'src/__generated__/AutoScalingRuleSettingModalFragment.graphql'; +import { + AutoScalingRuleSettingModalUpdateMutation, + AutoScalingRuleSettingModalUpdateMutation$variables, +} from 'src/__generated__/AutoScalingRuleSettingModalUpdateMutation.graphql'; + +interface AutoScalingRuleSettingModalProps extends BAIModalProps { + deploymentId?: string; + autoScalingRuleFrgmt?: AutoScalingRuleSettingModalFragment$key | null; + onRequestClose(success?: boolean): void; +} + +type CreateAutoScalingRuleFormValue = + AutoScalingRuleSettingModalCreateMutation$variables['input']; +type UpdateAutoScalingRuleFormValue = + AutoScalingRuleSettingModalUpdateMutation$variables['input']; + +const AutoScalingRuleSettingModal: React.FC< + AutoScalingRuleSettingModalProps +> = ({ + deploymentId, + autoScalingRuleFrgmt = null, + onRequestClose, + ...baiModalProps +}) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const formRef = + useRef< + FormInstance< + CreateAutoScalingRuleFormValue | UpdateAutoScalingRuleFormValue + > + >(null); + const autoScalingRule = useFragment( + graphql` + fragment AutoScalingRuleSettingModalFragment on AutoScalingRule { + id + metricSource + metricName + minThreshold + maxThreshold + stepSize + timeWindow + minReplicas + maxReplicas + } + `, + autoScalingRuleFrgmt, + ); + + const [commitCreateAutoScalingRule, isInFlightCreateAutoScalingRule] = + useMutation(graphql` + mutation AutoScalingRuleSettingModalCreateMutation( + $input: CreateAutoScalingRuleInput! + ) { + createAutoScalingRule(input: $input) { + autoScalingRule { + id + } + } + } + `); + + const [commitUpdateAutoScalingRule, isInFlightUpdateAutoScalingRule] = + useMutation(graphql` + mutation AutoScalingRuleSettingModalUpdateMutation( + $input: UpdateAutoScalingRuleInput! + ) { + updateAutoScalingRule(input: $input) { + autoScalingRule { + id + } + } + } + `); + + const handleOk = () => { + formRef.current + ?.validateFields() + .then((values) => { + if (autoScalingRule) { + const updateInput: UpdateAutoScalingRuleFormValue = { + id: autoScalingRule?.id || '', + ...values, + }; + commitUpdateAutoScalingRule({ + variables: { input: updateInput }, + onCompleted: (res, errors) => { + if (_.isEmpty(res?.updateAutoScalingRule?.autoScalingRule?.id)) { + message.error( + t('message.FailedToDelete', { + name: t('deployment.AutoScalingRule'), + }), + ); + return; + } + if (errors && errors.length > 0) { + const errorMsgList = _.map(errors, (error) => error.message); + for (const error of errorMsgList) { + message.error(error); + } + } else { + message.success( + t('message.SuccessfullyUpdated', { + name: t('deployment.AutoScalingRule'), + }), + ); + onRequestClose(true); + } + }, + onError: (err) => { + message.error( + err.message || + t('message.FailedToDelete', { + name: t('deployment.AutoScalingRule'), + }), + ); + }, + }); + } else { + const createInput: CreateAutoScalingRuleFormValue = { + modelDeploymentId: deploymentId || '', + metricName: values.metricName ?? '', + metricSource: values.metricSource ?? 'KERNEL', + minThreshold: values.minThreshold, + maxThreshold: values.maxThreshold, + stepSize: values.stepSize ?? 1, + timeWindow: values.timeWindow ?? 0, + minReplicas: values.minReplicas, + maxReplicas: values.maxReplicas, + }; + commitCreateAutoScalingRule({ + variables: { + input: createInput, + }, + onCompleted: (res, errors) => { + if (_.isEmpty(res?.createAutoScalingRule?.autoScalingRule?.id)) { + message.error(t('deployment.AutoScalingRuleCreationFailed')); + return; + } + if (errors && errors.length > 0) { + const errorMsgList = _.map(errors, (error) => error.message); + for (const error of errorMsgList) { + message.error(error); + } + } else { + message.success(t('deployment.AutoScalingRuleCreated')); + onRequestClose(true); + } + }, + onError: (err) => { + message.error( + err.message || t('deployment.AutoScalingRuleCreationFailed'), + ); + }, + }); + } + }) + .catch(() => {}); + }; + + return ( + onRequestClose(false)} + okText={autoScalingRule ? t('button.Update') : t('button.Create')} + cancelText={t('button.Cancel')} + confirmLoading={ + isInFlightCreateAutoScalingRule || isInFlightUpdateAutoScalingRule + } + destroyOnClose + {...baiModalProps} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +export default AutoScalingRuleSettingModal; diff --git a/react/src/components/DeploymentModifyModal.tsx b/react/src/components/DeploymentModifyModal.tsx index 655d9cebb4..0a6ee4761e 100644 --- a/react/src/components/DeploymentModifyModal.tsx +++ b/react/src/components/DeploymentModifyModal.tsx @@ -90,7 +90,11 @@ const DeploymentModifyModal: React.FC = ({ }, onCompleted: (res, errors) => { if (!res?.updateModelDeployment?.deployment?.id) { - message.error(t('message.FailedToUpdate')); + message.error( + t('message.FailedToUpdate', { + name: t('deployment.launcher.Deployment'), + }), + ); return; } if (errors && errors.length > 0) { @@ -99,12 +103,21 @@ const DeploymentModifyModal: React.FC = ({ message.error(error); } } else { - message.success(t('message.SuccessfullyUpdated')); + message.success( + t('message.SuccessfullyUpdated', { + name: t('deployment.launcher.Deployment'), + }), + ); onRequestClose(true); } }, onError: (err) => { - message.error(err.message || t('message.FailedToUpdate')); + message.error( + err.message || + t('message.FailedToUpdate', { + name: t('deployment.launcher.Deployment'), + }), + ); }, }); }); diff --git a/react/src/pages/DeploymentDetailPage.tsx b/react/src/pages/DeploymentDetailPage.tsx index 12b53dbd27..6198e74b4a 100644 --- a/react/src/pages/DeploymentDetailPage.tsx +++ b/react/src/pages/DeploymentDetailPage.tsx @@ -34,17 +34,17 @@ import { Suspense, useState, useTransition } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { graphql, useLazyLoadQuery, useMutation } from 'react-relay'; import { useParams } from 'react-router-dom'; +import { DeploymentDetailPageDeleteAutoScalingRuleMutation } from 'src/__generated__/DeploymentDetailPageDeleteAutoScalingRuleMutation.graphql'; import { DeploymentDetailPageDeleteMutation } from 'src/__generated__/DeploymentDetailPageDeleteMutation.graphql'; import { DeploymentDetailPageQuery, DeploymentDetailPageQuery$data, } from 'src/__generated__/DeploymentDetailPageQuery.graphql'; -<<<<<<< HEAD import { DeploymentDetailPageSetActiveRevisionMutation } from 'src/__generated__/DeploymentDetailPageSetActiveRevisionMutation.graphql'; import { DeploymentDetailPageSyncReplicasMutation } from 'src/__generated__/DeploymentDetailPageSyncReplicasMutation.graphql'; -======= +import AutoScalingRuleList from 'src/components/AutoScalingRuleList'; +import AutoScalingRuleSettingModal from 'src/components/AutoScalingRuleSettingModal'; import DeploymentModifyModal from 'src/components/DeploymentModifyModal'; ->>>>>>> cc7bb3933 (feat(FR-1407): add modify and delete action buttons for deployment detail page) import DeploymentTokenGenerationModal from 'src/components/DeploymentTokenGenerationModal'; import ReplicaList from 'src/components/ReplicaList'; import RevisionCreationModal from 'src/components/RevisionCreationModal'; @@ -57,6 +57,12 @@ type RevisionNodeType = NonNullableNodeOnEdges< NonNullable['revisionHistory'] >; +type AutoScalingRuleNodeType = NonNullable< + NonNullable< + NonNullable['scalingRule'] + >['autoScalingRules'] +>[number]; + const DeploymentDetailPage: React.FC = () => { const { t } = useTranslation(); const { message, modal } = App.useApp(); @@ -67,12 +73,18 @@ const DeploymentDetailPage: React.FC = () => { const [fetchKey, updateFetchKey] = useFetchKey(); const [selectedRevision, setSelectedRevision] = useState(null); + const [selectedAutoScalingRule, setSelectedAutoScalingRule] = + useState(null); const [isRevisionCreationModalOpen, { toggle: toggleRevisionCreationModal }] = useToggle(); const [isTokenGenerationModalOpen, { toggle: toggleTokenGenerationModal }] = useToggle(); const [isModifyModalOpen, { toggle: toggleModifyModal }] = useToggle(); const [isDeleteModalOpen, { toggle: toggleDeleteModal }] = useToggle(); + const [ + isSetAutoScalingRuleModalOpen, + { toggle: toggleSetAutoScalingRuleModal }, + ] = useToggle(); const { deployment } = useLazyLoadQuery( graphql` @@ -99,16 +111,6 @@ const DeploymentDetailPage: React.FC = () => { defaultDeploymentStrategy { type } - revision { - id - name - modelRuntimeConfig { - runtimeVariant - inferenceRuntimeConfig - environ - } - createdAt - } replicaState { desiredReplicaCount replicas { @@ -136,6 +138,13 @@ const DeploymentDetailPage: React.FC = () => { createdUser { email } + scalingRule { + autoScalingRules { + id + ...AutoScalingRuleListFragment + ...AutoScalingRuleSettingModalFragment + } + } ...DeploymentModifyModalFragment } } @@ -185,6 +194,17 @@ const DeploymentDetailPage: React.FC = () => { } `); + const [commitDeleteAutoScalingRule, isInFlightDeleteAutoScalingRule] = + useMutation(graphql` + mutation DeploymentDetailPageDeleteAutoScalingRuleMutation( + $input: DeleteAutoScalingRuleInput! + ) { + deleteAutoScalingRule(input: $input) { + id + } + } + `); + const deploymentInfoItems: DescriptionsProps['items'] = [ { key: 'name', @@ -492,7 +512,86 @@ const DeploymentDetailPage: React.FC = () => {
)} {curTabKey === 'autoScalingRules' && ( -
TODO: implement table or description
+ { + if (record) { + setSelectedAutoScalingRule( + _.find( + deployment?.scalingRule?.autoScalingRules, + (rule) => rule.id === record.id, + ) || null, + ); + } + toggleSetAutoScalingRuleModal(); + }} + onRequestDelete={(record) => { + if (!record?.id) { + message.error( + t('message.FailedToDelete', { + name: t('deployment.AutoScalingRule'), + }), + ); + return; + } + modal.confirm({ + title: t('deployment.DeleteAutoScalingRule'), + content: t('dialog.ask.DoYouWantToDeleteSomething', { + name: record?.metricName || t('deployment.AutoScalingRule'), + }), + okButtonProps: { + loading: isInFlightDeleteAutoScalingRule, + danger: true, + }, + okText: t('button.Delete'), + onOk: () => { + commitDeleteAutoScalingRule({ + variables: { + input: { + id: toLocalId(record.id), + }, + }, + onCompleted: (res, errors) => { + if (!res?.deleteAutoScalingRule?.id) { + message.error( + t('message.FailedToDelete', { + name: t('deployment.AutoScalingRule'), + }), + ); + return; + } + if (errors && errors.length > 0) { + const errorMsgList = _.map( + errors, + (error) => error.message, + ); + for (const error of errorMsgList) { + message.error(error); + } + } else { + message.success( + t('message.SuccessfullyDeleted', { + name: t('deployment.AutoScalingRule'), + }), + ); + startRefetchTransition(() => { + updateFetchKey(); + }); + } + }, + onError: (err) => { + message.error( + err.message || + t('message.FailedToDelete', { + name: t('deployment.AutoScalingRule'), + }), + ); + }, + }); + }, + }); + }} + /> )} {curTabKey === 'replicas' && ( @@ -597,7 +696,22 @@ const DeploymentDetailPage: React.FC = () => { }} /> - + + { + if (success) { + startRefetchTransition(() => { + updateFetchKey(); + }); + } + setSelectedAutoScalingRule(null); + toggleSetAutoScalingRuleModal(); + }} + /> + { @@ -609,7 +723,11 @@ const DeploymentDetailPage: React.FC = () => { }, onCompleted: (res, errors) => { if (!res?.deleteModelDeployment?.deployment?.id) { - message.error(t('message.FailedToDelete')); + message.error( + t('message.FailedToDelete', { + name: t('deployment.AutoScalingRule'), + }), + ); return; } if (errors && errors.length > 0) { @@ -618,11 +736,20 @@ const DeploymentDetailPage: React.FC = () => { message.error(error); } } else { - message.success(t('message.SuccessfullyDeleted')); + message.success( + t('message.SuccessfullyDeleted', { + name: t('deployment.AutoScalingRule'), + }), + ); } }, onError: (err) => { - message.error(err.message || t('message.FailedToDelete')); + message.error( + err.message || + t('message.FailedToDelete', { + name: t('deployment.AutoScalingRule'), + }), + ); }, }); toggleDeleteModal(); diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 0c27825274..6fa89a7704 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -497,10 +497,12 @@ "ActiveReplicas": "Active Replicas", "ActiveRevision": "Active Revision", "ActiveRevisions": "Active Revisions", + "AutoScalingRule": "Auto Scaling Rule", "AutoScalingRules": "Auto Scaling Rules", "CheckDomainAvailability": "Check domain availability", "ClusterSize": "Cluster Size", "ConfirmUpdateActiveRevision": "Are you sure you want to set the \"{{name}}\" revision as the current active revision?", + "CreateAutoScalingRule": "Create Auto Scaling Rule", "CreateDeployment": "Create Deployment", "CreateNewRevision": "Create New Revision", "CreateRevision": "Create Revision", @@ -512,6 +514,7 @@ "CustomExpirationDate": "Custom Expiration Date", "CustomExpiredDate": "Custom Expiration Date", "DefaultDeploymentStrategy": "Deployment Strategy", + "DeleteAutoScalingRule": "Delete Auto Scaling Rule", "DeleteDeployment": "Delete Deployment", "DeploymentAndNetwork": "Deployment & Network", "DeploymentDetail": "Deployment Detail", @@ -538,12 +541,24 @@ "Hibernate": "Hibernate", "ImageName": "Image Name", "Inactive": "Inactive", + "LastTriggeredAt": "Last Triggered At", "LivenessStatus": "Liveness Status", "LivenessStatusDesc": "Indicates whether the deployment is running and healthy.", + "MaxReplicas": "Max Replicas", + "MaxReplicasDesc": "Maximum number of replicas allowed.", + "MaxThreshold": "Max Threshold", + "MaxThresholdDesc": "Maximum metric value that can trigger scale-out.", + "MetricName": "Metric Name", + "MetricSource": "Metric Source", + "MinReplicas": "Min Replicas", + "MinReplicasDesc": "Minimum number of replicas allowed.", + "MinThreshold": "Min Threshold", + "MinThresholdDesc": "Minimum metric value that can trigger scale-in.", "Mode": "Mode", "ModeRequired": "Mode selection is required", "ModeWarning": "Mode cannot be changed after creation", "ModeWarningDescription": "Please choose carefully as this setting is permanent", + "ModifyAutoScalingRule": "Modify Auto Scaling Rule", "ModifyDeployment": "Modify Deployment", "Name": "Name", "NumberOfDesiredReplicas": "Number of Desired Replicas", @@ -555,6 +570,7 @@ "ReadinessStatusDesc": "Indicates whether the deployment is ready to receive traffic.", "ReplicaError": "Replica Error", "ReplicaID": "Replica ID", + "ReplicaLimits": "Replica Limits", "ReplicaLog": "Replica Log", "Replicas": "Replicas", "Resources": "Resources", @@ -564,16 +580,23 @@ "Revisions": "Revisions", "RoutesInfo": "Routes Info", "RoutingID": "Routing ID", + "ScalingSettings": "Scaling Settings", "SessionID": "Session ID", "SetAsActiveRevision": "Set as active revision", + "SetAutoScalingRule": "Set Auto Scaling Rule", "SimpleMode": "Simple Mode", "SimpleModeDescription": "Preset based", "SimpleModeTooltip": "Simple mode uses predefined presets for easy deployment", "Status": "Status", + "StepSize": "Step Size", + "StepSizeDesc": "Number of replicas to add or remove when scaling.", "SyncReplicas": "Sync replicas", "SyncReplicasFailed": "The replica synchronization request failed.", "SyncReplicasRequested": "The replica synchronization requested.", "Tags": "Tags", + "ThresholdSettings": "Threshold Settings", + "TimeWindow": "Time Window", + "TimeWindowDesc": "Duration over which metrics are evaluated before triggering scaling.", "Token": "Token", "TokenExpiredDateError": "The token expiration time must be after the current time", "TokenGenerated": "Token generated successfully", @@ -1116,10 +1139,10 @@ "64chars": "(maximum 64 chars)" }, "message": { - "FailedToDelete": "Failed to delete", - "FailedToUpdate": "Failed to update", - "SuccessfullyDeleted": "Successfully deleted", - "SuccessfullyUpdated": "Successfully updated" + "FailedToDelete": "Failed to delete {{ name }}", + "FailedToUpdate": "Failed to update {{ name }}", + "SuccessfullyDeleted": "Successfully deleted {{ name }}", + "SuccessfullyUpdated": "Successfully updated {{ name }}" }, "modelService": { "Active": "Active",