mirror of https://github.com/portainer/portainer
refactor(app): placement form section [EE-6386] (#10818)
Co-authored-by: testa113 <testa113>pull/10908/head
parent
2d77e71085
commit
9fc7187e24
|
@ -325,7 +325,7 @@ class KubernetesApplicationConverter {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.Pods && app.Pods.length) {
|
if (app.Pods && app.Pods.length) {
|
||||||
KubernetesApplicationHelper.generatePlacementsFormValuesFromAffinity(res, app.Pods[0].Affinity, nodesLabels);
|
KubernetesApplicationHelper.generatePlacementsFormValuesFromAffinity(res, app.Pods[0].Affinity);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
|
|
@ -22,7 +22,7 @@ import {
|
||||||
KubernetesApplicationVolumeSecretPayload,
|
KubernetesApplicationVolumeSecretPayload,
|
||||||
} from 'Kubernetes/models/application/payloads';
|
} from 'Kubernetes/models/application/payloads';
|
||||||
import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper';
|
import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper';
|
||||||
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationPlacementTypes, KubernetesApplicationTypes, HelmApplication } from 'Kubernetes/models/application/models';
|
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes, HelmApplication } from 'Kubernetes/models/application/models';
|
||||||
import { KubernetesPodAffinity, KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models';
|
import { KubernetesPodAffinity, KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models';
|
||||||
import {
|
import {
|
||||||
KubernetesNodeSelectorRequirementPayload,
|
KubernetesNodeSelectorRequirementPayload,
|
||||||
|
@ -429,31 +429,29 @@ class KubernetesApplicationHelper {
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
/* #region PLACEMENTS FV <> AFFINITY */
|
/* #region PLACEMENTS FV <> AFFINITY */
|
||||||
static generatePlacementsFormValuesFromAffinity(formValues, podAffinity, nodesLabels) {
|
static generatePlacementsFormValuesFromAffinity(formValues, podAffinity) {
|
||||||
let placements = formValues.Placements;
|
let placements = formValues.Placements;
|
||||||
let type = formValues.PlacementType;
|
let type = formValues.PlacementType;
|
||||||
const affinity = podAffinity.nodeAffinity;
|
const affinity = podAffinity.nodeAffinity;
|
||||||
if (affinity && affinity.requiredDuringSchedulingIgnoredDuringExecution) {
|
if (affinity && affinity.requiredDuringSchedulingIgnoredDuringExecution) {
|
||||||
type = KubernetesApplicationPlacementTypes.MANDATORY;
|
type = 'mandatory';
|
||||||
_.forEach(affinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms, (term) => {
|
_.forEach(affinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms, (term) => {
|
||||||
_.forEach(term.matchExpressions, (exp) => {
|
_.forEach(term.matchExpressions, (exp) => {
|
||||||
const placement = new KubernetesApplicationPlacementFormValue();
|
const placement = new KubernetesApplicationPlacementFormValue();
|
||||||
const label = _.find(nodesLabels, { Key: exp.key });
|
placement.label = exp.key;
|
||||||
placement.Label = label;
|
placement.value = exp.values[0];
|
||||||
placement.Value = exp.values[0];
|
placement.isNew = false;
|
||||||
placement.IsNew = false;
|
|
||||||
placements.push(placement);
|
placements.push(placement);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else if (affinity && affinity.preferredDuringSchedulingIgnoredDuringExecution) {
|
} else if (affinity && affinity.preferredDuringSchedulingIgnoredDuringExecution) {
|
||||||
type = KubernetesApplicationPlacementTypes.PREFERRED;
|
type = 'preferred';
|
||||||
_.forEach(affinity.preferredDuringSchedulingIgnoredDuringExecution, (term) => {
|
_.forEach(affinity.preferredDuringSchedulingIgnoredDuringExecution, (term) => {
|
||||||
_.forEach(term.preference.matchExpressions, (exp) => {
|
_.forEach(term.preference.matchExpressions, (exp) => {
|
||||||
const placement = new KubernetesApplicationPlacementFormValue();
|
const placement = new KubernetesApplicationPlacementFormValue();
|
||||||
const label = _.find(nodesLabels, { Key: exp.key });
|
placement.label = exp.key;
|
||||||
placement.Label = label;
|
placement.value = exp.values[0];
|
||||||
placement.Value = exp.values[0];
|
placement.isNew = false;
|
||||||
placement.IsNew = false;
|
|
||||||
placements.push(placement);
|
placements.push(placement);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -467,12 +465,12 @@ class KubernetesApplicationHelper {
|
||||||
const placements = formValues.Placements;
|
const placements = formValues.Placements;
|
||||||
const res = new KubernetesPodNodeAffinityPayload();
|
const res = new KubernetesPodNodeAffinityPayload();
|
||||||
let expressions = _.map(placements, (p) => {
|
let expressions = _.map(placements, (p) => {
|
||||||
if (!p.NeedsDeletion) {
|
if (!p.needsDeletion) {
|
||||||
const exp = new KubernetesNodeSelectorRequirementPayload();
|
const exp = new KubernetesNodeSelectorRequirementPayload();
|
||||||
exp.key = p.Label.Key;
|
exp.key = p.label;
|
||||||
if (p.Value) {
|
if (p.value) {
|
||||||
exp.operator = KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN;
|
exp.operator = KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN;
|
||||||
exp.values = [p.Value];
|
exp.values = [p.value];
|
||||||
} else {
|
} else {
|
||||||
exp.operator = KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS;
|
exp.operator = KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS;
|
||||||
delete exp.values;
|
delete exp.values;
|
||||||
|
@ -482,12 +480,12 @@ class KubernetesApplicationHelper {
|
||||||
});
|
});
|
||||||
expressions = _.without(expressions, undefined);
|
expressions = _.without(expressions, undefined);
|
||||||
if (expressions.length) {
|
if (expressions.length) {
|
||||||
if (formValues.PlacementType === KubernetesApplicationPlacementTypes.MANDATORY) {
|
if (formValues.PlacementType === 'mandatory') {
|
||||||
const term = new KubernetesNodeSelectorTermPayload();
|
const term = new KubernetesNodeSelectorTermPayload();
|
||||||
term.matchExpressions = expressions;
|
term.matchExpressions = expressions;
|
||||||
res.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms.push(term);
|
res.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms.push(term);
|
||||||
delete res.preferredDuringSchedulingIgnoredDuringExecution;
|
delete res.preferredDuringSchedulingIgnoredDuringExecution;
|
||||||
} else if (formValues.PlacementType === KubernetesApplicationPlacementTypes.PREFERRED) {
|
} else if (formValues.PlacementType === 'preferred') {
|
||||||
const term = new KubernetesPreferredSchedulingTermPayload();
|
const term = new KubernetesPreferredSchedulingTermPayload();
|
||||||
term.preference = new KubernetesNodeSelectorTermPayload();
|
term.preference = new KubernetesNodeSelectorTermPayload();
|
||||||
term.preference.matchExpressions = expressions;
|
term.preference.matchExpressions = expressions;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { PorImageRegistryModel } from '@/docker/models/porImageRegistry';
|
import { PorImageRegistryModel } from '@/docker/models/porImageRegistry';
|
||||||
import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes, KubernetesApplicationPlacementTypes } from './models';
|
import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes } from './models';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KubernetesApplicationFormValues Model
|
* KubernetesApplicationFormValues Model
|
||||||
|
@ -25,7 +25,7 @@ export function KubernetesApplicationFormValues() {
|
||||||
this.ConfigMaps = [];
|
this.ConfigMaps = [];
|
||||||
this.Secrets = [];
|
this.Secrets = [];
|
||||||
this.PublishedPorts = []; // KubernetesApplicationPublishedPortFormValue lis;
|
this.PublishedPorts = []; // KubernetesApplicationPublishedPortFormValue lis;
|
||||||
this.PlacementType = KubernetesApplicationPlacementTypes.PREFERRED;
|
this.PlacementType = 'preferred';
|
||||||
this.Placements = []; // KubernetesApplicationPlacementFormValue lis;
|
this.Placements = []; // KubernetesApplicationPlacementFormValue lis;
|
||||||
this.OriginalIngresses = undefined;
|
this.OriginalIngresses = undefined;
|
||||||
}
|
}
|
||||||
|
@ -119,10 +119,10 @@ export function KubernetesApplicationPublishedPortFormValue() {
|
||||||
|
|
||||||
export function KubernetesApplicationPlacementFormValue() {
|
export function KubernetesApplicationPlacementFormValue() {
|
||||||
return {
|
return {
|
||||||
Label: {},
|
label: {},
|
||||||
Value: '',
|
value: '',
|
||||||
NeedsDeletion: false,
|
needsDeletion: false,
|
||||||
IsNew: true,
|
isNew: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,11 +30,6 @@ export const KubernetesApplicationPublishingTypes = Object.freeze({
|
||||||
LOAD_BALANCER: 3,
|
LOAD_BALANCER: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const KubernetesApplicationPlacementTypes = Object.freeze({
|
|
||||||
PREFERRED: 1,
|
|
||||||
MANDATORY: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const KubernetesApplicationQuotaDefaults = {
|
export const KubernetesApplicationQuotaDefaults = {
|
||||||
CpuLimit: 0.1,
|
CpuLimit: 0.1,
|
||||||
MemoryLimit: 64, // MB
|
MemoryLimit: 64, // MB
|
||||||
|
|
|
@ -18,6 +18,10 @@ import {
|
||||||
ApplicationEventsDatatable,
|
ApplicationEventsDatatable,
|
||||||
} from '@/react/kubernetes/applications/DetailsView';
|
} from '@/react/kubernetes/applications/DetailsView';
|
||||||
import { ApplicationContainersDatatable } from '@/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable';
|
import { ApplicationContainersDatatable } from '@/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable';
|
||||||
|
import {
|
||||||
|
PlacementFormSection,
|
||||||
|
placementValidation,
|
||||||
|
} from '@/react/kubernetes/applications/components/PlacementFormSection';
|
||||||
import { withFormValidation } from '@/react-tools/withFormValidation';
|
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
|
import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
|
||||||
|
@ -267,3 +271,11 @@ withFormValidation(
|
||||||
['isMetricsEnabled'],
|
['isMetricsEnabled'],
|
||||||
autoScalingValidation
|
autoScalingValidation
|
||||||
);
|
);
|
||||||
|
|
||||||
|
withFormValidation(
|
||||||
|
ngModule,
|
||||||
|
withUIRouter(withCurrentUser(withReactQuery(PlacementFormSection))),
|
||||||
|
'placementFormSection',
|
||||||
|
[],
|
||||||
|
placementValidation
|
||||||
|
);
|
||||||
|
|
|
@ -475,105 +475,16 @@
|
||||||
></auto-scaling-form-section>
|
></auto-scaling-form-section>
|
||||||
</div>
|
</div>
|
||||||
<!-- #endregion -->
|
<!-- #endregion -->
|
||||||
|
|
||||||
<div class="mt-4 mb-2" ng-if="ctrl.formValues.DeploymentType === ctrl.ApplicationDeploymentTypes.REPLICATED">
|
|
||||||
<div class="col-sm-12 control-label !mb-2 !p-0 text-left"> Placement preferences and constraints </div>
|
|
||||||
|
|
||||||
<!-- #region PLACEMENTS -->
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12 small text-muted vertical-center !mb-2" ng-if="ctrl.formValues.Placements.length > 0">
|
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
|
||||||
<div> Deploy this application on nodes that respect <b>ALL</b> of the following placement rules. Placement rules are based on node labels. </div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-sm-12 form-inline">
|
<placement-form-section
|
||||||
<div ng-repeat-start="placement in ctrl.formValues.Placements" class="!mb-2">
|
ng-if="ctrl.formValues.DeploymentType === ctrl.ApplicationDeploymentTypes.REPLICATED"
|
||||||
<div class="col-sm-5 input-group mr-2" ng-class="{ striked: placement.NeedsDeletion }">
|
values="{placements: ctrl.formValues.Placements, placementType: ctrl.formValues.PlacementType}"
|
||||||
<select
|
on-change="(ctrl.onChangePlacements)"
|
||||||
class="form-control !rounded"
|
></placement-form-section>
|
||||||
ng-model="placement.Label"
|
|
||||||
ng-options="label as (label.Key | kubernetesNodeLabelHumanReadbleText) for label in ctrl.nodesLabels"
|
|
||||||
ng-change="ctrl.onChangePlacementLabel($index)"
|
|
||||||
ng-disabled="ctrl.isEditAndNotNewPlacement($index)"
|
|
||||||
data-cy="k8sAppCreate-placementLabel_{{ $index }}"
|
|
||||||
>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-5 input-group mr-2" ng-class="{ striked: placement.NeedsDeletion }">
|
|
||||||
<select
|
|
||||||
class="form-control !rounded"
|
|
||||||
ng-model="placement.Value"
|
|
||||||
ng-options="value for value in placement.Label.Values"
|
|
||||||
ng-disabled="ctrl.isEditAndNotNewPlacement($index)"
|
|
||||||
data-cy="k8sAppCreate-placementName_{{ $index }}"
|
|
||||||
>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-sm-1 input-group">
|
|
||||||
<button
|
|
||||||
ng-if="!placement.NeedsDeletion"
|
|
||||||
class="btn btn-md btn-dangerlight btn-only-icon !ml-0"
|
|
||||||
type="button"
|
|
||||||
ng-click="ctrl.removePlacement($index)"
|
|
||||||
data-cy="k8sAppCreate-deletePlacementButton"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
ng-if="placement.NeedsDeletion"
|
|
||||||
class="btn btn-sm btn-light btn-only-icon !ml-0"
|
|
||||||
type="button"
|
|
||||||
ng-click="ctrl.restorePlacement($index)"
|
|
||||||
data-cy="k8sAppCreate-restorePlacementButton"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'rotate-cw'" size="'md'"></pr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div ng-repeat-end ng-show="ctrl.state.duplicates.placements.refs[$index] !== undefined">
|
|
||||||
<div class="col-sm-5 input-group">
|
|
||||||
<div class="small text-warning" ng-if="ctrl.state.duplicates.placements.refs[$index] !== undefined">
|
|
||||||
<p class="vertical-center" ng-if="ctrl.state.duplicates.placements.refs[$index] !== undefined">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This label is already defined.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<span class="btn btn-primary btn-sm btn btn-sm btn-light mb-2 !ml-0 mt-2" ng-click="ctrl.addPlacement()">
|
|
||||||
<pr-icon icon="'plus'" size="'sm'"></pr-icon> Add rule
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div ng-if="ctrl.showPlacementPolicySection()">
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<label class="control-label text-left">Placement policy</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12 small text-muted"> Specify the policy associated to the placement rules. </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<box-selector
|
|
||||||
ng-if="ctrl.formValues.Placements.length"
|
|
||||||
options="ctrl.placementOptions"
|
|
||||||
slim="true"
|
|
||||||
value="ctrl.formValues.PlacementType"
|
|
||||||
on-change="(ctrl.onChangePlacementType)"
|
|
||||||
radio-name="'placementType'"
|
|
||||||
></box-selector>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- #endregion -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- kubernetes services options -->
|
<!-- kubernetes services options -->
|
||||||
<div ng-if="ctrl.formValues.ResourcePool">
|
<div class="mb-8" ng-if="ctrl.formValues.ResourcePool">
|
||||||
<kube-services-form
|
<kube-services-form
|
||||||
on-change="(ctrl.onServicesChange)"
|
on-change="(ctrl.onServicesChange)"
|
||||||
values="ctrl.formValues.Services"
|
values="ctrl.formValues.Services"
|
||||||
|
|
|
@ -13,14 +13,12 @@ import {
|
||||||
KubernetesApplicationPublishingTypes,
|
KubernetesApplicationPublishingTypes,
|
||||||
KubernetesApplicationQuotaDefaults,
|
KubernetesApplicationQuotaDefaults,
|
||||||
KubernetesApplicationTypes,
|
KubernetesApplicationTypes,
|
||||||
KubernetesApplicationPlacementTypes,
|
|
||||||
KubernetesDeploymentTypes,
|
KubernetesDeploymentTypes,
|
||||||
} from 'Kubernetes/models/application/models';
|
} from 'Kubernetes/models/application/models';
|
||||||
import {
|
import {
|
||||||
KubernetesApplicationEnvironmentVariableFormValue,
|
KubernetesApplicationEnvironmentVariableFormValue,
|
||||||
KubernetesApplicationFormValues,
|
KubernetesApplicationFormValues,
|
||||||
KubernetesApplicationPersistedFolderFormValue,
|
KubernetesApplicationPersistedFolderFormValue,
|
||||||
KubernetesApplicationPlacementFormValue,
|
|
||||||
KubernetesFormValidationReferences,
|
KubernetesFormValidationReferences,
|
||||||
} from 'Kubernetes/models/application/formValues';
|
} from 'Kubernetes/models/application/formValues';
|
||||||
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
||||||
|
@ -36,7 +34,6 @@ import { confirmUpdateAppIngress } from '@/react/kubernetes/applications/CreateV
|
||||||
import { confirm, confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
|
import { confirm, confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||||
import { buildConfirmButton } from '@@/modals/utils';
|
import { buildConfirmButton } from '@@/modals/utils';
|
||||||
import { ModalType } from '@@/modals';
|
import { ModalType } from '@@/modals';
|
||||||
import { placementOptions } from '@/react/kubernetes/applications/CreateView/placementTypes';
|
|
||||||
|
|
||||||
class KubernetesCreateApplicationController {
|
class KubernetesCreateApplicationController {
|
||||||
/* #region CONSTRUCTOR */
|
/* #region CONSTRUCTOR */
|
||||||
|
@ -80,13 +77,10 @@ class KubernetesCreateApplicationController {
|
||||||
this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
|
this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
|
||||||
this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies;
|
this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies;
|
||||||
this.ApplicationPublishingTypes = KubernetesApplicationPublishingTypes;
|
this.ApplicationPublishingTypes = KubernetesApplicationPublishingTypes;
|
||||||
this.ApplicationPlacementTypes = KubernetesApplicationPlacementTypes;
|
|
||||||
this.ApplicationTypes = KubernetesApplicationTypes;
|
this.ApplicationTypes = KubernetesApplicationTypes;
|
||||||
this.ServiceTypes = KubernetesServiceTypes;
|
this.ServiceTypes = KubernetesServiceTypes;
|
||||||
this.KubernetesDeploymentTypes = KubernetesDeploymentTypes;
|
this.KubernetesDeploymentTypes = KubernetesDeploymentTypes;
|
||||||
|
|
||||||
this.placementOptions = placementOptions;
|
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
appType: this.KubernetesDeploymentTypes.APPLICATION_FORM,
|
appType: this.KubernetesDeploymentTypes.APPLICATION_FORM,
|
||||||
updateWebEditorInProgress: false,
|
updateWebEditorInProgress: false,
|
||||||
|
@ -148,7 +142,6 @@ class KubernetesCreateApplicationController {
|
||||||
this.onDataAccessPolicyChange = this.onDataAccessPolicyChange.bind(this);
|
this.onDataAccessPolicyChange = this.onDataAccessPolicyChange.bind(this);
|
||||||
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
||||||
this.supportGlobalDeployment = this.supportGlobalDeployment.bind(this);
|
this.supportGlobalDeployment = this.supportGlobalDeployment.bind(this);
|
||||||
this.onChangePlacementType = this.onChangePlacementType.bind(this);
|
|
||||||
this.onServicesChange = this.onServicesChange.bind(this);
|
this.onServicesChange = this.onServicesChange.bind(this);
|
||||||
this.onEnvironmentVariableChange = this.onEnvironmentVariableChange.bind(this);
|
this.onEnvironmentVariableChange = this.onEnvironmentVariableChange.bind(this);
|
||||||
this.onConfigMapsChange = this.onConfigMapsChange.bind(this);
|
this.onConfigMapsChange = this.onConfigMapsChange.bind(this);
|
||||||
|
@ -157,12 +150,14 @@ class KubernetesCreateApplicationController {
|
||||||
this.onChangeResourceReservation = this.onChangeResourceReservation.bind(this);
|
this.onChangeResourceReservation = this.onChangeResourceReservation.bind(this);
|
||||||
this.onChangeReplicaCount = this.onChangeReplicaCount.bind(this);
|
this.onChangeReplicaCount = this.onChangeReplicaCount.bind(this);
|
||||||
this.onAutoScaleChange = this.onAutoScaleChange.bind(this);
|
this.onAutoScaleChange = this.onAutoScaleChange.bind(this);
|
||||||
|
this.onChangePlacements = this.onChangePlacements.bind(this);
|
||||||
}
|
}
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
onChangePlacementType(value) {
|
onChangePlacements(values) {
|
||||||
this.$scope.$evalAsync(() => {
|
return this.$async(async () => {
|
||||||
this.formValues.PlacementType = value;
|
this.formValues.Placements = values.placements;
|
||||||
|
this.formValues.PlacementType = values.placementType;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -410,50 +405,6 @@ class KubernetesCreateApplicationController {
|
||||||
}
|
}
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
/* #region PLACEMENT UI MANAGEMENT */
|
|
||||||
addPlacement() {
|
|
||||||
const placement = new KubernetesApplicationPlacementFormValue();
|
|
||||||
const label = this.nodesLabels[0];
|
|
||||||
placement.Label = label;
|
|
||||||
placement.Value = label.Values[0];
|
|
||||||
this.formValues.Placements.push(placement);
|
|
||||||
this.onChangePlacement();
|
|
||||||
}
|
|
||||||
|
|
||||||
restorePlacement(index) {
|
|
||||||
this.formValues.Placements[index].NeedsDeletion = false;
|
|
||||||
this.onChangePlacement();
|
|
||||||
}
|
|
||||||
|
|
||||||
removePlacement(index) {
|
|
||||||
if (this.state.isEdit && !this.formValues.Placements[index].IsNew) {
|
|
||||||
this.formValues.Placements[index].NeedsDeletion = true;
|
|
||||||
} else {
|
|
||||||
this.formValues.Placements.splice(index, 1);
|
|
||||||
}
|
|
||||||
this.onChangePlacement();
|
|
||||||
}
|
|
||||||
|
|
||||||
// call all validation functions when a placement is added/removed/restored
|
|
||||||
onChangePlacement() {
|
|
||||||
this.onChangePlacementLabelValidate();
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangePlacementLabel(index) {
|
|
||||||
this.formValues.Placements[index].Value = this.formValues.Placements[index].Label.Values[0];
|
|
||||||
this.onChangePlacementLabelValidate();
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangePlacementLabelValidate() {
|
|
||||||
const state = this.state.duplicates.placements;
|
|
||||||
const source = _.map(this.formValues.Placements, (p) => (p.NeedsDeletion ? undefined : p.Label.Key));
|
|
||||||
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
|
|
||||||
state.refs = duplicates;
|
|
||||||
state.hasRefs = Object.keys(duplicates).length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* #endregion */
|
|
||||||
|
|
||||||
/* #region SERVICES UI MANAGEMENT */
|
/* #region SERVICES UI MANAGEMENT */
|
||||||
onServicesChange(services) {
|
onServicesChange(services) {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
|
@ -675,15 +626,6 @@ class KubernetesCreateApplicationController {
|
||||||
}
|
}
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
isEditAndNotNewPlacement(index) {
|
|
||||||
return this.state.isEdit && !this.formValues.Placements[index].IsNew;
|
|
||||||
}
|
|
||||||
|
|
||||||
showPlacementPolicySection() {
|
|
||||||
const placements = _.filter(this.formValues.Placements, { NeedsDeletion: false });
|
|
||||||
return placements.length !== 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
isNonScalable() {
|
isNonScalable() {
|
||||||
const scalable = this.supportScalableReplicaDeployment();
|
const scalable = this.supportScalableReplicaDeployment();
|
||||||
const global = this.supportGlobalDeployment();
|
const global = this.supportGlobalDeployment();
|
||||||
|
|
|
@ -188,6 +188,7 @@ export function InputList<T = DefaultType>({
|
||||||
initialItemsCount={initialItemsCount.current}
|
initialItemsCount={initialItemsCount.current}
|
||||||
handleRemoveItem={handleRemoveItem}
|
handleRemoveItem={handleRemoveItem}
|
||||||
handleToggleNeedsDeletion={handleToggleNeedsDeletion}
|
handleToggleNeedsDeletion={handleToggleNeedsDeletion}
|
||||||
|
dataCy={`${deleteButtonDataCy}_${index}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -325,6 +326,7 @@ type CanUndoDeleteButtonProps<T> = {
|
||||||
initialItemsCount: number;
|
initialItemsCount: number;
|
||||||
handleRemoveItem(key: Key, item: T): void;
|
handleRemoveItem(key: Key, item: T): void;
|
||||||
handleToggleNeedsDeletion(key: Key, item: T): void;
|
handleToggleNeedsDeletion(key: Key, item: T): void;
|
||||||
|
dataCy: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CanUndoDeleteButton<T>({
|
function CanUndoDeleteButton<T>({
|
||||||
|
@ -333,6 +335,7 @@ function CanUndoDeleteButton<T>({
|
||||||
initialItemsCount,
|
initialItemsCount,
|
||||||
handleRemoveItem,
|
handleRemoveItem,
|
||||||
handleToggleNeedsDeletion,
|
handleToggleNeedsDeletion,
|
||||||
|
dataCy,
|
||||||
}: CanUndoDeleteButtonProps<T>) {
|
}: CanUndoDeleteButtonProps<T>) {
|
||||||
return (
|
return (
|
||||||
<div className="items-start">
|
<div className="items-start">
|
||||||
|
@ -343,6 +346,7 @@ function CanUndoDeleteButton<T>({
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
className="vertical-center btn-only-icon"
|
className="vertical-center btn-only-icon"
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
|
data-cy={`${dataCy}_delete`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{item.needsDeletion && (
|
{item.needsDeletion && (
|
||||||
|
@ -352,6 +356,7 @@ function CanUndoDeleteButton<T>({
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
className="vertical-center btn-only-icon"
|
className="vertical-center btn-only-icon"
|
||||||
icon={RotateCw}
|
icon={RotateCw}
|
||||||
|
data-cy={`${dataCy}_undo_delete`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -57,7 +57,7 @@ export function Select<
|
||||||
isCreatable = false,
|
isCreatable = false,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
...props
|
...props
|
||||||
}: Props<Option, IsMulti, Group>) {
|
}: Props<Option, IsMulti, Group> & AutomationTestingProps) {
|
||||||
const Component = isCreatable ? ReactSelectCreatable : ReactSelect;
|
const Component = isCreatable ? ReactSelectCreatable : ReactSelect;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
import { AlignJustify, Sliders } from 'lucide-react';
|
|
||||||
|
|
||||||
import { KubernetesApplicationPlacementTypes } from '@/kubernetes/models/application/models';
|
|
||||||
|
|
||||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
|
||||||
|
|
||||||
export const placementOptions: ReadonlyArray<BoxSelectorOption<number>> = [
|
|
||||||
{
|
|
||||||
id: 'placement_hard',
|
|
||||||
value: KubernetesApplicationPlacementTypes.MANDATORY,
|
|
||||||
icon: Sliders,
|
|
||||||
iconType: 'badge',
|
|
||||||
label: 'Mandatory',
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Schedule this application <b>ONLY</b> on nodes that match <b>ALL</b>{' '}
|
|
||||||
Rules
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'placement_soft',
|
|
||||||
value: KubernetesApplicationPlacementTypes.PREFERRED,
|
|
||||||
icon: AlignJustify,
|
|
||||||
iconType: 'badge',
|
|
||||||
label: 'Preferred',
|
|
||||||
description:
|
|
||||||
'Schedule this application on nodes that match the rules if possible',
|
|
||||||
},
|
|
||||||
] as const;
|
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useNodesQuery } from '@/react/kubernetes/cluster/HomeView/nodes.service';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { InputList } from '@@/form-components/InputList';
|
||||||
|
|
||||||
|
import { PlacementsFormValues, NodeLabels, Placement } from './types';
|
||||||
|
import { PlacementItem } from './PlacementItem';
|
||||||
|
import { PlacementTypeBoxSelector } from './PlacementTypeBoxSelector';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
values: PlacementsFormValues;
|
||||||
|
onChange: (values: PlacementsFormValues) => void;
|
||||||
|
errors?: FormikErrors<PlacementsFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlacementFormSection({ values, onChange, errors }: Props) {
|
||||||
|
// node labels are all of the unique node labels across all nodes
|
||||||
|
const nodesLabels = useNodeLabels();
|
||||||
|
// available node labels are the node labels that are not already in use by a placement
|
||||||
|
const availableNodeLabels = useAvailableNodeLabels(
|
||||||
|
nodesLabels,
|
||||||
|
values.placements
|
||||||
|
);
|
||||||
|
const firstAvailableNodeLabel = Object.keys(availableNodeLabels)[0] || '';
|
||||||
|
const firstAvailableNodeLabelValue =
|
||||||
|
availableNodeLabels[firstAvailableNodeLabel]?.[0] || '';
|
||||||
|
const nonDeletedPlacements = values.placements.filter(
|
||||||
|
(placement) => !placement.needsDeletion
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<FormSection
|
||||||
|
title="Placement preferences and constraints"
|
||||||
|
titleSize="sm"
|
||||||
|
titleClassName="control-label !text-[0.9em]"
|
||||||
|
>
|
||||||
|
{values.placements?.length > 0 && (
|
||||||
|
<TextTip color="blue">
|
||||||
|
Deploy this application on nodes that respect <b>ALL</b> of the
|
||||||
|
following placement rules. Placement rules are based on node labels.
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
<InputList
|
||||||
|
value={values.placements}
|
||||||
|
onChange={(placements) => onChange({ ...values, placements })}
|
||||||
|
renderItem={(item, onChange, index, error) => (
|
||||||
|
<PlacementItem
|
||||||
|
item={item}
|
||||||
|
onChange={onChange}
|
||||||
|
error={error}
|
||||||
|
index={index}
|
||||||
|
nodesLabels={nodesLabels}
|
||||||
|
availableNodeLabels={availableNodeLabels}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
itemBuilder={() => ({
|
||||||
|
label: firstAvailableNodeLabel,
|
||||||
|
value: firstAvailableNodeLabelValue,
|
||||||
|
needsDeletion: false,
|
||||||
|
})}
|
||||||
|
errors={errors?.placements}
|
||||||
|
addLabel="Add rule"
|
||||||
|
canUndoDelete
|
||||||
|
deleteButtonDataCy="k8sAppCreate-deletePlacementButton"
|
||||||
|
disabled={Object.keys(availableNodeLabels).length === 0}
|
||||||
|
addButtonError={
|
||||||
|
Object.keys(availableNodeLabels).length === 0
|
||||||
|
? 'There are no node labels available to add.'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
{nonDeletedPlacements.length >= 1 && (
|
||||||
|
<FormSection
|
||||||
|
title="Placement policy"
|
||||||
|
titleSize="sm"
|
||||||
|
titleClassName="control-label !text-[0.9em]"
|
||||||
|
>
|
||||||
|
<TextTip color="blue">
|
||||||
|
Specify the policy associated to the placement rules.
|
||||||
|
</TextTip>
|
||||||
|
<PlacementTypeBoxSelector
|
||||||
|
placementType={values.placementType}
|
||||||
|
onChange={(placementType) => onChange({ ...values, placementType })}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAvailableNodeLabels(
|
||||||
|
nodeLabels: NodeLabels,
|
||||||
|
placements: Placement[]
|
||||||
|
): NodeLabels {
|
||||||
|
return useMemo(() => {
|
||||||
|
const existingPlacementLabels = placements.map(
|
||||||
|
(placement) => placement.label
|
||||||
|
);
|
||||||
|
const availableNodeLabels = Object.keys(nodeLabels).filter(
|
||||||
|
(label) => !existingPlacementLabels.includes(label)
|
||||||
|
);
|
||||||
|
return availableNodeLabels.reduce((acc, label) => {
|
||||||
|
acc[label] = nodeLabels[label];
|
||||||
|
return acc;
|
||||||
|
}, {} as NodeLabels);
|
||||||
|
}, [nodeLabels, placements]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useNodeLabels(): NodeLabels {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const { data: nodes } = useNodesQuery(environmentId);
|
||||||
|
|
||||||
|
// all node label pairs (some might have the same key but different values)
|
||||||
|
const nodeLabelPairs =
|
||||||
|
nodes?.flatMap((node) =>
|
||||||
|
Object.entries(node.metadata?.labels || {}).map(([k, v]) => ({
|
||||||
|
key: k,
|
||||||
|
value: v,
|
||||||
|
}))
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
// get unique node labels with each label's possible values
|
||||||
|
const uniqueLabels = new Set(nodeLabelPairs.map((pair) => pair.key));
|
||||||
|
// create a NodeLabels object with each label's possible values
|
||||||
|
const nodesLabels = Array.from(uniqueLabels).reduce((acc, key) => {
|
||||||
|
acc[key] = nodeLabelPairs
|
||||||
|
.filter((pair) => pair.key === key)
|
||||||
|
.map((pair) => pair.value);
|
||||||
|
return acc;
|
||||||
|
}, {} as NodeLabels);
|
||||||
|
|
||||||
|
return nodesLabels;
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { ItemProps } from '@@/form-components/InputList';
|
||||||
|
import { Select } from '@@/form-components/ReactSelect';
|
||||||
|
import { isErrorType } from '@@/form-components/formikUtils';
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
|
||||||
|
import { NodeLabels, Placement } from './types';
|
||||||
|
|
||||||
|
interface PlacementItemProps extends ItemProps<Placement> {
|
||||||
|
nodesLabels: NodeLabels;
|
||||||
|
availableNodeLabels: NodeLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlacementItem({
|
||||||
|
onChange,
|
||||||
|
item,
|
||||||
|
error,
|
||||||
|
index,
|
||||||
|
nodesLabels,
|
||||||
|
availableNodeLabels,
|
||||||
|
}: PlacementItemProps) {
|
||||||
|
const labelOptions = Object.keys(availableNodeLabels).map((label) => ({
|
||||||
|
label,
|
||||||
|
value: label,
|
||||||
|
}));
|
||||||
|
const valueOptions = nodesLabels[item.label]?.map((value) => ({
|
||||||
|
label: value,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
const placementError = isErrorType(error) ? error : undefined;
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex w-full gap-2">
|
||||||
|
<div className="basis-1/2 grow">
|
||||||
|
<Select
|
||||||
|
options={labelOptions}
|
||||||
|
value={{ label: item.label, value: item.label }}
|
||||||
|
noOptionsMessage={() => 'No available node labels.'}
|
||||||
|
onChange={(labelOption) => {
|
||||||
|
const newValues = nodesLabels[labelOption?.value || ''];
|
||||||
|
onChange({
|
||||||
|
...item,
|
||||||
|
value: newValues?.[0] || '',
|
||||||
|
label: labelOption?.value || '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
className={clsx({ striked: !!item.needsDeletion })}
|
||||||
|
isDisabled={!!item.needsDeletion}
|
||||||
|
data-cy={`k8sAppCreate-placementLabel_${index}`}
|
||||||
|
/>
|
||||||
|
{placementError?.label && (
|
||||||
|
<FormError>{placementError.label}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="basis-1/2 grow">
|
||||||
|
<Select
|
||||||
|
options={valueOptions}
|
||||||
|
value={valueOptions?.find((option) => option.value === item.value)}
|
||||||
|
onChange={(valueOption) =>
|
||||||
|
onChange({ ...item, value: valueOption?.value || '' })
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
className={clsx({ striked: !!item.needsDeletion })}
|
||||||
|
isDisabled={!!item.needsDeletion}
|
||||||
|
data-cy={`k8sAppCreate-placementName_${index}`}
|
||||||
|
/>
|
||||||
|
{placementError?.value && (
|
||||||
|
<FormError>{placementError.value}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Sliders, AlignJustify } from 'lucide-react';
|
||||||
|
|
||||||
|
import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector';
|
||||||
|
|
||||||
|
import { PlacementType } from './types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
placementType: PlacementType;
|
||||||
|
onChange: (placementType: PlacementType) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const placementOptions: ReadonlyArray<BoxSelectorOption<PlacementType>> =
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'placement_hard',
|
||||||
|
value: 'mandatory',
|
||||||
|
icon: Sliders,
|
||||||
|
iconType: 'badge',
|
||||||
|
label: 'Mandatory',
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Schedule this application <b>ONLY</b> on nodes that match <b>ALL</b>{' '}
|
||||||
|
Rules
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'placement_soft',
|
||||||
|
value: 'preferred',
|
||||||
|
icon: AlignJustify,
|
||||||
|
iconType: 'badge',
|
||||||
|
label: 'Preferred',
|
||||||
|
description:
|
||||||
|
'Schedule this application on nodes that match the rules if possible',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function PlacementTypeBoxSelector({ placementType, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<BoxSelector<PlacementType>
|
||||||
|
value={placementType}
|
||||||
|
options={placementOptions}
|
||||||
|
onChange={(placementType) => onChange(placementType)}
|
||||||
|
radioName="placementType"
|
||||||
|
slim
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { PlacementFormSection } from './PlacementFormSection';
|
||||||
|
export { placementsValidation as placementValidation } from './placementValidation';
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { SchemaOf, array, boolean, mixed, object, string } from 'yup';
|
||||||
|
|
||||||
|
import { PlacementsFormValues } from './types';
|
||||||
|
|
||||||
|
export function placementsValidation(): SchemaOf<PlacementsFormValues> {
|
||||||
|
return object({
|
||||||
|
placementType: mixed().oneOf(['mandatory', 'preferred']).required(),
|
||||||
|
placements: array(
|
||||||
|
object({
|
||||||
|
label: string().required('Node label is required.'),
|
||||||
|
value: string().required('Node value is required.'),
|
||||||
|
needsDeletion: boolean(),
|
||||||
|
}).required()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
export type Placement = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
needsDeletion?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlacementType = 'mandatory' | 'preferred';
|
||||||
|
|
||||||
|
export type PlacementsFormValues = {
|
||||||
|
placementType: PlacementType;
|
||||||
|
placements: Placement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeLabels = Record<string, string[]>;
|
Loading…
Reference in New Issue