From 6d71a28584c0b5b2efb84b146ee1dbb1afc3b1c9 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Mon, 15 Jan 2024 13:29:35 +1300 Subject: [PATCH] fix(app): improve resource quota error handling [EE-5933] (#10951) --- .../models/application/models/constants.js | 2 +- app/kubernetes/react/components/index.ts | 20 ++++---- .../create/createApplication.html | 5 +- .../create/createApplicationController.js | 20 ++++++-- .../DetailsView/ApplicationSummaryWidget.tsx | 10 +++- .../AppDeploymentTypeFormSection.tsx | 21 +++++--- .../deploymentTypeValidation.ts | 19 +++++++ .../ResourceReservationFormSection.tsx | 50 ++++++++++--------- .../resourceReservationValidation.ts | 25 +++++++++- 9 files changed, 122 insertions(+), 50 deletions(-) rename app/react/kubernetes/applications/{CreateView => components/AppDeploymentTypeFormSection}/AppDeploymentTypeFormSection.tsx (62%) create mode 100644 app/react/kubernetes/applications/components/AppDeploymentTypeFormSection/deploymentTypeValidation.ts diff --git a/app/kubernetes/models/application/models/constants.js b/app/kubernetes/models/application/models/constants.js index d4caab60e..74f9767af 100644 --- a/app/kubernetes/models/application/models/constants.js +++ b/app/kubernetes/models/application/models/constants.js @@ -1,6 +1,6 @@ export const KubernetesApplicationQuotaDefaults = { CpuLimit: 0.1, - MemoryLimit: 64, // MB + MemoryLimit: 128, // MB }; export const KubernetesPortainerApplicationStackNameLabel = 'io.portainer.kubernetes.application.stack'; diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 38f6d0bfc..4ad90edd8 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -7,7 +7,6 @@ import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/Acce import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector'; import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm'; import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation'; -import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/CreateView/AppDeploymentTypeFormSection'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { @@ -56,6 +55,8 @@ import { NameFormSection, appNameValidation, } from '@/react/kubernetes/applications/components/NameFormSection'; +import { deploymentTypeValidation } from '@/react/kubernetes/applications/components/AppDeploymentTypeFormSection/deploymentTypeValidation'; +import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/components/AppDeploymentTypeFormSection/AppDeploymentTypeFormSection'; import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset'; @@ -122,14 +123,6 @@ export const ngModule = angular 'persistedFoldersUseExistingVolumes', ]) ) - .component( - 'appDeploymentTypeFormSection', - r2a(AppDeploymentTypeFormSection, [ - 'value', - 'onChange', - 'supportGlobalDeployment', - ]) - ) .component( 'kubeYamlInspector', r2a(withUIRouter(withReactQuery(withCurrentUser(YAMLInspector))), [ @@ -333,3 +326,12 @@ withFormValidation( appNameValidation, true ); + +withFormValidation( + ngModule, + AppDeploymentTypeFormSection, + 'appDeploymentTypeFormSection', + ['supportGlobalDeployment'], + deploymentTypeValidation, + true +); diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 5373e20a4..9a3d789e0 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -294,16 +294,17 @@ namespace-has-quota="ctrl.state.resourcePoolHasQuota" max-memory-limit="ctrl.state.sliders.memory.max" max-cpu-limit="ctrl.state.sliders.cpu.max" - validation-data="{maxMemoryLimit: ctrl.state.sliders.memory.max, maxCpuLimit: ctrl.state.sliders.cpu.max}" + validation-data="{maxMemoryLimit: ctrl.state.sliders.memory.max, maxCpuLimit: ctrl.state.sliders.cpu.max, isEnvironmentAdmin: ctrl.state.isEnvironmentAdmin}" resource-quota-capacity-exceeded="ctrl.resourceQuotaCapacityExceeded()" > diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js index 77d5da03f..37c3ef8e3 100644 --- a/app/kubernetes/views/applications/create/createApplicationController.js +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -994,13 +994,28 @@ class KubernetesCreateApplicationController { this.state.nodes.cpu += item.CPU; }); + var namespace = ''; + this.formValues.ResourcePool = this.resourcePools[0]; + if (this.resourcePools.length) { - this.namespaceWithQuota = await this.KubernetesResourcePoolService.get(this.resourcePools[0].Namespace.Name); + if (this.state.isEdit) { + namespace = this.$state.params.namespace; + this.formValues.ResourcePool = _.find(this.resourcePools, ['Namespace.Name', namespace]); + } + + namespace = this.formValues.ResourcePool.Namespace.Name; + this.namespaceWithQuota = await this.KubernetesResourcePoolService.get(namespace); this.formValues.ResourcePool.Quota = this.namespaceWithQuota.Quota; + + // this.savedFormValues is being used in updateNamespaceLimits behind a check to see isEdit + if (this.state.isEdit) { + this.savedFormValues = angular.copy(this.formValues); + } + this.updateNamespaceLimits(this.namespaceWithQuota); this.updateSliders(this.namespaceWithQuota); } - this.formValues.ResourcePool = this.resourcePools[0]; + if (!this.formValues.ResourcePool) { return; } @@ -1008,7 +1023,6 @@ class KubernetesCreateApplicationController { this.nodesLabels = KubernetesNodeHelper.generateNodeLabelsFromNodes(nodes); this.nodeNumber = nodes.length; - const namespace = this.state.isEdit ? this.$state.params.namespace : this.formValues.ResourcePool.Namespace.Name; await this.refreshNamespaceData(namespace); if (this.state.isEdit) { diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx index 1544ff579..44fddd4c2 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx @@ -97,10 +97,16 @@ export function ApplicationSummaryWidget() { <> {failedCreateCondition && (
- +
+ +
Failed to create application diff --git a/app/react/kubernetes/applications/CreateView/AppDeploymentTypeFormSection.tsx b/app/react/kubernetes/applications/components/AppDeploymentTypeFormSection/AppDeploymentTypeFormSection.tsx similarity index 62% rename from app/react/kubernetes/applications/CreateView/AppDeploymentTypeFormSection.tsx rename to app/react/kubernetes/applications/components/AppDeploymentTypeFormSection/AppDeploymentTypeFormSection.tsx index 0b099ca24..764fb64f0 100644 --- a/app/react/kubernetes/applications/CreateView/AppDeploymentTypeFormSection.tsx +++ b/app/react/kubernetes/applications/components/AppDeploymentTypeFormSection/AppDeploymentTypeFormSection.tsx @@ -1,21 +1,25 @@ +import { FormikErrors } from 'formik'; + import { BoxSelector } from '@@/BoxSelector'; import { FormSection } from '@@/form-components/FormSection'; import { TextTip } from '@@/Tip/TextTip'; +import { FormError } from '@@/form-components/FormError'; -import { DeploymentType } from '../types'; - -import { getDeploymentOptions } from './deploymentOptions'; +import { DeploymentType } from '../../types'; +import { getDeploymentOptions } from '../../CreateView/deploymentOptions'; interface Props { - value: DeploymentType; - onChange(value: DeploymentType): void; + values: DeploymentType; + onChange(values: DeploymentType): void; + errors: FormikErrors; supportGlobalDeployment: boolean; } export function AppDeploymentTypeFormSection({ - supportGlobalDeployment, - value, + values, onChange, + errors, + supportGlobalDeployment, }: Props) { const options = getDeploymentOptions(supportGlobalDeployment); @@ -27,10 +31,11 @@ export function AppDeploymentTypeFormSection({ + {!!errors && {errors}} ); } diff --git a/app/react/kubernetes/applications/components/AppDeploymentTypeFormSection/deploymentTypeValidation.ts b/app/react/kubernetes/applications/components/AppDeploymentTypeFormSection/deploymentTypeValidation.ts new file mode 100644 index 000000000..beb342735 --- /dev/null +++ b/app/react/kubernetes/applications/components/AppDeploymentTypeFormSection/deploymentTypeValidation.ts @@ -0,0 +1,19 @@ +import { SchemaOf, mixed } from 'yup'; + +import { DeploymentType } from '../../types'; + +type ValidationData = { + isQuotaExceeded: boolean; +}; + +export function deploymentTypeValidation( + validationData?: ValidationData +): SchemaOf { + return mixed() + .oneOf(['Replicated', 'Global']) + .test( + 'exhaused', + `This application would exceed available resources. Please review resource reservations or the instance count.`, + () => !validationData?.isQuotaExceeded + ); +} diff --git a/app/react/kubernetes/applications/components/ResourceReservationFormSection/ResourceReservationFormSection.tsx b/app/react/kubernetes/applications/components/ResourceReservationFormSection/ResourceReservationFormSection.tsx index 802417f97..ef38d63dc 100644 --- a/app/react/kubernetes/applications/components/ResourceReservationFormSection/ResourceReservationFormSection.tsx +++ b/app/react/kubernetes/applications/components/ResourceReservationFormSection/ResourceReservationFormSection.tsx @@ -55,14 +55,16 @@ export function ResourceReservationFormSection({ tooltip="An instance of this application will reserve this amount of memory. If the instance memory usage exceeds the reservation, it might be subject to OOM." >
- onChange({ ...values, memoryLimit: value })} - max={maxMemoryLimit} - step={128} - dataCy="k8sAppCreate-memoryLimit" - visibleTooltip - /> + {maxMemoryLimit > 0 && ( + onChange({ ...values, memoryLimit: value })} + max={maxMemoryLimit} + step={128} + dataCy="k8sAppCreate-memoryLimit" + visibleTooltip + /> + )} {errors?.memoryLimit && ( {errors.memoryLimit} )} @@ -74,21 +76,23 @@ export function ResourceReservationFormSection({ tooltip="An instance of this application will reserve this amount of CPU. If the instance CPU usage exceeds the reservation, it might be subject to CPU throttling." >
- - onChange( - typeof value === 'number' - ? { ...values, cpuLimit: value } - : { ...values, cpuLimit: value[0] ?? 0 } - ) - } - value={values.cpuLimit} - min={0} - max={maxCpuLimit} - step={0.01} - dataCy="k8sAppCreate-cpuLimitSlider" - visibleTooltip - /> + {maxCpuLimit > 0 && ( + + onChange( + typeof value === 'number' + ? { ...values, cpuLimit: value } + : { ...values, cpuLimit: value[0] ?? 0 } + ) + } + value={values.cpuLimit} + min={0} + max={maxCpuLimit} + step={0.1} + dataCy="k8sAppCreate-cpuLimitSlider" + visibleTooltip + /> + )} {errors?.cpuLimit && ( {errors.cpuLimit} )} diff --git a/app/react/kubernetes/applications/components/ResourceReservationFormSection/resourceReservationValidation.ts b/app/react/kubernetes/applications/components/ResourceReservationFormSection/resourceReservationValidation.ts index 11b5d708d..9b2791768 100644 --- a/app/react/kubernetes/applications/components/ResourceReservationFormSection/resourceReservationValidation.ts +++ b/app/react/kubernetes/applications/components/ResourceReservationFormSection/resourceReservationValidation.ts @@ -5,6 +5,7 @@ import { ResourceQuotaFormValues } from './types'; type ValidationData = { maxMemoryLimit: number; maxCpuLimit: number; + isEnvironmentAdmin: boolean; }; export function resourceReservationValidation( @@ -13,16 +14,36 @@ export function resourceReservationValidation( return object().shape({ memoryLimit: number() .min(0) + .test( + 'exhaused', + `The memory capacity for this namespace has been exhausted, so you cannot deploy the application.${ + validationData?.isEnvironmentAdmin + ? '' + : ' Contact your administrator to expand the memory capacity of the namespace.' + }`, + () => !!validationData && validationData.maxMemoryLimit > 0 + ) .max( validationData?.maxMemoryLimit || 0, - `Value must be between 0 and ${validationData?.maxMemoryLimit}` + ({ value }) => + `Value must be between 0 and ${validationData?.maxMemoryLimit}MB now - the previous value of ${value} exceeds this` ) .required(), cpuLimit: number() .min(0) + .test( + 'exhaused', + `The CPU capacity for this namespace has been exhausted, so you cannot deploy the application.${ + validationData?.isEnvironmentAdmin + ? '' + : ' Contact your administrator to expand the CPU capacity of the namespace.' + }`, + () => !!validationData && validationData.maxCpuLimit > 0 + ) .max( validationData?.maxCpuLimit || 0, - `Value must be between 0 and ${validationData?.maxCpuLimit}` + ({ value }) => + `Value must be between 0 and ${validationData?.maxCpuLimit} now - the previous value of ${value} exceeds this` ) .required(), });