refactor(app): migrate app summary section [EE-6239] (#10910)

pull/10917/head
Ali 2024-01-05 15:42:36 +13:00 committed by GitHub
parent 7a4314032a
commit abf517de28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 1461 additions and 661 deletions

View File

@ -219,7 +219,7 @@
<div class="menuContent"> <div class="menuContent">
<div class="md-checkbox" ng-repeat="filter in $ctrl.filters.state.values track by $index"> <div class="md-checkbox" ng-repeat="filter in $ctrl.filters.state.values track by $index">
<input id="filter_state_{{ $index }}" type="checkbox" ng-model="filter.display" ng-change="$ctrl.onStateFilterChange()" /> <input id="filter_state_{{ $index }}" type="checkbox" ng-model="filter.display" ng-change="$ctrl.onStateFilterChange()" />
<label for="filter_state_{{ $index }}">{{ filter.type | kubernetesApplicationTypeText }}</label> <label for="filter_state_{{ $index }}">{{ filter.type }}</label>
</div> </div>
</div> </div>
<div> <div>
@ -282,7 +282,7 @@
</a> </a>
<a <a
ng-if="!item.KubernetesApplications" ng-if="!item.KubernetesApplications"
ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool, 'resource-type': $ctrl.applicationTypeEnumToParamMap[item.ApplicationType] })" ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool, 'resource-type': item.ApplicationType })"
ng-click="$event.stopPropagation()" ng-click="$event.stopPropagation()"
class="hyperlink" class="hyperlink"
>{{ item.Name }} >{{ item.Name }}
@ -297,17 +297,17 @@
<td title="{{ item.Image }}" <td title="{{ item.Image }}"
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td >{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
> >
<td>{{ item.ApplicationType | kubernetesApplicationTypeText }}</td> <td>{{ item.ApplicationType }}</td>
<td ng-if="item.ApplicationType !== $ctrl.KubernetesApplicationTypes.POD"> <td ng-if="item.ApplicationType !== $ctrl.KubernetesApplicationTypes.Pod">
<status-indicator ok="(item.TotalPodsCount > 0 && item.TotalPodsCount === item.RunningPodsCount) || item.Status === 'Ready'"></status-indicator> <status-indicator ok="(item.TotalPodsCount > 0 && item.TotalPodsCount === item.RunningPodsCount) || item.Status === 'Ready'"></status-indicator>
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.REPLICATED">Replicated</span> <span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.Replicated">Replicated</span>
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.GLOBAL">Global</span> <span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.Global">Global</span>
<span ng-if="item.RunningPodsCount >= 0 && item.TotalPodsCount >= 0" <span ng-if="item.RunningPodsCount >= 0 && item.TotalPodsCount >= 0"
><code>{{ item.RunningPodsCount }}</code> / <code>{{ item.TotalPodsCount }}</code></span ><code>{{ item.RunningPodsCount }}</code> / <code>{{ item.TotalPodsCount }}</code></span
> >
<span ng-if="item.KubernetesApplications">{{ item.Status }}</span> <span ng-if="item.KubernetesApplications">{{ item.Status }}</span>
</td> </td>
<td ng-if="item.ApplicationType === $ctrl.KubernetesApplicationTypes.POD"> <td ng-if="item.ApplicationType === $ctrl.KubernetesApplicationTypes.Pod">
{{ item.Pods[0].Status }} {{ item.Pods[0].Status }}
</td> </td>
<td> <td>

View File

@ -1,8 +1,8 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models'; import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants';
angular.module('portainer.kubernetes').controller('KubernetesApplicationsDatatableController', [ angular.module('portainer.kubernetes').controller('KubernetesApplicationsDatatableController', [
'$scope', '$scope',
@ -33,13 +33,6 @@ angular.module('portainer.kubernetes').controller('KubernetesApplicationsDatatab
}, },
}; };
this.applicationTypeEnumToParamMap = {
[KubernetesApplicationTypes.DEPLOYMENT]: 'Deployment',
[KubernetesApplicationTypes.DAEMONSET]: 'DaemonSet',
[KubernetesApplicationTypes.STATEFULSET]: 'StatefulSet',
[KubernetesApplicationTypes.POD]: 'Pod',
};
this.expandAll = function () { this.expandAll = function () {
this.state.expandAll = !this.state.expandAll; this.state.expandAll = !this.state.expandAll;
this.state.filteredDataSet.forEach((item) => this.expandItem(item, this.state.expandAll)); this.state.filteredDataSet.forEach((item) => this.expandItem(item, this.state.expandAll));

View File

@ -1,4 +1,4 @@
import { KubernetesApplicationDeploymentTypes } from 'Kubernetes/models/application/models'; import { KubernetesApplicationDeploymentTypes } from 'Kubernetes/models/application/models/appConstants';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';

View File

@ -1,15 +1,12 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import filesizeParser from 'filesize-parser'; import filesizeParser from 'filesize-parser';
import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants';
import { import {
KubernetesApplication, KubernetesApplication,
KubernetesApplicationConfigurationVolume, KubernetesApplicationConfigurationVolume,
KubernetesApplicationDataAccessPolicies,
KubernetesApplicationDeploymentTypes,
KubernetesApplicationPersistedFolder, KubernetesApplicationPersistedFolder,
KubernetesApplicationPort, KubernetesApplicationPort,
KubernetesApplicationPublishingTypes,
KubernetesApplicationTypes,
KubernetesPortainerApplicationNameLabel, KubernetesPortainerApplicationNameLabel,
KubernetesPortainerApplicationNote, KubernetesPortainerApplicationNote,
KubernetesPortainerApplicationOwnerLabel, KubernetesPortainerApplicationOwnerLabel,
@ -241,16 +238,16 @@ class KubernetesApplicationConverter {
static apiPodToApplication(data, pods, service, ingresses) { static apiPodToApplication(data, pods, service, ingresses) {
const res = new KubernetesApplication(); const res = new KubernetesApplication();
KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses); KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses);
res.ApplicationType = KubernetesApplicationTypes.POD; res.ApplicationType = KubernetesApplicationTypes.Pod;
return res; return res;
} }
static apiDeploymentToApplication(data, pods, service, ingresses) { static apiDeploymentToApplication(data, pods, service, ingresses) {
const res = new KubernetesApplication(); const res = new KubernetesApplication();
KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses); KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses);
res.ApplicationType = KubernetesApplicationTypes.DEPLOYMENT; res.ApplicationType = KubernetesApplicationTypes.Deployment;
res.DeploymentType = KubernetesApplicationDeploymentTypes.REPLICATED; res.DeploymentType = KubernetesApplicationDeploymentTypes.Replicated;
res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.SHARED; res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.Shared;
res.RunningPodsCount = data.status.availableReplicas || data.status.replicas - data.status.unavailableReplicas || 0; res.RunningPodsCount = data.status.availableReplicas || data.status.replicas - data.status.unavailableReplicas || 0;
res.TotalPodsCount = data.spec.replicas; res.TotalPodsCount = data.spec.replicas;
return res; return res;
@ -259,9 +256,9 @@ class KubernetesApplicationConverter {
static apiDaemonSetToApplication(data, pods, service, ingresses) { static apiDaemonSetToApplication(data, pods, service, ingresses) {
const res = new KubernetesApplication(); const res = new KubernetesApplication();
KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses); KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses);
res.ApplicationType = KubernetesApplicationTypes.DAEMONSET; res.ApplicationType = KubernetesApplicationTypes.DaemonSet;
res.DeploymentType = KubernetesApplicationDeploymentTypes.GLOBAL; res.DeploymentType = KubernetesApplicationDeploymentTypes.Global;
res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.SHARED; res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.Shared;
res.RunningPodsCount = data.status.numberAvailable || data.status.desiredNumberScheduled - data.status.numberUnavailable || 0; res.RunningPodsCount = data.status.numberAvailable || data.status.desiredNumberScheduled - data.status.numberUnavailable || 0;
res.TotalPodsCount = data.status.desiredNumberScheduled; res.TotalPodsCount = data.status.desiredNumberScheduled;
return res; return res;
@ -270,9 +267,9 @@ class KubernetesApplicationConverter {
static apiStatefulSetToapplication(data, pods, service, ingresses) { static apiStatefulSetToapplication(data, pods, service, ingresses) {
const res = new KubernetesApplication(); const res = new KubernetesApplication();
KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses); KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses);
res.ApplicationType = KubernetesApplicationTypes.STATEFULSET; res.ApplicationType = KubernetesApplicationTypes.StatefulSet;
res.DeploymentType = KubernetesApplicationDeploymentTypes.REPLICATED; res.DeploymentType = KubernetesApplicationDeploymentTypes.Replicated;
res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.ISOLATED; res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.Isolated;
res.RunningPodsCount = data.status.readyReplicas || 0; res.RunningPodsCount = data.status.readyReplicas || 0;
res.TotalPodsCount = data.spec.replicas; res.TotalPodsCount = data.spec.replicas;
res.HeadlessServiceName = data.spec.serviceName; res.HeadlessServiceName = data.spec.serviceName;
@ -313,16 +310,7 @@ class KubernetesApplicationConverter {
res.PublishedPorts = KubernetesApplicationHelper.generatePublishedPortsFormValuesFromPublishedPorts(app.ServiceType, app.PublishedPorts, ingresses); res.PublishedPorts = KubernetesApplicationHelper.generatePublishedPortsFormValuesFromPublishedPorts(app.ServiceType, app.PublishedPorts, ingresses);
res.Containers = app.Containers; res.Containers = app.Containers;
const isIngress = _.filter(res.PublishedPorts, (p) => p.IngressName).length; res.PublishingType = app.ServiceType;
if (app.ServiceType === KubernetesServiceTypes.LOAD_BALANCER) {
res.PublishingType = KubernetesApplicationPublishingTypes.LOAD_BALANCER;
} else if (app.ServiceType === KubernetesServiceTypes.NODE_PORT) {
res.PublishingType = KubernetesApplicationPublishingTypes.NODE_PORT;
} else if (app.ServiceType === KubernetesServiceTypes.CLUSTER_IP && isIngress) {
res.PublishingType = KubernetesApplicationPublishingTypes.INGRESS;
} else {
res.PublishingType = KubernetesApplicationPublishingTypes.CLUSTER_IP;
}
if (app.Pods && app.Pods.length) { if (app.Pods && app.Pods.length) {
KubernetesApplicationHelper.generatePlacementsFormValuesFromAffinity(res, app.Pods[0].Affinity); KubernetesApplicationHelper.generatePlacementsFormValuesFromAffinity(res, app.Pods[0].Affinity);
@ -338,20 +326,20 @@ class KubernetesApplicationConverter {
const rwx = KubernetesApplicationHelper.hasRWX(claims); const rwx = KubernetesApplicationHelper.hasRWX(claims);
const deployment = const deployment =
(formValues.DeploymentType === KubernetesApplicationDeploymentTypes.REPLICATED && (formValues.DeploymentType === KubernetesApplicationDeploymentTypes.Replicated &&
(claims.length === 0 || (claims.length > 0 && formValues.DataAccessPolicy === KubernetesApplicationDataAccessPolicies.SHARED))) || (claims.length === 0 || (claims.length > 0 && formValues.DataAccessPolicy === KubernetesApplicationDataAccessPolicies.Shared))) ||
formValues.ApplicationType === KubernetesApplicationTypes.DEPLOYMENT; formValues.ApplicationType === KubernetesApplicationTypes.Deployment;
const statefulSet = const statefulSet =
(formValues.DeploymentType === KubernetesApplicationDeploymentTypes.REPLICATED && (formValues.DeploymentType === KubernetesApplicationDeploymentTypes.Replicated &&
claims.length > 0 && claims.length > 0 &&
formValues.DataAccessPolicy === KubernetesApplicationDataAccessPolicies.ISOLATED) || formValues.DataAccessPolicy === KubernetesApplicationDataAccessPolicies.Isolated) ||
formValues.ApplicationType === KubernetesApplicationTypes.STATEFULSET; formValues.ApplicationType === KubernetesApplicationTypes.StatefulSet;
const daemonSet = const daemonSet =
(formValues.DeploymentType === KubernetesApplicationDeploymentTypes.GLOBAL && (formValues.DeploymentType === KubernetesApplicationDeploymentTypes.Global &&
(claims.length === 0 || (claims.length > 0 && formValues.DataAccessPolicy === KubernetesApplicationDataAccessPolicies.SHARED && rwx))) || (claims.length === 0 || (claims.length > 0 && formValues.DataAccessPolicy === KubernetesApplicationDataAccessPolicies.Shared && rwx))) ||
formValues.ApplicationType === KubernetesApplicationTypes.DAEMONSET; formValues.ApplicationType === KubernetesApplicationTypes.DaemonSet;
let app; let app;
if (deployment) { if (deployment) {
@ -363,6 +351,7 @@ class KubernetesApplicationConverter {
} else { } else {
throw new PortainerError('Unable to determine which association to use to convert form'); throw new PortainerError('Unable to determine which association to use to convert form');
} }
app.ApplicationType = formValues.ApplicationType;
let headlessService; let headlessService;
if (statefulSet) { if (statefulSet) {

View File

@ -71,7 +71,7 @@ class KubernetesPersistentVolumeClaimConverter {
res.metadata.namespace = pvc.Namespace; res.metadata.namespace = pvc.Namespace;
res.spec.resources.requests.storage = pvc.Storage; res.spec.resources.requests.storage = pvc.Storage;
res.spec.storageClassName = pvc.storageClass ? pvc.storageClass.Name : ''; res.spec.storageClassName = pvc.storageClass ? pvc.storageClass.Name : '';
const accessModes = pvc.StorageClass && pvc.StorageClass.AccessModes ? pvc.StorageClass.AccessModes.map((accessMode) => storageClassToPVCAccessModes[accessMode]) : []; const accessModes = pvc.storageClass && pvc.storageClass.AccessModes ? pvc.storageClass.AccessModes.map((accessMode) => storageClassToPVCAccessModes[accessMode]) : [];
res.spec.accessModes = accessModes; res.spec.accessModes = accessModes;
res.metadata.labels.app = pvc.ApplicationName; res.metadata.labels.app = pvc.ApplicationName;
res.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = pvc.ApplicationOwner; res.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = pvc.ApplicationOwner;

View File

@ -3,7 +3,6 @@ import * as JsonPatch from 'fast-json-patch';
import { KubernetesServiceCreatePayload } from 'Kubernetes/models/service/payloads'; import { KubernetesServiceCreatePayload } from 'Kubernetes/models/service/payloads';
import { import {
KubernetesApplicationPublishingTypes,
KubernetesPortainerApplicationNameLabel, KubernetesPortainerApplicationNameLabel,
KubernetesPortainerApplicationOwnerLabel, KubernetesPortainerApplicationOwnerLabel,
KubernetesPortainerApplicationStackNameLabel, KubernetesPortainerApplicationStackNameLabel,
@ -42,11 +41,7 @@ class KubernetesServiceConverter {
res.StackName = formValues.StackName ? formValues.StackName : formValues.Name; res.StackName = formValues.StackName ? formValues.StackName : formValues.Name;
res.ApplicationOwner = formValues.ApplicationOwner; res.ApplicationOwner = formValues.ApplicationOwner;
res.ApplicationName = formValues.Name; res.ApplicationName = formValues.Name;
if (formValues.PublishingType === KubernetesApplicationPublishingTypes.NODE_PORT) { res.Type = formValues.PublishingType;
res.Type = KubernetesServiceTypes.NODE_PORT;
} else if (formValues.PublishingType === KubernetesApplicationPublishingTypes.LOAD_BALANCER) {
res.Type = KubernetesServiceTypes.LOAD_BALANCER;
}
const ports = _.map(formValues.PublishedPorts, (item) => _publishedPortToServicePort(formValues, item, res.Type)); const ports = _.map(formValues.PublishedPorts, (item) => _publishedPortToServicePort(formValues, item, res.Type));
res.Ports = _.uniqBy(_.without(ports, undefined), (p) => p.targetPort + p.protocol); res.Ports = _.uniqBy(_.without(ports, undefined), (p) => p.targetPort + p.protocol);
return res; return res;
@ -61,13 +56,7 @@ class KubernetesServiceConverter {
res.StackName = formValues.StackName ? formValues.StackName : formValues.Name; res.StackName = formValues.StackName ? formValues.StackName : formValues.Name;
res.ApplicationOwner = formValues.ApplicationOwner; res.ApplicationOwner = formValues.ApplicationOwner;
res.ApplicationName = formValues.Name; res.ApplicationName = formValues.Name;
if (service.Type === KubernetesApplicationPublishingTypes.NODE_PORT) { res.Type = service.Type;
res.Type = KubernetesServiceTypes.NODE_PORT;
} else if (service.Type === KubernetesApplicationPublishingTypes.LOAD_BALANCER) {
res.Type = KubernetesServiceTypes.LOAD_BALANCER;
} else if (service.Type === KubernetesApplicationPublishingTypes.CLUSTER_IP) {
res.Type = KubernetesServiceTypes.CLUSTER_IP;
}
res.Ingress = service.Ingress; res.Ingress = service.Ingress;
if (service.Selector !== undefined) { if (service.Selector !== undefined) {
@ -120,18 +109,7 @@ class KubernetesServiceConverter {
payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = service.ApplicationName; payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = service.ApplicationName;
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = service.ApplicationOwner; payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = service.ApplicationOwner;
const ports = []; payload.spec.ports = service.Ports;
service.Ports.forEach((port) => {
const p = {};
p.name = port.name;
p.port = port.port;
p.nodePort = port.nodePort;
p.protocol = port.protocol;
p.targetPort = port.targetPort;
ports.push(p);
});
payload.spec.ports = ports;
payload.spec.selector = service.Selector; payload.spec.selector = service.Selector;
if (service.Headless) { if (service.Headless) {
payload.spec.clusterIP = KubernetesServiceHeadlessClusterIP; payload.spec.clusterIP = KubernetesServiceHeadlessClusterIP;

View File

@ -1,29 +1,8 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { KubernetesApplicationDataAccessPolicies } from 'Kubernetes/models/application/models';
import { KubernetesApplicationTypes, KubernetesApplicationTypeStrings } from 'Kubernetes/models/application/models';
import { nodeAffinityValues } from './application'; import { nodeAffinityValues } from './application';
angular angular
.module('portainer.kubernetes') .module('portainer.kubernetes')
.filter('kubernetesApplicationTypeText', function () {
'use strict';
return function (type) {
switch (type) {
case KubernetesApplicationTypes.DEPLOYMENT:
return KubernetesApplicationTypeStrings.DEPLOYMENT;
case KubernetesApplicationTypes.DAEMONSET:
return KubernetesApplicationTypeStrings.DAEMONSET;
case KubernetesApplicationTypes.STATEFULSET:
return KubernetesApplicationTypeStrings.STATEFULSET;
case KubernetesApplicationTypes.POD:
return KubernetesApplicationTypeStrings.POD;
case KubernetesApplicationTypes.HELM:
return KubernetesApplicationTypeStrings.HELM;
default:
return '-';
}
};
})
.filter('kubernetesApplicationCPUValue', function () { .filter('kubernetesApplicationCPUValue', function () {
'use strict'; 'use strict';
return function (value) { return function (value) {
@ -34,31 +13,20 @@ angular
'use strict'; 'use strict';
return function (value) { return function (value) {
switch (value) { switch (value) {
case KubernetesApplicationDataAccessPolicies.ISOLATED: case 'Isolated':
return 'boxes'; return 'boxes';
case KubernetesApplicationDataAccessPolicies.SHARED: case 'Shared':
return 'box'; return 'box';
} }
}; };
}) })
.filter('kubernetesApplicationDataAccessPolicyText', function () {
'use strict';
return function (value) {
switch (value) {
case KubernetesApplicationDataAccessPolicies.ISOLATED:
return 'Isolated';
case KubernetesApplicationDataAccessPolicies.SHARED:
return 'Shared';
}
};
})
.filter('kubernetesApplicationDataAccessPolicyTooltip', function () { .filter('kubernetesApplicationDataAccessPolicyTooltip', function () {
'use strict'; 'use strict';
return function (value) { return function (value) {
switch (value) { switch (value) {
case KubernetesApplicationDataAccessPolicies.ISOLATED: case 'Isolated':
return 'All the instances of this application are using their own data.'; return 'All the instances of this application are using their own data.';
case KubernetesApplicationDataAccessPolicies.SHARED: case 'Shared':
return 'All the instances of this application are sharing the same data.'; return 'All the instances of this application are sharing the same data.';
} }
}; };

View File

@ -22,7 +22,8 @@ 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, KubernetesApplicationTypes, HelmApplication } from 'Kubernetes/models/application/models'; import { HelmApplication } from 'Kubernetes/models/application/models';
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants';
import { KubernetesPodAffinity, KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models'; import { KubernetesPodAffinity, KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models';
import { import {
KubernetesNodeSelectorRequirementPayload, KubernetesNodeSelectorRequirementPayload,
@ -287,13 +288,6 @@ class KubernetesApplicationHelper {
svc.ApplicationOwner = app.ApplicationOwner; svc.ApplicationOwner = app.ApplicationOwner;
svc.ApplicationName = app.ApplicationName; svc.ApplicationName = app.ApplicationName;
svc.Type = service.spec.type; svc.Type = service.spec.type;
if (service.spec.type === KubernetesServiceTypes.CLUSTER_IP) {
svc.Type = 1;
} else if (service.spec.type === KubernetesServiceTypes.NODE_PORT) {
svc.Type = 2;
} else if (service.spec.type === KubernetesServiceTypes.LOAD_BALANCER) {
svc.Type = 3;
}
let ports = []; let ports = [];
service.spec.ports.forEach(function (port) { service.spec.ports.forEach(function (port) {
@ -373,15 +367,15 @@ class KubernetesApplicationHelper {
static generateAutoScalerFormValueFromHorizontalPodAutoScaler(autoScaler, replicasCount) { static generateAutoScalerFormValueFromHorizontalPodAutoScaler(autoScaler, replicasCount) {
const res = new KubernetesApplicationAutoScalerFormValue(); const res = new KubernetesApplicationAutoScalerFormValue();
if (autoScaler) { if (autoScaler) {
res.IsUsed = true; res.isUsed = true;
res.MinReplicas = autoScaler.MinReplicas; res.minReplicas = autoScaler.MinReplicas;
res.MaxReplicas = autoScaler.MaxReplicas; res.maxReplicas = autoScaler.MaxReplicas;
res.TargetCPUUtilization = autoScaler.TargetCPUUtilization; res.targetCpuUtilizationPercentage = autoScaler.TargetCPUUtilization;
res.ApiVersion = autoScaler.ApiVersion; res.apiVersion = autoScaler.ApiVersion;
} else { } else {
res.ApiVersion = 'apps/v1'; res.apiVersion = 'apps/v1';
res.MinReplicas = replicasCount; res.minReplicas = replicasCount;
res.MaxReplicas = replicasCount; res.maxReplicas = replicasCount;
} }
return res; return res;
} }
@ -461,7 +455,7 @@ class KubernetesApplicationHelper {
} }
static generateAffinityFromPlacements(app, formValues) { static generateAffinityFromPlacements(app, formValues) {
if (formValues.DeploymentType === KubernetesApplicationDeploymentTypes.REPLICATED) { if (formValues.DeploymentType === KubernetesApplicationDeploymentTypes.Replicated) {
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) => {
@ -545,7 +539,7 @@ class KubernetesApplicationHelper {
const helmAppsList = helmAppsEntriesList.map(([helmInstance, applications]) => { const helmAppsList = helmAppsEntriesList.map(([helmInstance, applications]) => {
const helmApp = new HelmApplication(); const helmApp = new HelmApplication();
helmApp.Name = helmInstance; helmApp.Name = helmInstance;
helmApp.ApplicationType = KubernetesApplicationTypes.HELM; helmApp.ApplicationType = KubernetesApplicationTypes.Helm;
helmApp.ApplicationOwner = applications[0].ApplicationOwner; helmApp.ApplicationOwner = applications[0].ApplicationOwner;
helmApp.KubernetesApplications = applications; helmApp.KubernetesApplications = applications;

View File

@ -1,11 +1,11 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import uuidv4 from 'uuid/v4'; import uuidv4 from 'uuid/v4';
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants';
class KubernetesVolumeHelper { class KubernetesVolumeHelper {
// TODO: review // TODO: review
// the following condition // the following condition
// && (app.ApplicationType === KubernetesApplicationTypes.STATEFULSET ? _.includes(volume.PersistentVolumeClaim.Name, app.Name) : true); // && (app.ApplicationType === KubernetesApplicationTypes.StatefulSet ? _.includes(volume.PersistentVolumeClaim.Name, app.Name) : true);
// is made to enforce finding the good SFS when multiple SFS in the same namespace // is made to enforce finding the good SFS when multiple SFS in the same namespace
// are referencing an internal PVC using the same internal name // are referencing an internal PVC using the same internal name
// (PVC are not exposed to other apps so they can have the same name in differents SFS) // (PVC are not exposed to other apps so they can have the same name in differents SFS)
@ -16,7 +16,7 @@ class KubernetesVolumeHelper {
return ( return (
volume.ResourcePool.Namespace.Name === app.ResourcePool && volume.ResourcePool.Namespace.Name === app.ResourcePool &&
matchingNames.length && matchingNames.length &&
(app.ApplicationType === KubernetesApplicationTypes.STATEFULSET ? _.includes(volume.PersistentVolumeClaim.Name, app.Name) : true) (app.ApplicationType === KubernetesApplicationTypes.StatefulSet ? _.includes(volume.PersistentVolumeClaim.Name, app.Name) : true)
); );
}); });
} }

View File

@ -30,7 +30,7 @@ export class KubernetesHorizontalPodAutoScalerConverter {
payload.metadata.name = data.TargetEntity.Name; payload.metadata.name = data.TargetEntity.Name;
payload.spec.minReplicas = data.MinReplicas; payload.spec.minReplicas = data.MinReplicas;
payload.spec.maxReplicas = data.MaxReplicas; payload.spec.maxReplicas = data.MaxReplicas;
payload.spec.targetCPUUtilizationPercentage = data.TargetCPUUtilization; payload.spec.targetCPUUtilizationPercentage = data.targetCpuUtilizationPercentage;
payload.spec.scaleTargetRef.apiVersion = data.TargetEntity.ApiVersion; payload.spec.scaleTargetRef.apiVersion = data.TargetEntity.ApiVersion;
payload.spec.scaleTargetRef.kind = data.TargetEntity.Kind; payload.spec.scaleTargetRef.kind = data.TargetEntity.Kind;
payload.spec.scaleTargetRef.name = data.TargetEntity.Name; payload.spec.scaleTargetRef.name = data.TargetEntity.Name;
@ -48,86 +48,12 @@ export class KubernetesHorizontalPodAutoScalerConverter {
const res = new KubernetesHorizontalPodAutoScaler(); const res = new KubernetesHorizontalPodAutoScaler();
res.Name = formValues.Name; res.Name = formValues.Name;
res.Namespace = formValues.ResourcePool.Namespace.Name; res.Namespace = formValues.ResourcePool.Namespace.Name;
res.MinReplicas = formValues.AutoScaler.MinReplicas; res.MinReplicas = formValues.AutoScaler.minReplicas;
res.MaxReplicas = formValues.AutoScaler.MaxReplicas; res.MaxReplicas = formValues.AutoScaler.maxReplicas;
res.TargetCPUUtilization = formValues.AutoScaler.TargetCPUUtilization; res.TargetCPUUtilization = formValues.AutoScaler.targetCpuUtilizationPercentage;
res.TargetEntity.Name = formValues.Name; res.TargetEntity.Name = formValues.Name;
res.TargetEntity.Kind = kind; res.TargetEntity.Kind = kind;
res.TargetEntity.ApiVersion = formValues.AutoScaler.ApiVersion; res.TargetEntity.ApiVersion = formValues.AutoScaler.apiVersion;
return res; return res;
} }
/**
* Convertion functions to use with v2beta2 model
*/
// static apiToModel(data, yaml) {
// const res = new KubernetesHorizontalPodAutoScaler();
// res.Id = data.metadata.uid;
// res.Namespace = data.metadata.namespace;
// res.Name = data.metadata.name;
// res.MinReplicas = data.spec.minReplicas;
// res.MaxReplicas = data.spec.maxReplicas;
// res.TargetCPUUtilization = data.spec.targetCPUUtilization;
// _.forEach(data.spec.metrics, (metric) => {
// if (metric.type === 'Resource') {
// if (metric.resource.name === 'cpu') {
// res.TargetCPUUtilization = metric.resource.target.averageUtilization;
// }
// if (metric.resource.name === 'memory') {
// res.TargetMemoryValue = parseFloat(metric.resource.target.averageValue) / 1000;
// }
// }
// });
// if (data.spec.scaleTargetRef) {
// res.TargetEntity.ApiVersion = data.spec.scaleTargetRef.apiVersion;
// res.TargetEntity.Kind = data.spec.scaleTargetRef.kind;
// res.TargetEntity.Name = data.spec.scaleTargetRef.name;
// }
// res.Yaml = yaml ? yaml.data : '';
// return res;
// }
// static createPayload(data) {
// const payload = new KubernetesHorizontalPodAutoScalerCreatePayload();
// payload.metadata.namespace = data.Namespace;
// payload.metadata.name = data.TargetEntity.Name;
// payload.spec.minReplicas = data.MinReplicas;
// payload.spec.maxReplicas = data.MaxReplicas;
// if (data.TargetMemoryValue) {
// const memoryMetric = new KubernetesHorizontalPodAutoScalerMemoryMetric();
// memoryMetric.resource.target.averageValue = data.TargetMemoryValue;
// payload.spec.metrics.push(memoryMetric);
// }
// if (data.TargetCPUUtilization) {
// const cpuMetric = new KubernetesHorizontalPodAutoScalerCPUMetric();
// cpuMetric.resource.target.averageUtilization = data.TargetCPUUtilization;
// payload.spec.metrics.push(cpuMetric);
// }
// payload.spec.scaleTargetRef.apiVersion = data.TargetEntity.ApiVersion;
// payload.spec.scaleTargetRef.kind = data.TargetEntity.Kind;
// payload.spec.scaleTargetRef.name = data.TargetEntity.Name;
// return payload;
// }
// static applicationFormValuesToModel(formValues, kind) {
// const res = new KubernetesHorizontalPodAutoScaler();
// res.Name = formValues.Name;
// res.Namespace = formValues.ResourcePool.Namespace.Name;
// res.MinReplicas = formValues.AutoScaler.MinReplicas;
// res.MaxReplicas = formValues.AutoScaler.MaxReplicas;
// res.TargetCPUUtilization = formValues.AutoScaler.TargetCPUUtilization;
// if (formValues.AutoScaler.TargetMemoryValue) {
// res.TargetMemoryValue = formValues.AutoScaler.TargetMemoryValue + 'M';
// }
// res.TargetEntity.Name = formValues.Name;
// res.TargetEntity.Kind = kind;
// return res;
// }
} }

View File

@ -1,27 +1,8 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import PortainerError from 'Portainer/error';
import { KubernetesApplication, KubernetesApplicationTypes, KubernetesApplicationTypeStrings } from 'Kubernetes/models/application/models';
import { KubernetesDeployment } from 'Kubernetes/models/deployment/models';
import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models';
import { KubernetesDaemonSet } from 'Kubernetes/models/daemon-set/models';
export class KubernetesHorizontalPodAutoScalerHelper { export class KubernetesHorizontalPodAutoScalerHelper {
static findApplicationBoundScaler(sList, app) { static findApplicationBoundScaler(sList, app) {
const kind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(app); const kind = app.ApplicationType;
return _.find(sList, (item) => item.TargetEntity.Kind === kind && item.TargetEntity.Name === app.Name); return _.find(sList, (item) => item.TargetEntity.Kind === kind && item.TargetEntity.Name === app.Name);
} }
static getApplicationTypeString(app) {
if ((app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.DEPLOYMENT) || app instanceof KubernetesDeployment) {
return KubernetesApplicationTypeStrings.DEPLOYMENT;
} else if ((app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.DAEMONSET) || app instanceof KubernetesDaemonSet) {
return KubernetesApplicationTypeStrings.DAEMONSET;
} else if ((app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.STATEFULSET) || app instanceof KubernetesStatefulSet) {
return KubernetesApplicationTypeStrings.STATEFULSET;
} else if (app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.POD) {
return KubernetesApplicationTypeStrings.POD;
} else {
throw new PortainerError('Unable to determine application type');
}
}
} }

View File

@ -7,7 +7,6 @@ import {
KubernetesResourcePoolIngressClassFormValue, KubernetesResourcePoolIngressClassFormValue,
KubernetesResourcePoolIngressClassHostFormValue, KubernetesResourcePoolIngressClassHostFormValue,
} from 'Kubernetes/models/resource-pool/formValues'; } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesApplicationPublishingTypes } from '../models/application/models';
import { KubernetesIngress, KubernetesIngressRule } from './models'; import { KubernetesIngress, KubernetesIngressRule } from './models';
import { KubernetesIngressCreatePayload, KubernetesIngressRuleCreatePayload, KubernetesIngressRulePathCreatePayload } from './payloads'; import { KubernetesIngressCreatePayload, KubernetesIngressRuleCreatePayload, KubernetesIngressRulePathCreatePayload } from './payloads';
import { KubernetesIngressClassAnnotation, PortainerIngressClassTypes } from './constants'; import { KubernetesIngressClassAnnotation, PortainerIngressClassTypes } from './constants';
@ -48,36 +47,6 @@ export class KubernetesIngressConverter {
return res; return res;
} }
/**
* Converts Application Form Value (from Create Application View) to Ingresses
* @param {KubernetesApplicationFormValues} formValues
* @param {string} serviceName
* @returns {KubernetesIngressRule[]}
*/
static applicationFormValuesToIngresses(formValues, serviceName) {
const isPublishingToIngress = formValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS;
const ingresses = angular.copy(formValues.OriginalIngresses);
_.forEach(formValues.PublishedPorts, (p) => {
const ingress = _.find(ingresses, { Name: p.IngressName });
if (ingress) {
if (p.NeedsDeletion) {
_.remove(ingress.Paths, (path) => path.Port === p.ContainerPort && path.ServiceName === serviceName && path.Path === p.IngressRoute);
} else if (isPublishingToIngress && p.IsNew) {
const rule = new KubernetesIngressRule();
rule.IngressName = ingress.Name;
rule.ServiceName = serviceName;
rule.Port = p.ContainerPort;
if (p.IngressRoute) {
rule.Path = _.startsWith(p.IngressRoute, '/') ? p.IngressRoute : '/' + p.IngressRoute;
}
rule.Host = p.IngressHost;
ingress.Paths.push(rule);
}
}
});
return ingresses;
}
static applicationFormValuesToDeleteIngresses(formValues, application) { static applicationFormValuesToDeleteIngresses(formValues, application) {
const ingresses = angular.copy(formValues.OriginalIngresses); const ingresses = angular.copy(formValues.OriginalIngresses);
application.Services.forEach((service) => { application.Services.forEach((service) => {

View File

@ -1,11 +1,11 @@
import { PorImageRegistryModel } from '@/docker/models/porImageRegistry'; import { PorImageRegistryModel } from '@/docker/models/porImageRegistry';
import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes } from './models'; import { KubernetesApplicationTypes, KubernetesApplicationDeploymentTypes, KubernetesApplicationDataAccessPolicies } from 'Kubernetes/models/application/models/appConstants';
/** /**
* KubernetesApplicationFormValues Model * KubernetesApplicationFormValues Model
*/ */
export function KubernetesApplicationFormValues() { export function KubernetesApplicationFormValues() {
this.ApplicationType = undefined; // will only exist for formValues generated from Application (app edit situation; this.ApplicationType = KubernetesApplicationTypes.Deployment; // will only exist for formValues generated from Application (app edit situation;
this.ResourcePool = {}; this.ResourcePool = {};
this.Name = ''; this.Name = '';
this.StackName = ''; this.StackName = '';
@ -14,13 +14,13 @@ export function KubernetesApplicationFormValues() {
this.Note = ''; this.Note = '';
this.MemoryLimit = 0; this.MemoryLimit = 0;
this.CpuLimit = 0; this.CpuLimit = 0;
this.DeploymentType = KubernetesApplicationDeploymentTypes.REPLICATED; this.DeploymentType = KubernetesApplicationDeploymentTypes.Replicated;
this.ReplicaCount = 1; this.ReplicaCount = 1;
this.AutoScaler = {}; this.AutoScaler = {};
this.Containers = []; this.Containers = [];
this.Services = []; this.Services = [];
this.EnvironmentVariables = []; // KubernetesApplicationEnvironmentVariableFormValue lis; this.EnvironmentVariables = []; // KubernetesApplicationEnvironmentVariableFormValue lis;
this.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.ISOLATED; this.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.Isolated;
this.PersistedFolders = []; // KubernetesApplicationPersistedFolderFormValue lis; this.PersistedFolders = []; // KubernetesApplicationPersistedFolderFormValue lis;
this.ConfigMaps = []; this.ConfigMaps = [];
this.Secrets = []; this.Secrets = [];
@ -130,11 +130,11 @@ export function KubernetesApplicationPlacementFormValue() {
* KubernetesApplicationAutoScalerFormValue Model * KubernetesApplicationAutoScalerFormValue Model
*/ */
const _KubernetesApplicationAutoScalerFormValue = Object.freeze({ const _KubernetesApplicationAutoScalerFormValue = Object.freeze({
MinReplicas: 0, minReplicas: 0,
MaxReplicas: 0, maxReplicas: 0,
TargetCPUUtilization: 50, targetCpuUtilizationPercentage: 50,
ApiVersion: '', apiVersion: '',
IsUsed: false, isUsed: false,
}); });
export class KubernetesApplicationAutoScalerFormValue { export class KubernetesApplicationAutoScalerFormValue {

View File

@ -0,0 +1,41 @@
import {
AppType,
AppDataAccessPolicy,
DeploymentType,
} from '@/react/kubernetes/applications/types';
import { ServiceType } from '@/react/kubernetes/services/types';
// The following constants are used by angular views and can be removed once they are no longer referenced
export const KubernetesApplicationTypes: Record<AppType, AppType> = {
Deployment: 'Deployment',
StatefulSet: 'StatefulSet',
DaemonSet: 'DaemonSet',
Pod: 'Pod',
Helm: 'Helm',
} as const;
export const KubernetesApplicationDeploymentTypes: Record<
DeploymentType,
DeploymentType
> = {
Global: 'Global',
Replicated: 'Replicated',
} as const;
export const KubernetesApplicationDataAccessPolicies: Record<
AppDataAccessPolicy,
AppDataAccessPolicy
> = {
Isolated: 'Isolated',
Shared: 'Shared',
} as const;
export const KubernetesApplicationServiceTypes: Record<
ServiceType,
ServiceType
> = {
ClusterIP: 'ClusterIP',
NodePort: 'NodePort',
LoadBalancer: 'LoadBalancer',
ExternalName: 'ExternalName',
} as const;

View File

@ -1,35 +1,3 @@
export const KubernetesApplicationDeploymentTypes = Object.freeze({
REPLICATED: 1,
GLOBAL: 2,
});
export const KubernetesApplicationDataAccessPolicies = Object.freeze({
SHARED: 1,
ISOLATED: 2,
});
export const KubernetesApplicationTypes = Object.freeze({
DEPLOYMENT: 1,
DAEMONSET: 2,
STATEFULSET: 3,
POD: 4,
HELM: 5,
});
export const KubernetesApplicationTypeStrings = Object.freeze({
HELM: 'Helm',
DEPLOYMENT: 'Deployment',
DAEMONSET: 'DaemonSet',
STATEFULSET: 'StatefulSet',
POD: 'Pod',
});
export const KubernetesApplicationPublishingTypes = Object.freeze({
CLUSTER_IP: 1,
NODE_PORT: 2,
LOAD_BALANCER: 3,
});
export const KubernetesApplicationQuotaDefaults = { export const KubernetesApplicationQuotaDefaults = {
CpuLimit: 0.1, CpuLimit: 0.1,
MemoryLimit: 64, // MB MemoryLimit: 64, // MB

View File

@ -6,7 +6,6 @@ import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessVie
import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector'; import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector';
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector'; import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector'; import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector';
import { DataAccessPolicyFormSection } from '@/react/kubernetes/applications/CreateView/DataAccessPolicyFormSection';
import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm'; import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation'; import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation';
import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/CreateView/AppDeploymentTypeFormSection'; import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/CreateView/AppDeploymentTypeFormSection';
@ -22,6 +21,7 @@ import {
PlacementFormSection, PlacementFormSection,
placementValidation, placementValidation,
} from '@/react/kubernetes/applications/components/PlacementFormSection'; } from '@/react/kubernetes/applications/components/PlacementFormSection';
import { ApplicationSummarySection } from '@/react/kubernetes/applications/components/ApplicationSummarySection';
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';
@ -33,6 +33,7 @@ import { SecretsFormSection } from '@/react/kubernetes/applications/components/C
import { configurationsValidationSchema } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/configurationValidationSchema'; import { configurationsValidationSchema } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/configurationValidationSchema';
import { ConfigMapsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection'; import { ConfigMapsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection';
import { PersistedFoldersFormSection } from '@/react/kubernetes/applications/components/PersistedFoldersFormSection'; import { PersistedFoldersFormSection } from '@/react/kubernetes/applications/components/PersistedFoldersFormSection';
import { DataAccessPolicyFormSection } from '@/react/kubernetes/applications/CreateView/DataAccessPolicyFormSection';
import { persistedFoldersValidation } from '@/react/kubernetes/applications/components/PersistedFoldersFormSection/persistedFoldersValidation'; import { persistedFoldersValidation } from '@/react/kubernetes/applications/components/PersistedFoldersFormSection/persistedFoldersValidation';
import { import {
ResourceReservationFormSection, ResourceReservationFormSection,
@ -46,6 +47,7 @@ import {
AutoScalingFormSection, AutoScalingFormSection,
autoScalingValidation, autoScalingValidation,
} from '@/react/kubernetes/applications/components/AutoScalingFormSection'; } from '@/react/kubernetes/applications/components/AutoScalingFormSection';
import { withControlledInput } from '@/react-tools/withControlledInput';
import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset'; import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset';
@ -112,7 +114,7 @@ export const ngModule = angular
r2a(withUIRouter(withReactQuery(withCurrentUser(NodesDatatable))), []) r2a(withUIRouter(withReactQuery(withCurrentUser(NodesDatatable))), [])
) )
.component( .component(
'dataAccessPolicyFormSection', 'accessPolicyFormSection',
r2a(DataAccessPolicyFormSection, [ r2a(DataAccessPolicyFormSection, [
'value', 'value',
'onChange', 'onChange',
@ -174,6 +176,13 @@ export const ngModule = angular
[] []
) )
) )
.component(
'applicationSummarySection',
r2a(
withUIRouter(withReactQuery(withCurrentUser(ApplicationSummarySection))),
['formValues', 'oldFormValues']
)
)
.component( .component(
'kubernetesApplicationsStacksDatatable', 'kubernetesApplicationsStacksDatatable',
r2a(withUIRouter(withCurrentUser(ApplicationsStacksDatatable)), [ r2a(withUIRouter(withCurrentUser(ApplicationsStacksDatatable)), [
@ -193,7 +202,9 @@ export const componentsModule = ngModule.name;
withFormValidation( withFormValidation(
ngModule, ngModule,
withUIRouter(withCurrentUser(withReactQuery(KubeServicesForm))), withControlledInput(
withUIRouter(withCurrentUser(withReactQuery(KubeServicesForm)))
),
'kubeServicesForm', 'kubeServicesForm',
['values', 'onChange', 'appName', 'selector', 'isEditMode', 'namespace'], ['values', 'onChange', 'appName', 'selector', 'isEditMode', 'namespace'],
kubeServicesValidation kubeServicesValidation
@ -201,7 +212,7 @@ withFormValidation(
withFormValidation( withFormValidation(
ngModule, ngModule,
EnvironmentVariablesFieldset, withControlledInput(EnvironmentVariablesFieldset),
'kubeEnvironmentVariablesFieldset', 'kubeEnvironmentVariablesFieldset',
['canUndoDelete'], ['canUndoDelete'],
// use kubeEnvVarValidationSchema instead of envVarValidation to add a regex matches rule // use kubeEnvVarValidationSchema instead of envVarValidation to add a regex matches rule
@ -210,7 +221,9 @@ withFormValidation(
withFormValidation( withFormValidation(
ngModule, ngModule,
withUIRouter(withCurrentUser(withReactQuery(ConfigMapsFormSection))), withControlledInput(
withUIRouter(withCurrentUser(withReactQuery(ConfigMapsFormSection)))
),
'configMapsFormSection', 'configMapsFormSection',
['values', 'onChange', 'namespace'], ['values', 'onChange', 'namespace'],
configurationsValidationSchema configurationsValidationSchema
@ -218,7 +231,9 @@ withFormValidation(
withFormValidation( withFormValidation(
ngModule, ngModule,
withUIRouter(withCurrentUser(withReactQuery(SecretsFormSection))), withControlledInput(
withUIRouter(withCurrentUser(withReactQuery(SecretsFormSection)))
),
'secretsFormSection', 'secretsFormSection',
['values', 'onChange', 'namespace'], ['values', 'onChange', 'namespace'],
configurationsValidationSchema configurationsValidationSchema
@ -240,7 +255,11 @@ withFormValidation(
withFormValidation( withFormValidation(
ngModule, ngModule,
withUIRouter(withCurrentUser(withReactQuery(ResourceReservationFormSection))), withControlledInput(
withUIRouter(
withCurrentUser(withReactQuery(ResourceReservationFormSection))
)
),
'resourceReservationFormSection', 'resourceReservationFormSection',
[ [
'namespaceHasQuota', 'namespaceHasQuota',
@ -253,7 +272,9 @@ withFormValidation(
withFormValidation( withFormValidation(
ngModule, ngModule,
withUIRouter(withCurrentUser(withReactQuery(ReplicationFormSection))), withControlledInput(
withUIRouter(withCurrentUser(withReactQuery(ReplicationFormSection)))
),
'replicationFormSection', 'replicationFormSection',
[ [
'supportScalableReplicaDeployment', 'supportScalableReplicaDeployment',
@ -266,7 +287,9 @@ withFormValidation(
withFormValidation( withFormValidation(
ngModule, ngModule,
withUIRouter(withCurrentUser(withReactQuery(AutoScalingFormSection))), withControlledInput(
withUIRouter(withCurrentUser(withReactQuery(AutoScalingFormSection)))
),
'autoScalingFormSection', 'autoScalingFormSection',
['isMetricsEnabled'], ['isMetricsEnabled'],
autoScalingValidation autoScalingValidation

View File

@ -2,12 +2,9 @@ import _ from 'lodash-es';
import angular from 'angular'; import angular from 'angular';
import PortainerError from 'Portainer/error'; import PortainerError from 'Portainer/error';
import { KubernetesApplication, KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import KubernetesApplicationConverter from 'Kubernetes/converters/application'; import KubernetesApplicationConverter from 'Kubernetes/converters/application';
import { KubernetesDeployment } from 'Kubernetes/models/deployment/models';
import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models'; import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models';
import { KubernetesDaemonSet } from 'Kubernetes/models/daemon-set/models';
import KubernetesServiceHelper from 'Kubernetes/helpers/serviceHelper'; import KubernetesServiceHelper from 'Kubernetes/helpers/serviceHelper';
import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-pod-auto-scaler/helper'; import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-pod-auto-scaler/helper';
import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter'; import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter';
@ -15,6 +12,7 @@ import KubernetesPodConverter from 'Kubernetes/pod/converter';
import { notifyError } from '@/portainer/services/notifications'; import { notifyError } from '@/portainer/services/notifications';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter'; import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
import { generateNewIngressesFromFormPaths } from '@/react/kubernetes/applications/CreateView/application-services/utils'; import { generateNewIngressesFromFormPaths } from '@/react/kubernetes/applications/CreateView/application-services/utils';
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants';
class KubernetesApplicationService { class KubernetesApplicationService {
/* #region CONSTRUCTOR */ /* #region CONSTRUCTOR */
@ -58,13 +56,13 @@ class KubernetesApplicationService {
/* #region UTILS */ /* #region UTILS */
_getApplicationApiService(app) { _getApplicationApiService(app) {
let apiService; let apiService;
if (app instanceof KubernetesDeployment || (app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.DEPLOYMENT)) { if (app.ApplicationType === KubernetesApplicationTypes.Deployment) {
apiService = this.KubernetesDeploymentService; apiService = this.KubernetesDeploymentService;
} else if (app instanceof KubernetesDaemonSet || (app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.DAEMONSET)) { } else if (app.ApplicationType === KubernetesApplicationTypes.DaemonSet) {
apiService = this.KubernetesDaemonSetService; apiService = this.KubernetesDaemonSetService;
} else if (app instanceof KubernetesStatefulSet || (app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.STATEFULSET)) { } else if (app.ApplicationType === KubernetesApplicationTypes.StatefulSet) {
apiService = this.KubernetesStatefulSetService; apiService = this.KubernetesStatefulSetService;
} else if (app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.POD) { } else if (app.ApplicationType === KubernetesApplicationTypes.Pod) {
apiService = this.KubernetesPodService; apiService = this.KubernetesPodService;
} else { } else {
throw new PortainerError('Unable to determine which association to use to retrieve API Service'); throw new PortainerError('Unable to determine which association to use to retrieve API Service');
@ -257,8 +255,8 @@ class KubernetesApplicationService {
await Promise.all(_.without(claimPromises, undefined)); await Promise.all(_.without(claimPromises, undefined));
} }
if (formValues.AutoScaler.IsUsed && formValues.DeploymentType !== KubernetesApplicationDeploymentTypes.GLOBAL) { if (formValues.AutoScaler.isUsed && formValues.DeploymentType !== KubernetesApplicationDeploymentTypes.Global) {
const kind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(app); const kind = app.ApplicationType;
const autoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(formValues, kind); const autoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(formValues, kind);
await this.KubernetesHorizontalPodAutoScalerService.create(autoScaler); await this.KubernetesHorizontalPodAutoScalerService.create(autoScaler);
} }
@ -378,16 +376,16 @@ class KubernetesApplicationService {
} }
} }
const newKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(newApp); const newKind = newApp.ApplicationType;
const newAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(newFormValues, newKind); const newAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(newFormValues, newKind);
if (!oldFormValues.AutoScaler.IsUsed) { if (!oldFormValues.AutoScaler.isUsed) {
if (newFormValues.AutoScaler.IsUsed) { if (newFormValues.AutoScaler.isUsed) {
await this.KubernetesHorizontalPodAutoScalerService.create(newAutoScaler); await this.KubernetesHorizontalPodAutoScalerService.create(newAutoScaler);
} }
} else { } else {
const oldKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(oldApp); const oldKind = oldApp.ApplicationType;
const oldAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(oldFormValues, oldKind); const oldAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(oldFormValues, oldKind);
if (newFormValues.AutoScaler.IsUsed) { if (newFormValues.AutoScaler.isUsed) {
await this.KubernetesHorizontalPodAutoScalerService.patch(oldAutoScaler, newAutoScaler); await this.KubernetesHorizontalPodAutoScalerService.patch(oldAutoScaler, newAutoScaler);
} else { } else {
await this.KubernetesHorizontalPodAutoScalerService.delete(oldAutoScaler); await this.KubernetesHorizontalPodAutoScalerService.delete(oldAutoScaler);

View File

@ -3,7 +3,7 @@ import _ from 'lodash-es';
import KubernetesStackHelper from 'Kubernetes/helpers/stackHelper'; import KubernetesStackHelper from 'Kubernetes/helpers/stackHelper';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper'; import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper';
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants';
import { KubernetesPortainerApplicationStackNameLabel } from 'Kubernetes/models/application/models'; import { KubernetesPortainerApplicationStackNameLabel } from 'Kubernetes/models/application/models';
import { confirmDelete } from '@@/modals/confirm'; import { confirmDelete } from '@@/modals/confirm';
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service'; import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
@ -90,7 +90,7 @@ class KubernetesApplicationsController {
let actionCount = selectedItems.length; let actionCount = selectedItems.length;
for (const application of selectedItems) { for (const application of selectedItems) {
try { try {
if (application.ApplicationType === KubernetesApplicationTypes.HELM) { if (application.ApplicationType === KubernetesApplicationTypes.Helm) {
await this.HelmService.uninstall(this.endpoint.Id, application); await this.HelmService.uninstall(this.endpoint.Id, application);
} else { } else {
await this.KubernetesApplicationService.delete(application); await this.KubernetesApplicationService.delete(application);

View File

@ -103,9 +103,16 @@
></kube-services-form> ></kube-services-form>
</div> </div>
<!-- kubernetes services options --> <!-- kubernetes services options -->
<kubernetes-summary-view form-values="ctrl.formValues" old-form-values="ctrl.savedFormValues"></kubernetes-summary-view> <!-- kubernetes summary for external application -->
<!-- #region ACTIONS --> <application-summary-section
application-kind="ctrl.formValues.ApplicationType"
form-values="ctrl.formValues"
old-form-values="ctrl.savedFormValues"
ng-if="ctrl.isExternalApplication()"
></application-summary-section>
<!-- kubernetes summary for external application -->
<div class="col-sm-12 form-section-title !mt-6" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT" ng-hide="ctrl.stack.IsComposeFormat"> Actions </div> <div class="col-sm-12 form-section-title !mt-6" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT" ng-hide="ctrl.stack.IsComposeFormat"> Actions </div>
<!-- #region ACTIONS -->
<div class="form-group" ng-hide="ctrl.stack.IsComposeFormat"> <div class="form-group" ng-hide="ctrl.stack.IsComposeFormat">
<div class="col-sm-12"> <div class="col-sm-12">
<button <button
@ -423,12 +430,12 @@
<!-- #region DATA ACCESS POLICY --> <!-- #region DATA ACCESS POLICY -->
<div ng-if="ctrl.showDataAccessPolicySection()"> <div ng-if="ctrl.showDataAccessPolicySection()">
<data-access-policy-form-section <access-policy-form-section
value="ctrl.formValues.DataAccessPolicy" value="ctrl.formValues.DataAccessPolicy"
on-change="(ctrl.onDataAccessPolicyChange)" on-change="(ctrl.onDataAccessPolicyChange)"
is-edit="ctrl.state.isEdit" is-edit="ctrl.state.isEdit"
persisted-folders-use-existing-volumes="ctrl.state.persistedFoldersUseExistingVolumes" persisted-folders-use-existing-volumes="ctrl.state.persistedFoldersUseExistingVolumes"
></data-access-policy-form-section> ></access-policy-form-section>
</div> </div>
<!-- #endregion --> <!-- #endregion -->
@ -451,7 +458,7 @@
></app-deployment-type-form-section> ></app-deployment-type-form-section>
<!-- replica count --> <!-- replica count -->
<div ng-if="ctrl.formValues.DeploymentType === ctrl.ApplicationDeploymentTypes.REPLICATED"> <div ng-if="ctrl.formValues.DeploymentType === ctrl.ApplicationDeploymentTypes.Replicated">
<replication-form-section <replication-form-section
values="{replicaCount: ctrl.formValues.ReplicaCount}" values="{replicaCount: ctrl.formValues.ReplicaCount}"
on-change="(ctrl.onChangeReplicaCount)" on-change="(ctrl.onChangeReplicaCount)"
@ -466,9 +473,9 @@
<!-- #endregion --> <!-- #endregion -->
<!-- #region AUTO SCALING --> <!-- #region AUTO SCALING -->
<div ng-if="ctrl.formValues.DeploymentType !== ctrl.ApplicationDeploymentTypes.GLOBAL"> <div ng-if="ctrl.formValues.DeploymentType !== ctrl.ApplicationDeploymentTypes.Global">
<auto-scaling-form-section <auto-scaling-form-section
values="{isUsed: ctrl.formValues.AutoScaler.IsUsed, minReplicas: ctrl.formValues.AutoScaler.MinReplicas, maxReplicas: ctrl.formValues.AutoScaler.MaxReplicas, targetCpuUtilizationPercentage: ctrl.formValues.AutoScaler.TargetCPUUtilization}" values="ctrl.formValues.AutoScaler"
on-change="(ctrl.onAutoScaleChange)" on-change="(ctrl.onAutoScaleChange)"
validation-data="{autoScalerOverflow: ctrl.autoScalerOverflow()}" validation-data="{autoScalerOverflow: ctrl.autoScalerOverflow()}"
is-metrics-enabled="ctrl.state.useServerMetrics" is-metrics-enabled="ctrl.state.useServerMetrics"
@ -478,7 +485,7 @@
</div> </div>
<placement-form-section <placement-form-section
ng-if="ctrl.formValues.DeploymentType === ctrl.ApplicationDeploymentTypes.REPLICATED" ng-if="ctrl.formValues.DeploymentType === ctrl.ApplicationDeploymentTypes.Replicated"
values="{placements: ctrl.formValues.Placements, placementType: ctrl.formValues.PlacementType}" values="{placements: ctrl.formValues.Placements, placementType: ctrl.formValues.PlacementType}"
on-change="(ctrl.onChangePlacements)" on-change="(ctrl.onChangePlacements)"
></placement-form-section> ></placement-form-section>
@ -497,11 +504,12 @@
></kube-services-form> ></kube-services-form>
</div> </div>
<!-- kubernetes services options --> <!-- kubernetes services options -->
<kubernetes-summary-view <application-summary-section
application-kind="ctrl.formValues.ApplicationType"
ng-if="!(!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.state.pullImageValidity)" ng-if="!(!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.state.pullImageValidity)"
form-values="ctrl.formValues" form-values="ctrl.formValues"
old-form-values="ctrl.savedFormValues" old-form-values="ctrl.savedFormValues"
></kubernetes-summary-view> ></application-summary-section>
</div> </div>
<!-- #region ACTIONS --> <!-- #region ACTIONS -->
<div class="col-sm-12 form-section-title !mt-6" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT" ng-hide="ctrl.stack.IsComposeFormat"> Actions </div> <div class="col-sm-12 form-section-title !mt-6" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT" ng-hide="ctrl.stack.IsComposeFormat"> Actions </div>

View File

@ -10,17 +10,11 @@ import { getGlobalDeploymentOptions } from '@/react/portainer/settings/settings.
import { import {
KubernetesApplicationDataAccessPolicies, KubernetesApplicationDataAccessPolicies,
KubernetesApplicationDeploymentTypes, KubernetesApplicationDeploymentTypes,
KubernetesApplicationPublishingTypes, KubernetesApplicationServiceTypes,
KubernetesApplicationQuotaDefaults,
KubernetesApplicationTypes, KubernetesApplicationTypes,
KubernetesDeploymentTypes, } from 'Kubernetes/models/application/models/appConstants';
} from 'Kubernetes/models/application/models'; import { KubernetesApplicationQuotaDefaults, KubernetesDeploymentTypes } from 'Kubernetes/models/application/models';
import { import { KubernetesApplicationEnvironmentVariableFormValue, KubernetesApplicationFormValues, KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
KubernetesApplicationEnvironmentVariableFormValue,
KubernetesApplicationFormValues,
KubernetesApplicationPersistedFolderFormValue,
KubernetesFormValidationReferences,
} from 'Kubernetes/models/application/formValues';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper'; import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import KubernetesApplicationConverter from 'Kubernetes/converters/application'; import KubernetesApplicationConverter from 'Kubernetes/converters/application';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
@ -76,7 +70,7 @@ class KubernetesCreateApplicationController {
this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies; this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies;
this.ApplicationPublishingTypes = KubernetesApplicationPublishingTypes; this.KubernetesApplicationServiceTypes = KubernetesApplicationServiceTypes;
this.ApplicationTypes = KubernetesApplicationTypes; this.ApplicationTypes = KubernetesApplicationTypes;
this.ServiceTypes = KubernetesServiceTypes; this.ServiceTypes = KubernetesServiceTypes;
this.KubernetesDeploymentTypes = KubernetesDeploymentTypes; this.KubernetesDeploymentTypes = KubernetesDeploymentTypes;
@ -151,6 +145,9 @@ class KubernetesCreateApplicationController {
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); this.onChangePlacements = this.onChangePlacements.bind(this);
this.updateApplicationType = this.updateApplicationType.bind(this);
this.getAppType = this.getAppType.bind(this);
this.showDataAccessPolicySection = this.showDataAccessPolicySection.bind(this);
} }
/* #endregion */ /* #endregion */
@ -165,6 +162,24 @@ class KubernetesCreateApplicationController {
this.$scope.$evalAsync(() => { this.$scope.$evalAsync(() => {
this.formValues.DeploymentType = value; this.formValues.DeploymentType = value;
}); });
this.updateApplicationType();
}
updateApplicationType() {
return this.$scope.$evalAsync(() => {
this.formValues.ApplicationType = this.getAppType();
this.validatePersistedFolders();
});
}
getAppType() {
if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Global) {
return this.ApplicationTypes.DaemonSet;
}
if (this.formValues.PersistedFolders && this.formValues.PersistedFolders.length) {
return this.ApplicationTypes.StatefulSet;
}
return this.ApplicationTypes.Deployment;
} }
onChangeFileContent(value) { onChangeFileContent(value) {
@ -230,28 +245,23 @@ class KubernetesCreateApplicationController {
/* #region AUTO SCALER UI MANAGEMENT */ /* #region AUTO SCALER UI MANAGEMENT */
unselectAutoScaler() { unselectAutoScaler() {
if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.GLOBAL) { if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Global) {
this.formValues.AutoScaler.IsUsed = false; this.formValues.AutoScaler.isUsed = false;
} }
} }
onAutoScaleChange(values) { onAutoScaleChange(values) {
return this.$async(async () => { return this.$async(async () => {
if (!this.formValues.AutoScaler.IsUsed && values.isUsed) { if (!this.formValues.AutoScaler.isUsed && values.isUsed) {
this.formValues.AutoScaler = { this.formValues.AutoScaler = {
IsUsed: values.isUsed, isUsed: values.isUsed,
MinReplicas: 1, minReplicas: 1,
MaxReplicas: 3, maxReplicas: 3,
TargetCPUUtilization: 50, targetCpuUtilizationPercentage: 50,
}; };
return; return;
} }
this.formValues.AutoScaler = { this.formValues.AutoScaler = values;
IsUsed: values.isUsed,
MinReplicas: values.minReplicas,
MaxReplicas: values.maxReplicas,
TargetCPUUtilization: values.targetCpuUtilizationPercentage,
};
}); });
} }
/* #endregion */ /* #endregion */
@ -319,22 +329,6 @@ class KubernetesCreateApplicationController {
/* #endregion */ /* #endregion */
/* #region PERSISTENT FOLDERS UI MANAGEMENT */ /* #region PERSISTENT FOLDERS UI MANAGEMENT */
addPersistedFolder() {
let storageClass = {};
if (this.storageClasses.length > 0) {
storageClass = this.storageClasses[0];
}
const newPf = new KubernetesApplicationPersistedFolderFormValue(storageClass);
this.formValues.PersistedFolders.push(newPf);
this.resetDeploymentType();
}
restorePersistedFolder(index) {
this.formValues.PersistedFolders[index].needsDeletion = false;
this.validatePersistedFolders();
}
resetPersistedFolders() { resetPersistedFolders() {
this.formValues.PersistedFolders = _.forEach(this.formValues.PersistedFolders, (persistedFolder) => { this.formValues.PersistedFolders = _.forEach(this.formValues.PersistedFolders, (persistedFolder) => {
persistedFolder.existingVolume = null; persistedFolder.existingVolume = null;
@ -342,32 +336,6 @@ class KubernetesCreateApplicationController {
}); });
this.validatePersistedFolders(); this.validatePersistedFolders();
} }
removePersistedFolder(index) {
if (this.state.isEdit && this.formValues.PersistedFolders[index].persistentVolumeClaimName) {
this.formValues.PersistedFolders[index].needsDeletion = true;
} else {
this.formValues.PersistedFolders.splice(index, 1);
}
this.validatePersistedFolders();
}
useNewVolume(index) {
this.formValues.PersistedFolders[index].useNewVolume = true;
this.formValues.PersistedFolders[index].existingVolume = null;
this.state.persistedFoldersUseExistingVolumes = _.some(this.formValues.PersistedFolders, { useNewVolume: false });
this.validatePersistedFolders();
}
useExistingVolume(index) {
this.formValues.PersistedFolders[index].useNewVolume = false;
this.state.persistedFoldersUseExistingVolumes = _.some(this.formValues.PersistedFolders, { useNewVolume: false });
if (this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.ISOLATED) {
this.formValues.DataAccessPolicy = this.ApplicationDataAccessPolicies.SHARED;
this.resetDeploymentType();
}
this.validatePersistedFolders();
}
/* #endregion */ /* #endregion */
/* #region PERSISTENT FOLDERS ON CHANGE VALIDATION */ /* #region PERSISTENT FOLDERS ON CHANGE VALIDATION */
@ -389,7 +357,14 @@ class KubernetesCreateApplicationController {
} }
onChangePersistedFolder(values) { onChangePersistedFolder(values) {
this.formValues.PersistedFolders = values; this.$scope.$evalAsync(() => {
this.formValues.PersistedFolders = values;
if (values && values.length && !this.supportGlobalDeployment()) {
this.onChangeDeploymentType(this.ApplicationDeploymentTypes.Replicated);
return;
}
this.updateApplicationType();
});
} }
onChangeExistingVolumeSelection() { onChangeExistingVolumeSelection() {
@ -445,13 +420,12 @@ class KubernetesCreateApplicationController {
} }
resetDeploymentType() { resetDeploymentType() {
this.formValues.DeploymentType = this.ApplicationDeploymentTypes.REPLICATED; this.formValues.DeploymentType = this.ApplicationDeploymentTypes.Replicated;
} }
// The data access policy panel is not shown when: // // The data access policy panel is shown when a persisted folder is specified
// * There is not persisted folder specified
showDataAccessPolicySection() { showDataAccessPolicySection() {
return this.formValues.PersistedFolders.length !== 0; return this.formValues.PersistedFolders.length > 0;
} }
// A global deployment is not available when either: // A global deployment is not available when either:
@ -460,7 +434,7 @@ class KubernetesCreateApplicationController {
supportGlobalDeployment() { supportGlobalDeployment() {
const hasFolders = this.formValues.PersistedFolders.length !== 0; const hasFolders = this.formValues.PersistedFolders.length !== 0;
const hasRWOOnly = KubernetesApplicationHelper.hasRWOOnly(this.formValues); const hasRWOOnly = KubernetesApplicationHelper.hasRWOOnly(this.formValues);
const isIsolated = this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.ISOLATED; const isIsolated = this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.Isolated;
if (hasFolders && (hasRWOOnly || isIsolated)) { if (hasFolders && (hasRWOOnly || isIsolated)) {
return false; return false;
@ -469,9 +443,9 @@ class KubernetesCreateApplicationController {
return true; return true;
} }
// A StatefulSet is defined by DataAccessPolicy === ISOLATED // A StatefulSet is defined by DataAccessPolicy === 'Isolated'
isEditAndStatefulSet() { isEditAndStatefulSet() {
return this.state.isEdit && this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.ISOLATED; return this.state.isEdit && this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.Isolated;
} }
// A scalable deployment is available when either: // A scalable deployment is available when either:
@ -482,7 +456,7 @@ class KubernetesCreateApplicationController {
supportScalableReplicaDeployment() { supportScalableReplicaDeployment() {
const hasFolders = this.formValues.PersistedFolders.length !== 0; const hasFolders = this.formValues.PersistedFolders.length !== 0;
const hasRWOOnly = KubernetesApplicationHelper.hasRWOOnly(this.formValues); const hasRWOOnly = KubernetesApplicationHelper.hasRWOOnly(this.formValues);
const isIsolated = this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.ISOLATED; const isIsolated = this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.Isolated;
if (!hasFolders || isIsolated || (hasFolders && !hasRWOOnly)) { if (!hasFolders || isIsolated || (hasFolders && !hasRWOOnly)) {
return true; return true;
@ -540,7 +514,7 @@ class KubernetesCreateApplicationController {
} }
effectiveInstances() { effectiveInstances() {
return this.formValues.DeploymentType === this.ApplicationDeploymentTypes.GLOBAL ? this.nodeNumber : this.formValues.ReplicaCount; return this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Global ? this.nodeNumber : this.formValues.ReplicaCount;
} }
hasPortErrors() { hasPortErrors() {
@ -564,11 +538,11 @@ class KubernetesCreateApplicationController {
return true; return true;
} }
if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.REPLICATED) { if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Replicated) {
return this.nodesLimits.overflowForReplica(cpu, memory, instances); return this.nodesLimits.overflowForReplica(cpu, memory, instances);
} }
// DeploymentType == GLOBAL // DeploymentType == 'Global'
return this.nodesLimits.overflowForGlobal(cpu, memory); return this.nodesLimits.overflowForGlobal(cpu, memory);
} }
@ -613,7 +587,7 @@ class KubernetesCreateApplicationController {
} }
isExistingVolumeButtonDisabled() { isExistingVolumeButtonDisabled() {
return !this.hasAvailableVolumes() || (this.isEdit && this.application.ApplicationType === this.ApplicationTypes.STATEFULSET); return !this.hasAvailableVolumes() || (this.isEdit && this.application.ApplicationType === this.ApplicationTypes.StatefulSet);
} }
/* #endregion */ /* #endregion */
@ -630,7 +604,7 @@ class KubernetesCreateApplicationController {
const scalable = this.supportScalableReplicaDeployment(); const scalable = this.supportScalableReplicaDeployment();
const global = this.supportGlobalDeployment(); const global = this.supportGlobalDeployment();
const replica = this.formValues.ReplicaCount > 1; const replica = this.formValues.ReplicaCount > 1;
const replicated = this.formValues.DeploymentType === this.ApplicationDeploymentTypes.REPLICATED; const replicated = this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Replicated;
const res = (replicated && !scalable && replica) || (!replicated && !global); const res = (replicated && !scalable && replica) || (!replicated && !global);
return res; return res;
} }
@ -784,7 +758,7 @@ class KubernetesCreateApplicationController {
if (this.savedFormValues) { if (this.savedFormValues) {
this.formValues.PublishingType = this.savedFormValues.PublishingType; this.formValues.PublishingType = this.savedFormValues.PublishingType;
} else { } else {
this.formValues.PublishingType = this.ApplicationPublishingTypes.CLUSTER_IP; this.formValues.PublishingType = this.KubernetesApplicationServiceTypes.ClusterIP;
} }
} }
this.formValues.OriginalIngresses = this.ingresses; this.formValues.OriginalIngresses = this.ingresses;
@ -1070,9 +1044,8 @@ class KubernetesCreateApplicationController {
this.savedFormValues = angular.copy(this.formValues); this.savedFormValues = angular.copy(this.formValues);
this.updateNamespaceLimits(this.namespaceWithQuota); this.updateNamespaceLimits(this.namespaceWithQuota);
this.updateSliders(this.namespaceWithQuota); this.updateSliders(this.namespaceWithQuota);
delete this.formValues.ApplicationType;
if (this.application.ApplicationType !== KubernetesApplicationTypes.STATEFULSET) { if (this.application.ApplicationType !== KubernetesApplicationTypes.StatefulSet) {
_.forEach(this.formValues.PersistedFolders, (persistedFolder) => { _.forEach(this.formValues.PersistedFolders, (persistedFolder) => {
const volume = _.find(this.availableVolumes, ['PersistentVolumeClaim.Name', persistedFolder.persistentVolumeClaimName]); const volume = _.find(this.availableVolumes, ['PersistentVolumeClaim.Name', persistedFolder.persistentVolumeClaimName]);
if (volume) { if (volume) {

View File

@ -1,12 +1,9 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { KubernetesResourceTypes, KubernetesResourceActions } from 'Kubernetes/models/resource-types/models'; import { KubernetesResourceTypes, KubernetesResourceActions } from 'Kubernetes/models/resource-types/models';
import { KubernetesApplicationFormValues } from 'Kubernetes/models/application/formValues'; import { KubernetesApplicationFormValues } from 'Kubernetes/models/application/formValues';
import { KubernetesDeployment } from 'Kubernetes/models/deployment/models';
import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models'; import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models';
import { KubernetesDaemonSet } from 'Kubernetes/models/daemon-set/models';
import { KubernetesService, KubernetesServiceTypes } from 'Kubernetes/models/service/models'; import { KubernetesService, KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import { KubernetesApplication, KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; import { KubernetesApplicationDeploymentTypes } from 'Kubernetes/models/application/models/appConstants';
import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-pod-auto-scaler/helper';
import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter'; import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter';
import KubernetesApplicationConverter from 'Kubernetes/converters/application'; import KubernetesApplicationConverter from 'Kubernetes/converters/application';
import KubernetesServiceConverter from 'Kubernetes/converters/service'; import KubernetesServiceConverter from 'Kubernetes/converters/service';
@ -33,7 +30,7 @@ export function getApplicationResources(formValues, oldFormValues = {}) {
* Get summary of Kubernetes resources to be created * Get summary of Kubernetes resources to be created
* @param {KubernetesApplicationFormValues} formValues * @param {KubernetesApplicationFormValues} formValues
*/ */
function getCreatedApplicationResources(formValues) { export function getCreatedApplicationResources(formValues) {
const resources = []; const resources = [];
let [app, headlessService, services, service, claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues); let [app, headlessService, services, service, claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues);
@ -65,14 +62,14 @@ function getCreatedApplicationResources(formValues) {
} }
// Horizontal pod autoscalers // Horizontal pod autoscalers
if (formValues.AutoScaler.IsUsed && formValues.DeploymentType !== KubernetesApplicationDeploymentTypes.GLOBAL) { if (formValues.AutoScaler.IsUsed && formValues.DeploymentType !== KubernetesApplicationDeploymentTypes.Global) {
const kind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(app); const kind = app.ApplicationType;
const autoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(formValues, kind); const autoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(formValues, kind);
resources.push({ action: CREATE, kind: KubernetesResourceTypes.HORIZONTAL_POD_AUTOSCALER, name: autoScaler.Name }); resources.push({ action: CREATE, kind: KubernetesResourceTypes.HORIZONTAL_POD_AUTOSCALER, name: autoScaler.Name });
} }
// Deployment // Deployment
const appResourceType = getApplicationResourceType(app); const appResourceType = app.ApplicationType;
if (appResourceType !== null) { if (appResourceType !== null) {
resources.push({ action: CREATE, kind: appResourceType, name: app.Name }); resources.push({ action: CREATE, kind: appResourceType, name: app.Name });
} }
@ -85,13 +82,13 @@ function getCreatedApplicationResources(formValues) {
* @param {KubernetesApplicationFormValues} oldFormValues * @param {KubernetesApplicationFormValues} oldFormValues
* @param {KubernetesApplicationFormValues} newFormValues * @param {KubernetesApplicationFormValues} newFormValues
*/ */
function getUpdatedApplicationResources(oldFormValues, newFormValues) { export function getUpdatedApplicationResources(oldFormValues, newFormValues) {
const resources = []; const resources = [];
const [oldApp, oldHeadlessService, oldServices, oldService, oldClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(oldFormValues); const [oldApp, oldHeadlessService, oldServices, oldService, oldClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(oldFormValues);
const [newApp, newHeadlessService, newServices, newService, newClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(newFormValues); const [newApp, newHeadlessService, newServices, newService, newClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(newFormValues);
const oldAppResourceType = getApplicationResourceType(oldApp); const oldAppResourceType = oldApp.ApplicationType;
const newAppResourceType = getApplicationResourceType(newApp); const newAppResourceType = newApp.ApplicationType;
if (oldAppResourceType !== newAppResourceType) { if (oldAppResourceType !== newAppResourceType) {
// Deployment // Deployment
@ -152,7 +149,7 @@ function getUpdatedApplicationResources(oldFormValues, newFormValues) {
resources.push({ action: DELETE, kind: KubernetesResourceTypes.SERVICE, name: oldService.Name, type: oldService.Type || KubernetesServiceTypes.CLUSTER_IP }); resources.push({ action: DELETE, kind: KubernetesResourceTypes.SERVICE, name: oldService.Name, type: oldService.Type || KubernetesServiceTypes.CLUSTER_IP });
} }
const newKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(newApp); const newKind = newApp.ApplicationType;
const newAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(newFormValues, newKind); const newAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(newFormValues, newKind);
if (!oldFormValues.AutoScaler.IsUsed) { if (!oldFormValues.AutoScaler.IsUsed) {
if (newFormValues.AutoScaler.IsUsed) { if (newFormValues.AutoScaler.IsUsed) {
@ -161,7 +158,7 @@ function getUpdatedApplicationResources(oldFormValues, newFormValues) {
} }
} else { } else {
// Horizontal pod autoscalers // Horizontal pod autoscalers
const oldKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(oldApp); const oldKind = oldApp.ApplicationType;
const oldAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(oldFormValues, oldKind); const oldAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(oldFormValues, oldKind);
if (newFormValues.AutoScaler.IsUsed) { if (newFormValues.AutoScaler.IsUsed) {
const hpaUpdateSummary = getHorizontalPodAutoScalerUpdateResourceSummary(oldAutoScaler, newAutoScaler); const hpaUpdateSummary = getHorizontalPodAutoScalerUpdateResourceSummary(oldAutoScaler, newAutoScaler);
@ -176,17 +173,6 @@ function getUpdatedApplicationResources(oldFormValues, newFormValues) {
return resources; return resources;
} }
function getApplicationResourceType(app) {
if (app instanceof KubernetesDeployment || (app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.DEPLOYMENT)) {
return KubernetesResourceTypes.DEPLOYMENT;
} else if (app instanceof KubernetesDaemonSet || (app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.DAEMONSET)) {
return KubernetesResourceTypes.DAEMONSET;
} else if (app instanceof KubernetesStatefulSet || (app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.STATEFULSET)) {
return KubernetesResourceTypes.STATEFULSET;
}
return null;
}
function getIngressUpdateSummary(oldIngresses, newIngresses) { function getIngressUpdateSummary(oldIngresses, newIngresses) {
const ingressesSummaries = newIngresses const ingressesSummaries = newIngresses
.map((newIng) => { .map((newIng) => {

View File

@ -5,15 +5,15 @@ import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { convertToArrayOfStrings, parseDotEnvFile } from './utils'; import { convertToArrayOfStrings, parseDotEnvFile } from './utils';
import { type Value } from './types'; import { type Values } from './types';
export function AdvancedMode({ export function AdvancedMode({
value, value,
onChange, onChange,
onSimpleModeClick, onSimpleModeClick,
}: { }: {
value: Value; value: Values;
onChange: (value: Value) => void; onChange: (value: Values) => void;
onSimpleModeClick: () => void; onSimpleModeClick: () => void;
}) { }) {
const editorValue = convertToArrayOfStrings(value).join('\n'); const editorValue = convertToArrayOfStrings(value).join('\n');

View File

@ -6,7 +6,7 @@ import { buildUniquenessTest } from '../validate-unique';
import { AdvancedMode } from './AdvancedMode'; import { AdvancedMode } from './AdvancedMode';
import { SimpleMode } from './SimpleMode'; import { SimpleMode } from './SimpleMode';
import { Value } from './types'; import { Values } from './types';
export function EnvironmentVariablesFieldset({ export function EnvironmentVariablesFieldset({
onChange, onChange,
@ -14,9 +14,9 @@ export function EnvironmentVariablesFieldset({
errors, errors,
canUndoDelete, canUndoDelete,
}: { }: {
values: Value; values: Values;
onChange(value: Value): void; onChange(value: Values): void;
errors?: ArrayError<Value>; errors?: ArrayError<Values>;
canUndoDelete?: boolean; canUndoDelete?: boolean;
}) { }) {
const [simpleMode, setSimpleMode] = useState(true); const [simpleMode, setSimpleMode] = useState(true);
@ -42,7 +42,7 @@ export function EnvironmentVariablesFieldset({
); );
} }
export function envVarValidation(): SchemaOf<Value> { export function envVarValidation(): SchemaOf<Values> {
return array( return array(
object({ object({
name: string().required('Name is required'), name: string().required('Name is required'),

View File

@ -3,7 +3,7 @@ import { TextTip } from '@@/Tip/TextTip';
import { ArrayError } from '../InputList/InputList'; import { ArrayError } from '../InputList/InputList';
import { Value } from './types'; import { Values } from './types';
import { EnvironmentVariablesFieldset } from './EnvironmentVariablesFieldset'; import { EnvironmentVariablesFieldset } from './EnvironmentVariablesFieldset';
export function EnvironmentVariablesPanel({ export function EnvironmentVariablesPanel({
@ -15,10 +15,10 @@ export function EnvironmentVariablesPanel({
isFoldable = false, isFoldable = false,
}: { }: {
explanation?: string; explanation?: string;
values: Value; values: Values;
onChange(value: Value): void; onChange(value: Values): void;
showHelpMessage?: boolean; showHelpMessage?: boolean;
errors?: ArrayError<Value>; errors?: ArrayError<Values>;
isFoldable?: boolean; isFoldable?: boolean;
}) { }) {
return ( return (

View File

@ -9,7 +9,7 @@ import { FileUploadField } from '@@/form-components/FileUpload';
import { InputList } from '@@/form-components/InputList'; import { InputList } from '@@/form-components/InputList';
import { ArrayError } from '@@/form-components/InputList/InputList'; import { ArrayError } from '@@/form-components/InputList/InputList';
import type { Value } from './types'; import type { Values } from './types';
import { parseDotEnvFile } from './utils'; import { parseDotEnvFile } from './utils';
import { EnvironmentVariableItem } from './EnvironmentVariableItem'; import { EnvironmentVariableItem } from './EnvironmentVariableItem';
@ -20,10 +20,10 @@ export function SimpleMode({
errors, errors,
canUndoDelete, canUndoDelete,
}: { }: {
value: Value; value: Values;
onChange: (value: Value) => void; onChange: (value: Values) => void;
onAdvancedModeClick: () => void; onAdvancedModeClick: () => void;
errors?: ArrayError<Value>; errors?: ArrayError<Values>;
canUndoDelete?: boolean; canUndoDelete?: boolean;
}) { }) {
return ( return (
@ -70,7 +70,7 @@ export function SimpleMode({
); );
} }
function FileEnv({ onChooseFile }: { onChooseFile: (file: Value) => void }) { function FileEnv({ onChooseFile }: { onChooseFile: (file: Values) => void }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const fileTooBig = file && file.size > 1024 * 1024; const fileTooBig = file && file.size > 1024 * 1024;

View File

@ -5,4 +5,4 @@ export {
export { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel'; export { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel';
export { type Value as EnvVarValues } from './types'; export { type Values as EnvVarValues } from './types';

View File

@ -4,4 +4,4 @@ export interface EnvVar {
needsDeletion?: boolean; needsDeletion?: boolean;
} }
export type Value = Array<EnvVar>; export type Values = Array<EnvVar>;

View File

@ -1,16 +1,17 @@
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset'; import {
EnvVarValues,
EnvironmentVariablesPanel,
} from '@@/form-components/EnvironmentVariablesFieldset';
import { ArrayError } from '@@/form-components/InputList/InputList'; import { ArrayError } from '@@/form-components/InputList/InputList';
import { Values } from './types';
export function EnvVarsTab({ export function EnvVarsTab({
values, values,
onChange, onChange,
errors, errors,
}: { }: {
values: Values; values: EnvVarValues;
onChange(value: Values): void; onChange(value: EnvVarValues): void;
errors?: ArrayError<Values>; errors?: ArrayError<EnvVarValues>;
}) { }) {
return ( return (
<div className="form-group"> <div className="form-group">
@ -23,7 +24,7 @@ export function EnvVarsTab({
</div> </div>
); );
function handleChange(values: Values) { function handleChange(values: EnvVarValues) {
onChange(values); onChange(values);
} }
} }

View File

@ -4,7 +4,6 @@ import { toRequest } from './toRequest';
import { toViewModel, getDefaultViewModel } from './toViewModel'; import { toViewModel, getDefaultViewModel } from './toViewModel';
export { EnvVarsTab } from './EnvVarsTab'; export { EnvVarsTab } from './EnvVarsTab';
export type { Values } from './types';
export const envVarsTabUtils = { export const envVarsTabUtils = {
toRequest, toRequest,

View File

@ -1,12 +1,11 @@
import { convertToArrayOfStrings } from '@@/form-components/EnvironmentVariablesFieldset/utils'; import { convertToArrayOfStrings } from '@@/form-components/EnvironmentVariablesFieldset/utils';
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
import { CreateContainerRequest } from '../types'; import { CreateContainerRequest } from '../types';
import { Values } from './types';
export function toRequest( export function toRequest(
oldConfig: CreateContainerRequest, oldConfig: CreateContainerRequest,
values: Values values: EnvVarValues
): CreateContainerRequest { ): CreateContainerRequest {
return { return {
...oldConfig, ...oldConfig,

View File

@ -1 +0,0 @@
export type { Value as Values } from '@@/form-components/EnvironmentVariablesFieldset/types';

View File

@ -32,10 +32,7 @@ import {
VolumesTabValues, VolumesTabValues,
volumesTabUtils, volumesTabUtils,
} from '@/react/docker/containers/CreateView/VolumesTab'; } from '@/react/docker/containers/CreateView/VolumesTab';
import { import { envVarsTabUtils } from '@/react/docker/containers/CreateView/EnvVarsTab';
Values as EnvVarsTabValues,
envVarsTabUtils,
} from '@/react/docker/containers/CreateView/EnvVarsTab';
import { UserId } from '@/portainer/users/types'; import { UserId } from '@/portainer/users/types';
import { getImageConfig } from '@/react/portainer/registries/utils/getImageConfig'; import { getImageConfig } from '@/react/portainer/registries/utils/getImageConfig';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
@ -43,6 +40,8 @@ import { useCurrentUser } from '@/react/hooks/useUser';
import { useWebhooks } from '@/react/portainer/webhooks/useWebhooks'; import { useWebhooks } from '@/react/portainer/webhooks/useWebhooks';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries'; import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
import { useNetworksForSelector } from '../components/NetworkSelector'; import { useNetworksForSelector } from '../components/NetworkSelector';
import { useContainers } from '../queries/containers'; import { useContainers } from '../queries/containers';
import { useContainer } from '../queries/container'; import { useContainer } from '../queries/container';
@ -55,7 +54,7 @@ export interface Values extends BaseFormValues {
restartPolicy: RestartPolicy; restartPolicy: RestartPolicy;
resources: ResourcesTabValues; resources: ResourcesTabValues;
capabilities: CapabilitiesTabValues; capabilities: CapabilitiesTabValues;
env: EnvVarsTabValues; env: EnvVarValues;
} }
export function useInitialValues(submitting: boolean) { export function useInitialValues(submitting: boolean) {

View File

@ -2,11 +2,13 @@ import { BoxSelector } from '@@/BoxSelector';
import { FormSection } from '@@/form-components/FormSection'; import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { DeploymentType } from '../types';
import { getDeploymentOptions } from './deploymentOptions'; import { getDeploymentOptions } from './deploymentOptions';
interface Props { interface Props {
value: number; value: DeploymentType;
onChange(value: number): void; onChange(value: DeploymentType): void;
supportGlobalDeployment: boolean; supportGlobalDeployment: boolean;
} }

View File

@ -1,14 +1,14 @@
import { Box, Boxes } from 'lucide-react'; import { Box, Boxes } from 'lucide-react';
import { KubernetesApplicationDataAccessPolicies } from '@/kubernetes/models/application/models';
import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector'; import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector';
import { AppDataAccessPolicy } from '../types';
interface Props { interface Props {
isEdit: boolean; isEdit: boolean;
persistedFoldersUseExistingVolumes: boolean; persistedFoldersUseExistingVolumes: boolean;
value: number; value: AppDataAccessPolicy;
onChange(value: number): void; onChange(value: AppDataAccessPolicy): void;
} }
export function DataAccessPolicyFormSection({ export function DataAccessPolicyFormSection({
@ -31,13 +31,13 @@ export function DataAccessPolicyFormSection({
} }
function getOptions( function getOptions(
value: number, value: AppDataAccessPolicy,
isEdit: boolean, isEdit: boolean,
persistedFoldersUseExistingVolumes: boolean persistedFoldersUseExistingVolumes: boolean
): ReadonlyArray<BoxSelectorOption<number>> { ): ReadonlyArray<BoxSelectorOption<AppDataAccessPolicy>> {
return [ return [
{ {
value: KubernetesApplicationDataAccessPolicies.ISOLATED, value: 'Isolated',
id: 'data_access_isolated', id: 'data_access_isolated',
icon: Boxes, icon: Boxes,
iconType: 'badge', iconType: 'badge',
@ -49,12 +49,10 @@ function getOptions(
? 'Changing the data access policy is not allowed' ? 'Changing the data access policy is not allowed'
: '', : '',
disabled: () => disabled: () =>
(isEdit && (isEdit && value !== 'Isolated') || persistedFoldersUseExistingVolumes,
value !== KubernetesApplicationDataAccessPolicies.ISOLATED) ||
persistedFoldersUseExistingVolumes,
}, },
{ {
value: KubernetesApplicationDataAccessPolicies.SHARED, value: 'Shared',
id: 'data_access_shared', id: 'data_access_shared',
icon: Box, icon: Box,
iconType: 'badge', iconType: 'badge',
@ -63,8 +61,7 @@ function getOptions(
'Application will be deployed as a Deployment with a shared storage access', 'Application will be deployed as a Deployment with a shared storage access',
tooltip: () => tooltip: () =>
isEdit ? 'Changing the data access policy is not allowed' : '', isEdit ? 'Changing the data access policy is not allowed' : '',
disabled: () => disabled: () => isEdit && value !== 'Shared',
isEdit && value !== KubernetesApplicationDataAccessPolicies.SHARED,
}, },
] as const; ] as const;
} }

View File

@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { FormikErrors } from 'formik'; import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { import {
useIngressControllers, useIngressControllers,
@ -10,12 +9,7 @@ import {
import { FormSection } from '@@/form-components/FormSection'; import { FormSection } from '@@/form-components/FormSection';
import { import { ServiceFormValues, ServiceTypeOption, ServiceType } from './types';
ServiceFormValues,
ServiceTypeAngularEnum,
ServiceTypeOption,
ServiceTypeValue,
} from './types';
import { generateUniqueName } from './utils'; import { generateUniqueName } from './utils';
import { ClusterIpServicesForm } from './cluster-ip/ClusterIpServicesForm'; import { ClusterIpServicesForm } from './cluster-ip/ClusterIpServicesForm';
import { ServiceTabs } from './components/ServiceTabs'; import { ServiceTabs } from './components/ServiceTabs';
@ -24,15 +18,6 @@ import { LoadBalancerServicesForm } from './load-balancer/LoadBalancerServicesFo
import { ServiceTabLabel } from './components/ServiceTabLabel'; import { ServiceTabLabel } from './components/ServiceTabLabel';
import { PublishingExplaination } from './PublishingExplaination'; import { PublishingExplaination } from './PublishingExplaination';
const serviceTypeEnumsToValues: Record<
ServiceTypeAngularEnum,
ServiceTypeValue
> = {
[KubernetesApplicationPublishingTypes.CLUSTER_IP]: 'ClusterIP',
[KubernetesApplicationPublishingTypes.NODE_PORT]: 'NodePort',
[KubernetesApplicationPublishingTypes.LOAD_BALANCER]: 'LoadBalancer',
};
interface Props { interface Props {
values: ServiceFormValues[]; values: ServiceFormValues[];
onChange: (services: ServiceFormValues[]) => void; onChange: (services: ServiceFormValues[]) => void;
@ -53,7 +38,7 @@ export function KubeServicesForm({
namespace, namespace,
}: Props) { }: Props) {
const [selectedServiceType, setSelectedServiceType] = const [selectedServiceType, setSelectedServiceType] =
useState<ServiceTypeValue>('ClusterIP'); useState<ServiceType>('ClusterIP');
// start loading ingresses and controllers early to reduce perceived loading time // start loading ingresses and controllers early to reduce perceived loading time
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
@ -195,17 +180,17 @@ function getUniqNames(appName: string, services: ServiceFormValues[]) {
*/ */
function getServiceTypeCounts( function getServiceTypeCounts(
services: ServiceFormValues[] services: ServiceFormValues[]
): Record<ServiceTypeValue, number> { ): Record<ServiceType, number> {
return services.reduce( return services.reduce(
(acc, service) => { (acc, service) => {
const type = serviceTypeEnumsToValues[service.Type]; const type = service.Type;
const count = acc[type]; const count = acc[type];
return { return {
...acc, ...acc,
[type]: count ? count + 1 : 1, [type]: count ? count + 1 : 1,
}; };
}, },
{} as Record<ServiceTypeValue, number> {} as Record<ServiceType, number>
); );
} }
@ -215,10 +200,10 @@ function getServiceTypeCounts(
function getServiceTypeHasErrors( function getServiceTypeHasErrors(
services: ServiceFormValues[], services: ServiceFormValues[],
errors: FormikErrors<ServiceFormValues[] | undefined> errors: FormikErrors<ServiceFormValues[] | undefined>
): Record<ServiceTypeValue, boolean> { ): Record<ServiceType, boolean> {
return services.reduce( return services.reduce(
(acc, service, index) => { (acc, service, index) => {
const type = serviceTypeEnumsToValues[service.Type]; const type = service.Type;
const serviceHasErrors = !!errors?.[index]; const serviceHasErrors = !!errors?.[index];
// if the service type already has an error, don't overwrite it // if the service type already has an error, don't overwrite it
if (acc[type] === true) return acc; if (acc[type] === true) return acc;
@ -228,6 +213,6 @@ function getServiceTypeHasErrors(
[type]: serviceHasErrors, [type]: serviceHasErrors,
}; };
}, },
{} as Record<ServiceTypeValue, boolean> {} as Record<ServiceType, boolean>
); );
} }

View File

@ -88,10 +88,7 @@ export function ClusterIpServiceForm({
value={servicePort.targetPort} value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => { onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts]; const newServicePorts = [...servicePorts];
const newValue = const newValue = e.target.valueAsNumber;
e.target.value === ''
? undefined
: Number(e.target.value);
newServicePorts[portIndex] = { newServicePorts[portIndex] = {
...newServicePorts[portIndex], ...newServicePorts[portIndex],
targetPort: newValue, targetPort: newValue,
@ -113,10 +110,7 @@ export function ClusterIpServiceForm({
const newServicePorts = [...servicePorts]; const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = { newServicePorts[portIndex] = {
...newServicePorts[portIndex], ...newServicePorts[portIndex],
port: port: e.target.valueAsNumber,
e.target.value === ''
? undefined
: Number(e.target.value),
}; };
onChangePort(newServicePorts); onChangePort(newServicePorts);
}} }}

View File

@ -1,8 +1,6 @@
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { FormikErrors } from 'formik'; import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { Card } from '@@/Card'; import { Card } from '@@/Card';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
@ -36,8 +34,7 @@ export function ClusterIpServicesForm({
isEditMode, isEditMode,
}: Props) { }: Props) {
const clusterIPServiceCount = services.filter( const clusterIPServiceCount = services.filter(
(service) => (service) => service.Type === 'ClusterIP'
service.Type === KubernetesApplicationPublishingTypes.CLUSTER_IP
).length; ).length;
return ( return (
<Card className="pb-5"> <Card className="pb-5">
@ -50,8 +47,7 @@ export function ClusterIpServicesForm({
{clusterIPServiceCount > 0 && ( {clusterIPServiceCount > 0 && (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
{services.map((service, index) => {services.map((service, index) =>
service.Type === service.Type === 'ClusterIP' ? (
KubernetesApplicationPublishingTypes.CLUSTER_IP ? (
<ClusterIpServiceForm <ClusterIpServiceForm
key={index} key={index}
serviceName={service.Name} serviceName={service.Name}
@ -86,7 +82,7 @@ export function ClusterIpServicesForm({
services.length + 1, services.length + 1,
services services
); );
newService.Type = KubernetesApplicationPublishingTypes.CLUSTER_IP; newService.Type = 'ClusterIP';
const newServicePort = newPort(newService.Name); const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort]; newService.Ports = [newServicePort];
newService.Selector = selector; newService.Selector = selector;

View File

@ -22,7 +22,7 @@ export function ContainerPortInput({
type="number" type="number"
className="form-control min-w-max" className="form-control min-w-max"
name={`container_port_${portIndex}`} name={`container_port_${portIndex}`}
placeholder="80" placeholder="e.g. 80"
min="1" min="1"
max="65535" max="65535"
value={value ?? ''} value={value ?? ''}

View File

@ -22,7 +22,7 @@ export function ServicePortInput({
type="number" type="number"
className="form-control min-w-max" className="form-control min-w-max"
name={`service_port_${portIndex}`} name={`service_port_${portIndex}`}
placeholder="80" placeholder="e.g. 80"
min="1" min="1"
max="65535" max="65535"
value={value ?? ''} value={value ?? ''}

View File

@ -1,11 +1,11 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { ServiceTypeOption, ServiceTypeValue } from '../types'; import { ServiceTypeOption, ServiceType } from '../types';
type Props = { type Props = {
serviceTypeOptions: ServiceTypeOption[]; serviceTypeOptions: ServiceTypeOption[];
selectedServiceType: ServiceTypeValue; selectedServiceType: ServiceType;
setSelectedServiceType: (serviceTypeValue: ServiceTypeValue) => void; setSelectedServiceType: (serviceTypeValue: ServiceType) => void;
}; };
export function ServiceTabs({ export function ServiceTabs({
@ -32,7 +32,7 @@ export function ServiceTabs({
value={serviceTypeOptions[index].value} value={serviceTypeOptions[index].value}
checked={selectedServiceType === serviceTypeOptions[index].value} checked={selectedServiceType === serviceTypeOptions[index].value}
onChange={(e) => onChange={(e) =>
setSelectedServiceType(e.target.value as ServiceTypeValue) setSelectedServiceType(e.target.value as ServiceType)
} }
/> />
{label} {label}

View File

@ -1,7 +1,5 @@
import { SchemaOf, array, object, boolean, string, mixed, number } from 'yup'; import { SchemaOf, array, object, boolean, string, mixed, number } from 'yup';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { ServiceFormValues, ServicePort } from './types'; import { ServiceFormValues, ServicePort } from './types';
import { prependWithSlash } from './utils'; import { prependWithSlash } from './utils';
@ -46,11 +44,7 @@ export function kubeServicesValidation(
Namespace: string(), Namespace: string(),
Name: string(), Name: string(),
StackName: string(), StackName: string(),
Type: mixed().oneOf([ Type: mixed().oneOf(['ClusterIP', 'NodePort', 'LoadBalancer']),
KubernetesApplicationPublishingTypes.CLUSTER_IP,
KubernetesApplicationPublishingTypes.NODE_PORT,
KubernetesApplicationPublishingTypes.LOAD_BALANCER,
]),
ClusterIP: string(), ClusterIP: string(),
ApplicationName: string(), ApplicationName: string(),
ApplicationOwner: string(), ApplicationOwner: string(),
@ -61,6 +55,7 @@ export function kubeServicesValidation(
object({ object({
port: number() port: number()
.required('Service port number is required.') .required('Service port number is required.')
.typeError('Service port number is required.')
.min(1, 'Service port number must be inside the range 1-65535.') .min(1, 'Service port number must be inside the range 1-65535.')
.max(65535, 'Service port number must be inside the range 1-65535.') .max(65535, 'Service port number must be inside the range 1-65535.')
.test( .test(
@ -93,6 +88,7 @@ export function kubeServicesValidation(
), ),
targetPort: number() targetPort: number()
.required('Container port number is required.') .required('Container port number is required.')
.typeError('Container port number is required.')
.min(1, 'Container port number must be inside the range 1-65535.') .min(1, 'Container port number must be inside the range 1-65535.')
.max( .max(
65535, 65535,
@ -116,8 +112,7 @@ export function kubeServicesValidation(
); );
if ( if (
matchingService === undefined || matchingService === undefined ||
matchingService.Type !== matchingService.Type !== 'NodePort'
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
) { ) {
return true; return true;
} }
@ -143,8 +138,7 @@ export function kubeServicesValidation(
if ( if (
matchingService === undefined || matchingService === undefined ||
matchingService.Type !== matchingService.Type !== 'NodePort'
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
) { ) {
return true; return true;
} }
@ -163,8 +157,7 @@ export function kubeServicesValidation(
const formNodePortsWithoutCurrentService = formServices const formNodePortsWithoutCurrentService = formServices
.filter( .filter(
(formService) => (formService) =>
formService.Type === formService.Type === 'NodePort' &&
KubernetesApplicationPublishingTypes.NODE_PORT &&
formService.Name !== matchingService.Name formService.Name !== matchingService.Name
) )
.flatMap((formService) => formService.Ports) .flatMap((formService) => formService.Ports)
@ -187,11 +180,7 @@ export function kubeServicesValidation(
context.parent as ServicePort, context.parent as ServicePort,
formServices formServices
); );
if ( if (!matchingService || matchingService.Type !== 'NodePort') {
!matchingService ||
matchingService.Type !==
KubernetesApplicationPublishingTypes.NODE_PORT
) {
return true; return true;
} }
return nodePort >= 30000; return nodePort >= 30000;
@ -209,11 +198,7 @@ export function kubeServicesValidation(
context.parent as ServicePort, context.parent as ServicePort,
formServices formServices
); );
if ( if (!matchingService || matchingService.Type !== 'NodePort') {
!matchingService ||
matchingService.Type !==
KubernetesApplicationPublishingTypes.NODE_PORT
) {
return true; return true;
} }
return nodePort <= 32767; return nodePort <= 32767;

View File

@ -93,10 +93,7 @@ export function LoadBalancerServiceForm({
value={servicePort.targetPort} value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => { onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts]; const newServicePorts = [...servicePorts];
const newValue = const newValue = e.target.valueAsNumber;
e.target.value === ''
? undefined
: Number(e.target.value);
newServicePorts[portIndex] = { newServicePorts[portIndex] = {
...newServicePorts[portIndex], ...newServicePorts[portIndex],
targetPort: newValue, targetPort: newValue,
@ -119,10 +116,7 @@ export function LoadBalancerServiceForm({
const newServicePorts = [...servicePorts]; const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = { newServicePorts[portIndex] = {
...newServicePorts[portIndex], ...newServicePorts[portIndex],
port: port: e.target.valueAsNumber,
e.target.value === ''
? undefined
: Number(e.target.value),
}; };
onChangePort(newServicePorts); onChangePort(newServicePorts);
}} }}
@ -140,7 +134,7 @@ export function LoadBalancerServiceForm({
type="number" type="number"
className="form-control min-w-max" className="form-control min-w-max"
name={`loadbalancer_port_${portIndex}`} name={`loadbalancer_port_${portIndex}`}
placeholder="80" placeholder="e.g. 80"
min="1" min="1"
max="65535" max="65535"
value={servicePort.port || ''} value={servicePort.port || ''}
@ -148,10 +142,7 @@ export function LoadBalancerServiceForm({
const newServicePorts = [...servicePorts]; const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = { newServicePorts[portIndex] = {
...newServicePorts[portIndex], ...newServicePorts[portIndex],
port: port: e.target.valueAsNumber,
e.target.value === ''
? undefined
: Number(e.target.value),
}; };
onChangePort(newServicePorts); onChangePort(newServicePorts);
}} }}

View File

@ -1,7 +1,6 @@
import { Plus, RefreshCw } from 'lucide-react'; import { Plus, RefreshCw } from 'lucide-react';
import { FormikErrors } from 'formik'; import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { useCurrentUser } from '@/react/hooks/useUser'; import { useCurrentUser } from '@/react/hooks/useUser';
import { useEnvironment } from '@/react/portainer/environments/queries'; import { useEnvironment } from '@/react/portainer/environments/queries';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
@ -49,8 +48,7 @@ export function LoadBalancerServicesForm({
); );
const loadBalancerServiceCount = services.filter( const loadBalancerServiceCount = services.filter(
(service) => (service) => service.Type === 'LoadBalancer'
service.Type === KubernetesApplicationPublishingTypes.LOAD_BALANCER
).length; ).length;
return ( return (
<Card className="pb-5"> <Card className="pb-5">
@ -95,8 +93,7 @@ export function LoadBalancerServicesForm({
{loadBalancerServiceCount > 0 && ( {loadBalancerServiceCount > 0 && (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
{services.map((service, index) => {services.map((service, index) =>
service.Type === service.Type === 'LoadBalancer' ? (
KubernetesApplicationPublishingTypes.LOAD_BALANCER ? (
<LoadBalancerServiceForm <LoadBalancerServiceForm
key={index} key={index}
serviceName={service.Name} serviceName={service.Name}
@ -131,8 +128,7 @@ export function LoadBalancerServicesForm({
services.length + 1, services.length + 1,
services services
); );
newService.Type = newService.Type = 'LoadBalancer';
KubernetesApplicationPublishingTypes.LOAD_BALANCER;
const newServicePort = newPort(newService.Name); const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort]; newService.Ports = [newServicePort];
newService.Selector = selector; newService.Selector = selector;

View File

@ -94,10 +94,7 @@ export function NodePortServiceForm({
value={servicePort.targetPort} value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => { onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts]; const newServicePorts = [...servicePorts];
const newValue = const newValue = e.target.valueAsNumber;
e.target.value === ''
? undefined
: Number(e.target.value);
newServicePorts[portIndex] = { newServicePorts[portIndex] = {
...newServicePorts[portIndex], ...newServicePorts[portIndex],
targetPort: newValue, targetPort: newValue,
@ -120,10 +117,7 @@ export function NodePortServiceForm({
const newServicePorts = [...servicePorts]; const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = { newServicePorts[portIndex] = {
...newServicePorts[portIndex], ...newServicePorts[portIndex],
port: port: e.target.valueAsNumber,
e.target.value === ''
? undefined
: Number(e.target.value),
}; };
onChangePort(newServicePorts); onChangePort(newServicePorts);
}} }}
@ -139,7 +133,7 @@ export function NodePortServiceForm({
type="number" type="number"
className="form-control min-w-max" className="form-control min-w-max"
name={`node_port_${portIndex}`} name={`node_port_${portIndex}`}
placeholder="30080" placeholder="e.g. 30080"
min="30000" min="30000"
max="32767" max="32767"
value={servicePort.nodePort ?? ''} value={servicePort.nodePort ?? ''}

View File

@ -1,8 +1,6 @@
import { FormikErrors } from 'formik'; import { FormikErrors } from 'formik';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { Card } from '@@/Card'; import { Card } from '@@/Card';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
@ -36,7 +34,7 @@ export function NodePortServicesForm({
isEditMode, isEditMode,
}: Props) { }: Props) {
const nodePortServiceCount = services.filter( const nodePortServiceCount = services.filter(
(service) => service.Type === KubernetesApplicationPublishingTypes.NODE_PORT (service) => service.Type === 'NodePort'
).length; ).length;
return ( return (
<Card className="pb-5"> <Card className="pb-5">
@ -48,8 +46,7 @@ export function NodePortServicesForm({
{nodePortServiceCount > 0 && ( {nodePortServiceCount > 0 && (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
{services.map((service, index) => {services.map((service, index) =>
service.Type === service.Type === 'NodePort' ? (
KubernetesApplicationPublishingTypes.NODE_PORT ? (
<NodePortServiceForm <NodePortServiceForm
key={index} key={index}
serviceName={service.Name} serviceName={service.Name}
@ -84,7 +81,7 @@ export function NodePortServicesForm({
services.length + 1, services.length + 1,
services services
); );
newService.Type = KubernetesApplicationPublishingTypes.NODE_PORT; newService.Type = 'NodePort';
const newServicePort = newPort(newService.Name); const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort]; newService.Ports = [newServicePort];
newService.Selector = selector; newService.Selector = selector;

View File

@ -1,9 +1,7 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
export interface ServicePort { export interface ServicePort {
port?: number; port: number;
targetPort?: number; targetPort?: number;
nodePort?: number; nodePort?: number;
serviceName?: string; serviceName?: string;
@ -12,9 +10,6 @@ export interface ServicePort {
ingressPaths?: ServicePortIngressPath[]; ingressPaths?: ServicePortIngressPath[];
} }
export type ServiceTypeAngularEnum =
(typeof KubernetesApplicationPublishingTypes)[keyof typeof KubernetesApplicationPublishingTypes];
export type ServicePortIngressPath = { export type ServicePortIngressPath = {
IngressName?: string; IngressName?: string;
Host?: string; Host?: string;
@ -24,7 +19,7 @@ export type ServicePortIngressPath = {
export type ServiceFormValues = { export type ServiceFormValues = {
Headless: boolean; Headless: boolean;
Ports: ServicePort[]; Ports: ServicePort[];
Type: ServiceTypeAngularEnum; Type: ServiceType;
Ingress: boolean; Ingress: boolean;
ClusterIP?: string; ClusterIP?: string;
ApplicationName?: string; ApplicationName?: string;
@ -36,9 +31,9 @@ export type ServiceFormValues = {
Namespace?: string; Namespace?: string;
}; };
export type ServiceTypeValue = 'ClusterIP' | 'NodePort' | 'LoadBalancer'; export type ServiceType = 'ClusterIP' | 'NodePort' | 'LoadBalancer';
export type ServiceTypeOption = { export type ServiceTypeOption = {
value: ServiceTypeValue; value: ServiceType;
label: ReactNode; label: ReactNode;
}; };

View File

@ -1,10 +1,21 @@
import { Ingress } from '@/react/kubernetes/ingresses/types'; import { compare } from 'fast-json-patch';
import { Service, ServiceSpec } from 'kubernetes-types/core/v1';
import { ObjectMeta } from 'kubernetes-types/meta/v1';
import angular from 'angular';
import { Ingress as IngressFormValues } from '@/react/kubernetes/ingresses/types';
import {
appNameLabel,
appOwnerLabel,
appStackNameLabel,
} from '../../constants';
import { ServiceFormValues, ServicePort } from './types'; import { ServiceFormValues, ServicePort } from './types';
export function newPort(serviceName?: string) { export function newPort(serviceName?: string): ServicePort {
return { return {
port: undefined, port: 80,
targetPort: undefined, targetPort: undefined,
name: '', name: '',
protocol: 'TCP', protocol: 'TCP',
@ -43,7 +54,7 @@ export const serviceFormDefaultValues: ServiceFormValues = {
Name: '', Name: '',
StackName: '', StackName: '',
Ports: [], Ports: [],
Type: 1, // clusterip type as default Type: 'ClusterIP',
ClusterIP: '', ClusterIP: '',
ApplicationName: '', ApplicationName: '',
ApplicationOwner: '', ApplicationOwner: '',
@ -54,16 +65,16 @@ export const serviceFormDefaultValues: ServiceFormValues = {
/** /**
* Generates new Ingress objects from form path data * Generates new Ingress objects from form path data
* @param {Ingress[]} oldIngresses - The old Ingress objects * @param {IngressFormValues[]} oldIngresses - The old Ingress objects
* @param {ServicePort[]} newServicesPorts - The new ServicePort objects from the form * @param {ServicePort[]} newServicesPorts - The new ServicePort objects from the form
* @param {ServicePort[]} oldServicesPorts - The old ServicePort objects * @param {ServicePort[]} oldServicesPorts - The old ServicePort objects
* @returns {Ingress[]} The new Ingress objects * @returns {IngressFormValues[]} The new Ingress objects
*/ */
export function generateNewIngressesFromFormPaths( export function generateNewIngressesFromFormPaths(
oldIngresses?: Ingress[], oldIngresses?: IngressFormValues[],
newServicesPorts?: ServicePort[], newServicesPorts?: ServicePort[],
oldServicesPorts?: ServicePort[] oldServicesPorts?: ServicePort[]
): Ingress[] { ): IngressFormValues[] {
// filter the ports to only the ones that have an ingress // filter the ports to only the ones that have an ingress
const oldIngressPaths = oldServicesPorts const oldIngressPaths = oldServicesPorts
?.flatMap((port) => port.ingressPaths) ?.flatMap((port) => port.ingressPaths)
@ -77,7 +88,7 @@ export function generateNewIngressesFromFormPaths(
} }
// remove the old paths from the newIngresses copy // remove the old paths from the newIngresses copy
const newIngresses = structuredClone(oldIngresses) ?? []; const newIngresses: IngressFormValues[] = angular.copy(oldIngresses) ?? []; // the current jest version doesn't support structured cloning, so we need to use angular.copy
oldIngressPaths?.forEach((oldIngressPath) => { oldIngressPaths?.forEach((oldIngressPath) => {
if (!oldIngressPath?.Path) return; if (!oldIngressPath?.Path) return;
const newMatchingIng = newIngresses?.find( const newMatchingIng = newIngresses?.find(
@ -151,3 +162,57 @@ export function prependWithSlash(path?: string) {
if (!path) return ''; if (!path) return '';
return path.startsWith('/') ? path : `/${path}`; return path.startsWith('/') ? path : `/${path}`;
} }
export function getServicePatchPayload(
oldService: ServiceFormValues,
newService: ServiceFormValues
) {
const oldPayload = getServicePayload(oldService);
const newPayload = getServicePayload(newService);
const payload = compare(oldPayload, newPayload);
return payload;
}
function getServicePayload(service: ServiceFormValues): Service {
if (!service.Name || !service.Namespace) {
throw new Error('Service name and namespace are required');
}
// metadata
const labels: Record<string, string> = {};
if (service.ApplicationName) {
labels[appNameLabel] = service.ApplicationName;
}
if (service.ApplicationOwner) {
labels[appOwnerLabel] = service.ApplicationOwner;
}
if (service.StackName) {
labels[appStackNameLabel] = service.StackName;
}
const metadata: ObjectMeta = {
name: service.Name,
namespace: service.Namespace,
labels,
};
// spec
const ports = service.Headless ? [] : service.Ports;
const selector = service.Selector;
const clusterIP = service.Headless ? 'None' : service.ClusterIP;
const type = service.Headless ? 'ClusterIP' : service.Type;
const spec: ServiceSpec = {
ports,
selector,
clusterIP,
type,
};
const servicePayload: Service = {
apiVersion: 'v1',
kind: 'Service',
metadata,
spec,
};
return servicePayload;
}

View File

@ -1,17 +1,17 @@
import { Boxes, Sliders } from 'lucide-react'; import { Boxes, Sliders } from 'lucide-react';
import { KubernetesApplicationDeploymentTypes } from '@/kubernetes/models/application/models';
import { BoxSelectorOption } from '@@/BoxSelector'; import { BoxSelectorOption } from '@@/BoxSelector';
import { DeploymentType } from '../types';
export function getDeploymentOptions( export function getDeploymentOptions(
supportGlobalDeployment: boolean supportGlobalDeployment: boolean
): ReadonlyArray<BoxSelectorOption<number>> { ): ReadonlyArray<BoxSelectorOption<DeploymentType>> {
return [ return [
{ {
id: 'deployment_replicated', id: 'deployment_replicated',
label: 'Replicated', label: 'Replicated',
value: KubernetesApplicationDeploymentTypes.REPLICATED, value: 'Replicated',
icon: Sliders, icon: Sliders,
iconType: 'badge', iconType: 'badge',
description: 'Run one or multiple instances of this container', description: 'Run one or multiple instances of this container',
@ -26,7 +26,7 @@ export function getDeploymentOptions(
label: 'Global', label: 'Global',
description: description:
'Application will be deployed as a DaemonSet with an instance on each node of the cluster', 'Application will be deployed as a DaemonSet with an instance on each node of the cluster',
value: KubernetesApplicationDeploymentTypes.GLOBAL, value: 'Global',
icon: Boxes, icon: Boxes,
iconType: 'badge', iconType: 'badge',
}, },

View File

@ -0,0 +1,73 @@
import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
import { ApplicationFormValues } from '../../types';
import { getAppResourceSummaries, getArticle } from './utils';
import { Summary } from './types';
type Props = {
formValues: ApplicationFormValues;
oldFormValues: ApplicationFormValues;
};
export function ApplicationSummarySection({
formValues,
oldFormValues,
}: Props) {
// extract cpu and memory requests & limits for pod
const limits = {
cpu: formValues.CpuLimit,
memory: formValues.MemoryLimit,
};
const appResourceSummaries = getAppResourceSummaries(
formValues,
oldFormValues
);
if (!appResourceSummaries || appResourceSummaries?.length === 0) {
return null;
}
return (
<FormSection title="Summary" isFoldable defaultFolded={false}>
<TextTip color="blue">
Portainer will execute the following Kubernetes actions.
</TextTip>
<ul className="w-full small text-muted ml-5">
{appResourceSummaries.map((summary) => (
<SummaryItem key={JSON.stringify(summary)} summary={summary} />
))}
{!!limits.memory && (
<li>
Set the memory resources limits and requests to{' '}
<code>{limits.memory}M</code>
</li>
)}
{!!limits.cpu && (
<li>
Set the CPU resources limits and requests to{' '}
<code>{limits.cpu}</code>
</li>
)}
</ul>
</FormSection>
);
}
function SummaryItem({ summary }: { summary: Summary }) {
return (
<li>
{`${summary.action} ${getArticle(summary.kind, summary.action)} `}
<span className="bold">{summary.kind}</span>
{' named '}
<code>{summary.name}</code>
{!!summary.type && (
<span>
{' of type '}
<code>{summary.type}</code>
</span>
)}
</li>
);
}

View File

@ -0,0 +1 @@
export { ApplicationSummarySection } from './ApplicationSummarySection';

View File

@ -0,0 +1,21 @@
import { AppKind } from '../../types';
export type KubernetesResourceAction = 'Create' | 'Update' | 'Delete';
export type KubernetesResourceType =
| AppKind
| 'Namespace'
| 'ResourceQuota'
| 'ConfigMap'
| 'Secret'
| 'PersistentVolumeClaim'
| 'Service'
| 'Ingress'
| 'HorizontalPodAutoscaler';
export type Summary = {
action: KubernetesResourceAction;
kind: KubernetesResourceType;
name: string;
type?: string;
};

View File

@ -0,0 +1,517 @@
import { ApplicationFormValues } from '../../types';
import { Summary } from './types';
import { getAppResourceSummaries } from './utils';
const complicatedStatefulSet: ApplicationFormValues = {
ApplicationType: 'StatefulSet',
ResourcePool: {
Namespace: {
Id: '9ef75267-3cf4-46f6-879a-5baeceb5c477',
Name: 'default',
CreationDate: '2023-08-30T18:55:34Z',
Status: 'Active',
Yaml: '',
IsSystem: false,
Annotations: [],
},
Ingresses: [],
Yaml: '',
$$hashKey: 'object:702',
},
Name: 'my-app',
StackName: '',
ApplicationOwner: '',
ImageModel: {
UseRegistry: true,
Registry: {
Id: 0,
Type: 0,
Name: 'Docker Hub (anonymous)',
URL: 'docker.io',
},
Image: 'caddy',
},
Note: '',
MemoryLimit: 512,
CpuLimit: 0.5,
DeploymentType: 'Replicated',
ReplicaCount: 1,
AutoScaler: {
isUsed: true,
minReplicas: 1,
maxReplicas: 3,
targetCpuUtilizationPercentage: 50,
},
Containers: [],
Services: [
{
Headless: false,
Namespace: '',
Name: 'my-app',
StackName: '',
Ports: [
{
port: 80,
targetPort: 80,
name: '',
protocol: 'TCP',
serviceName: 'my-app',
ingressPaths: [
{
Host: '127.0.0.1.nip.io',
IngressName: 'default-ingress-3',
Path: '/test',
},
],
},
],
Type: 'ClusterIP',
ClusterIP: '',
ApplicationName: '',
ApplicationOwner: '',
Note: '',
Ingress: false,
},
{
Headless: false,
Namespace: '',
Name: 'my-app-2',
StackName: '',
Ports: [
{
port: 80,
targetPort: 80,
name: '',
protocol: 'TCP',
nodePort: 30080,
serviceName: 'my-app-2',
},
],
Type: 'NodePort',
ClusterIP: '',
ApplicationName: '',
ApplicationOwner: '',
Note: '',
Ingress: false,
},
{
Headless: false,
Namespace: '',
Name: 'my-app-3',
StackName: '',
Ports: [
{
port: 80,
targetPort: 80,
name: '',
protocol: 'TCP',
serviceName: 'my-app-3',
},
],
Type: 'LoadBalancer',
ClusterIP: '',
ApplicationName: '',
ApplicationOwner: '',
Note: '',
Ingress: false,
},
],
EnvironmentVariables: [],
DataAccessPolicy: 'Isolated',
PersistedFolders: [
{
persistentVolumeClaimName: 'my-app-6be07c40-de3a-4775-a29b-19a60890052e',
containerPath: 'test',
size: '1',
sizeUnit: 'GB',
storageClass: {
Name: 'local-path',
AccessModes: ['RWO', 'RWX'],
Provisioner: 'rancher.io/local-path',
AllowVolumeExpansion: true,
},
useNewVolume: true,
needsDeletion: false,
},
],
ConfigMaps: [],
Secrets: [],
PlacementType: 'preferred',
Placements: [],
Annotations: [],
};
const complicatedStatefulSetNoServices: ApplicationFormValues = {
ApplicationType: 'StatefulSet',
ResourcePool: {
Namespace: {
Id: '9ef75267-3cf4-46f6-879a-5baeceb5c477',
Name: 'default',
CreationDate: '2023-08-30T18:55:34Z',
Status: 'Active',
Yaml: '',
IsSystem: false,
Annotations: [],
},
Ingresses: [],
Yaml: '',
$$hashKey: 'object:129',
},
Name: 'my-app',
StackName: 'my-app',
ApplicationOwner: 'admin',
ImageModel: {
UseRegistry: true,
Registry: {
Id: 0,
Type: 0,
Name: 'Docker Hub (anonymous)',
URL: 'docker.io',
},
Image: 'caddy:latest',
},
Note: '',
MemoryLimit: 512,
CpuLimit: 0.5,
DeploymentType: 'Replicated',
ReplicaCount: 1,
AutoScaler: {
minReplicas: 1,
maxReplicas: 3,
targetCpuUtilizationPercentage: 50,
isUsed: true,
},
Containers: [
{
Type: 2,
PodName: 'my-app-0',
Name: 'my-app',
Image: 'caddy:latest',
ImagePullPolicy: 'Always',
Status: 'Terminated',
Limits: {
cpu: '500m',
memory: '512M',
},
Requests: {
cpu: '500m',
memory: '512M',
},
VolumeMounts: [
{
name: 'test-6be07c40-de3a-4775-a29b-19a60890052e-test-0',
mountPath: '/test',
},
{
name: 'kube-api-access-n4vht',
readOnly: true,
mountPath: '/var/run/secrets/kubernetes.io/serviceaccount',
},
],
ConfigurationVolumes: [],
PersistedFolders: [
{
MountPath: '/test',
persistentVolumeClaimName:
'test-6be07c40-de3a-4775-a29b-19a60890052e-test-0',
HostPath: '',
},
],
},
],
Services: [],
EnvironmentVariables: [],
DataAccessPolicy: 'Isolated',
PersistedFolders: [
{
persistentVolumeClaimName:
'test-6be07c40-de3a-4775-a29b-19a60890052e-test-0',
needsDeletion: false,
containerPath: '/test',
size: '1',
sizeUnit: 'GB',
storageClass: {
Name: 'local-path',
AccessModes: ['RWO', 'RWX'],
Provisioner: 'rancher.io/local-path',
AllowVolumeExpansion: true,
},
useNewVolume: true,
},
],
ConfigMaps: [],
Secrets: [],
PlacementType: 'preferred',
Placements: [],
Annotations: [],
};
const createComplicatedStatefulSetSummaries: Array<Summary> = [
{
action: 'Create',
kind: 'StatefulSet',
name: 'my-app',
},
{
action: 'Create',
kind: 'Service',
name: 'my-app',
type: 'ClusterIP',
},
{
action: 'Create',
kind: 'Service',
name: 'my-app-2',
type: 'NodePort',
},
{
action: 'Create',
kind: 'Service',
name: 'my-app-3',
type: 'LoadBalancer',
},
{
action: 'Create',
kind: 'Service',
name: 'headless-my-app',
type: 'ClusterIP',
},
{
action: 'Update',
kind: 'Ingress',
name: 'default-ingress-3',
},
{
action: 'Create',
kind: 'HorizontalPodAutoscaler',
name: 'my-app',
},
];
const simpleDaemonset: ApplicationFormValues = {
ApplicationType: 'DaemonSet',
ResourcePool: {
Namespace: {
Id: '49acd824-0ee4-46d1-b1e2-3d36a64ce7e4',
Name: 'default',
CreationDate: '2023-12-19T06:40:12Z',
Status: 'Active',
Yaml: '',
IsSystem: false,
Annotations: [],
},
Ingresses: [],
Yaml: '',
$$hashKey: 'object:418',
},
Name: 'my-app',
StackName: '',
ApplicationOwner: '',
ImageModel: {
UseRegistry: true,
Registry: {
Id: 0,
Type: 0,
Name: 'Docker Hub (anonymous)',
URL: 'docker.io',
},
Image: 'caddy',
},
Note: '',
MemoryLimit: 0,
CpuLimit: 0,
DeploymentType: 'Global',
ReplicaCount: 1,
Containers: [],
DataAccessPolicy: 'Shared',
PersistedFolders: [
{
persistentVolumeClaimName: 'my-app-7c114420-a5d0-491c-8bd6-ec70c3d380be',
containerPath: '/test',
size: '1',
sizeUnit: 'GB',
storageClass: {
Name: 'oci',
AccessModes: ['RWO', 'RWX'],
Provisioner: 'oracle.com/oci',
AllowVolumeExpansion: true,
},
useNewVolume: true,
needsDeletion: false,
},
],
PlacementType: 'preferred',
};
const createSimpleDaemonsetSummaries: Array<Summary> = [
{
action: 'Create',
kind: 'DaemonSet',
name: 'my-app',
},
{
action: 'Create',
kind: 'PersistentVolumeClaim',
name: 'my-app-7c114420-a5d0-491c-8bd6-ec70c3d380be',
},
];
const simpleDeployment: ApplicationFormValues = {
ApplicationType: 'Deployment',
ResourcePool: {
Namespace: {
Id: '49acd824-0ee4-46d1-b1e2-3d36a64ce7e4',
Name: 'default',
CreationDate: '2023-12-19T06:40:12Z',
Status: 'Active',
Yaml: '',
IsSystem: false,
Annotations: [],
},
Ingresses: [],
Yaml: '',
$$hashKey: 'object:582',
},
Name: 'my-app',
StackName: '',
ApplicationOwner: '',
ImageModel: {
UseRegistry: true,
Registry: {
Id: 0,
Type: 0,
Name: 'Docker Hub (anonymous)',
URL: 'docker.io',
},
Image: 'caddy',
},
Note: '',
MemoryLimit: 512,
CpuLimit: 0.5,
DeploymentType: 'Replicated',
ReplicaCount: 1,
Containers: [],
DataAccessPolicy: 'Isolated',
PlacementType: 'preferred',
};
const createSimpleDeploymentSummaries: Array<Summary> = [
{
action: 'Create',
kind: 'Deployment',
name: 'my-app',
},
];
describe('getCreateAppSummaries', () => {
const tests: {
oldFormValues?: ApplicationFormValues;
newFormValues: ApplicationFormValues;
expected: Array<Summary>;
title: string;
}[] = [
{
oldFormValues: undefined,
newFormValues: complicatedStatefulSet,
expected: createComplicatedStatefulSetSummaries,
title: 'should return create summaries for a complicated statefulset',
},
{
oldFormValues: undefined,
newFormValues: simpleDaemonset,
expected: createSimpleDaemonsetSummaries,
title: 'should return create summaries for a simple daemonset',
},
{
oldFormValues: undefined,
newFormValues: simpleDeployment,
expected: createSimpleDeploymentSummaries,
title: 'should return create summaries for a simple deployment',
},
];
tests.forEach((test) => {
// eslint-disable-next-line jest/valid-title
it(test.title, () => {
expect(
getAppResourceSummaries(test.newFormValues, test.oldFormValues)
).toEqual(test.expected);
});
});
});
const updateComplicatedStatefulSetSummaries: Array<Summary> = [
{
action: 'Update',
kind: 'StatefulSet',
name: 'my-app',
},
{
action: 'Delete',
kind: 'Service',
name: 'my-app',
type: 'ClusterIP',
},
{
action: 'Delete',
kind: 'Service',
name: 'my-app-2',
type: 'NodePort',
},
{
action: 'Delete',
kind: 'Service',
name: 'my-app-3',
type: 'LoadBalancer',
},
];
const updateDeploymentToStatefulSetSummaries: Array<Summary> = [
{
action: 'Delete',
kind: 'Deployment',
name: 'my-app',
},
{
action: 'Create',
kind: 'StatefulSet',
name: 'my-app',
},
{
action: 'Create',
kind: 'HorizontalPodAutoscaler',
name: 'my-app',
},
];
describe('getUpdateAppSummaries', () => {
const tests: {
oldFormValues: ApplicationFormValues;
newFormValues: ApplicationFormValues;
expected: Array<Summary>;
title: string;
}[] = [
{
oldFormValues: complicatedStatefulSet,
newFormValues: complicatedStatefulSetNoServices,
expected: updateComplicatedStatefulSetSummaries,
title:
'should return update summaries for removing services from statefulset',
},
{
oldFormValues: simpleDeployment,
newFormValues: complicatedStatefulSetNoServices,
expected: updateDeploymentToStatefulSetSummaries,
title:
'should return update summaries for changing deployment to statefulset',
},
];
tests.forEach((test) => {
// eslint-disable-next-line jest/valid-title
it(test.title, () => {
expect(
getAppResourceSummaries(test.newFormValues, test.oldFormValues)
).toEqual(test.expected);
});
});
});

View File

@ -0,0 +1,362 @@
import { Ingress } from '@/react/kubernetes/ingresses/types';
import { ServiceFormValues } from '../../CreateView/application-services/types';
import { ApplicationFormValues } from '../../types';
import {
generateNewIngressesFromFormPaths,
getServicePatchPayload,
} from '../../CreateView/application-services/utils';
import {
KubernetesResourceType,
KubernetesResourceAction,
Summary,
} from './types';
export function getArticle(
resourceType: KubernetesResourceType,
resourceAction: KubernetesResourceAction
) {
if (resourceAction === 'Delete' || resourceAction === 'Update') {
return 'the';
}
if (resourceAction === 'Create' && resourceType === 'Ingress') {
return 'an';
}
return 'a';
}
/**
* generateResourceSummaryList maps formValues to create and update summaries
*/
export function getAppResourceSummaries(
newFormValues: ApplicationFormValues,
oldFormValues?: ApplicationFormValues
): Array<Summary> {
if (!oldFormValues) {
return getCreatedApplicationResourcesNew(newFormValues);
}
return getUpdatedApplicationResources(newFormValues, oldFormValues);
}
function getCreatedApplicationResourcesNew(
formValues: ApplicationFormValues
): Array<Summary> {
// app summary
const appSummary: Summary = {
action: 'Create',
kind: formValues.ApplicationType,
name: formValues.Name,
};
// service summaries
const serviceFormSummaries: Array<Summary> =
formValues.Services?.map((service) => ({
action: 'Create',
kind: 'Service',
name: service.Name || '',
type: service.Type,
})) || [];
// statefulsets require a headless service (https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#limitations)
// create a headless service summary if the application is a statefulset
const headlessSummary: Array<Summary> =
formValues.ApplicationType === 'StatefulSet'
? [
{
action: 'Create',
kind: 'Service',
name: `headless-${formValues.Name}`,
type: 'ClusterIP',
},
]
: [];
const serviceSummaries = [...serviceFormSummaries, ...headlessSummary];
// ingress summaries
const ingressesSummaries: Array<Summary> =
formValues.Services?.flatMap((service) => {
// a single service port can have multiple ingress paths (and even use different ingresses)
const servicePathsIngressNames = service.Ports.flatMap(
(port) => port.ingressPaths?.map((path) => path.IngressName) || []
);
const uniqueIngressNames = [...new Set(servicePathsIngressNames)];
return uniqueIngressNames.map((ingressName) => ({
action: 'Update',
kind: 'Ingress',
name: ingressName || '',
}));
}) || [];
// persistent volume claim (pvc) summaries
const pvcSummaries: Array<Summary> =
// apps with a isolated data access policy are statefulsets.
// statefulset pvcs are defined in spec.volumeClaimTemplates.
// https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#stable-storage
formValues.DataAccessPolicy === 'Shared'
? formValues.PersistedFolders?.map((volume) => ({
action: 'Create',
kind: 'PersistentVolumeClaim',
name:
volume.existingVolume?.PersistentVolumeClaim.Name ||
volume.persistentVolumeClaimName ||
'',
})) || []
: [];
// horizontal pod autoscaler summaries
const hpaSummary: Array<Summary> =
formValues.AutoScaler?.isUsed === true &&
formValues.DeploymentType !== 'Global'
? [
{
action: 'Create',
kind: 'HorizontalPodAutoscaler',
name: formValues.Name,
},
]
: [];
return [
appSummary,
...serviceSummaries,
...ingressesSummaries,
...pvcSummaries,
...hpaSummary,
];
}
function getUpdatedApplicationResources(
newFormValues: ApplicationFormValues,
oldFormValues: ApplicationFormValues
) {
// app summaries
const updateAppSummaries: Array<Summary> =
oldFormValues.ApplicationType !== newFormValues.ApplicationType
? [
{
action: 'Delete',
kind: oldFormValues.ApplicationType,
name: oldFormValues.Name,
},
{
action: 'Create',
kind: newFormValues.ApplicationType,
name: newFormValues.Name,
},
]
: [
{
action: 'Update',
kind: newFormValues.ApplicationType,
name: newFormValues.Name,
},
];
// service summaries
const serviceSummaries: Array<Summary> = getServiceUpdateResourceSummary(
oldFormValues.Services,
newFormValues.Services
);
// ingress summaries
const oldServicePorts = oldFormValues.Services?.flatMap(
(service) => service.Ports
);
const oldIngresses = generateNewIngressesFromFormPaths(
oldFormValues.OriginalIngresses,
oldServicePorts,
oldServicePorts
);
const newServicePorts = newFormValues.Services?.flatMap(
(service) => service.Ports
);
const newIngresses = generateNewIngressesFromFormPaths(
newFormValues.OriginalIngresses,
newServicePorts,
oldServicePorts
);
const ingressSummaries = getIngressUpdateSummary(oldIngresses, newIngresses);
// persistent volume claim (pvc) summaries
const pvcSummaries: Array<Summary> =
// apps with a isolated data access policy are statefulsets.
// statefulset pvcs are defined in spec.volumeClaimTemplates.
// https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#stable-storage
newFormValues.DataAccessPolicy === 'Shared'
? newFormValues.PersistedFolders?.flatMap((newVolume) => {
const oldVolume = oldFormValues.PersistedFolders?.find(
(oldVolume) =>
oldVolume.persistentVolumeClaimName ===
newVolume.persistentVolumeClaimName
);
if (!oldVolume) {
return [
{
action: 'Create',
kind: 'PersistentVolumeClaim',
name:
newVolume.existingVolume?.PersistentVolumeClaim.Name ||
newVolume.persistentVolumeClaimName ||
'',
},
];
}
// updating a pvc is not supported
return [];
}) || []
: [];
// TODO: horizontal pod autoscaler summaries
const createHPASummary: Array<Summary> =
newFormValues.AutoScaler?.isUsed && !oldFormValues.AutoScaler?.isUsed
? [
{
action: 'Create',
kind: 'HorizontalPodAutoscaler',
name: newFormValues.Name,
},
]
: [];
const deleteHPASummary: Array<Summary> =
!newFormValues.AutoScaler?.isUsed && oldFormValues.AutoScaler?.isUsed
? [
{
action: 'Delete',
kind: 'HorizontalPodAutoscaler',
name: oldFormValues.Name,
},
]
: [];
const isHPAUpdated =
newFormValues.AutoScaler?.isUsed &&
oldFormValues.AutoScaler?.isUsed &&
(newFormValues.AutoScaler?.minReplicas !==
oldFormValues.AutoScaler?.minReplicas ||
newFormValues.AutoScaler?.maxReplicas !==
oldFormValues.AutoScaler?.maxReplicas ||
newFormValues.AutoScaler?.targetCpuUtilizationPercentage !==
oldFormValues.AutoScaler?.targetCpuUtilizationPercentage);
const updateHPASummary: Array<Summary> = isHPAUpdated
? [
{
action: 'Update',
kind: 'HorizontalPodAutoscaler',
name: newFormValues.Name,
},
]
: [];
const hpaSummaries = [
...createHPASummary,
...deleteHPASummary,
...updateHPASummary,
];
return [
...updateAppSummaries,
...serviceSummaries,
...ingressSummaries,
...pvcSummaries,
...hpaSummaries,
];
}
// getServiceUpdateResourceSummary replicates KubernetesServiceService.patch
function getServiceUpdateResourceSummary(
oldServices?: Array<ServiceFormValues>,
newServices?: Array<ServiceFormValues>
): Array<Summary> {
const updateAndCreateSummaries =
newServices?.flatMap<Summary>((newService) => {
const oldServiceMatched = oldServices?.find(
(oldService) => oldService.Name === newService.Name
);
if (oldServiceMatched) {
return getServiceUpdateSummary(oldServiceMatched, newService);
}
return [
{
action: 'Create',
kind: 'Service',
name: newService.Name || '',
type: newService.Type || 'ClusterIP',
},
];
}) || [];
const deleteSummaries =
oldServices?.flatMap<Summary>((oldService) => {
const newServiceMatched = newServices?.find(
(newService) => newService.Name === oldService.Name
);
if (newServiceMatched) {
return [];
}
return [
{
action: 'Delete',
kind: 'Service',
name: oldService.Name || '',
type: oldService.Type || 'ClusterIP',
},
];
}) || [];
return [...updateAndCreateSummaries, ...deleteSummaries];
}
function getServiceUpdateSummary(
oldService: ServiceFormValues,
newService: ServiceFormValues
): Array<Summary> {
const payload = getServicePatchPayload(oldService, newService);
if (payload.length) {
return [
{
action: 'Update',
kind: 'Service',
name: oldService.Name || '',
type: oldService.Type || 'ClusterIP',
},
];
}
return [];
}
export function getIngressUpdateSummary(
oldIngresses: Array<Ingress>,
newIngresses: Array<Ingress>
): Array<Summary> {
const ingressesSummaries = newIngresses.flatMap((newIng) => {
const oldIng = oldIngresses.find((oldIng) => oldIng.Name === newIng.Name);
if (oldIng) {
return getIngressUpdateResourceSummary(oldIng, newIng);
}
return [];
});
return ingressesSummaries;
}
// getIngressUpdateResourceSummary checks if any ingress paths have been changed
function getIngressUpdateResourceSummary(
oldIngress: Ingress,
newIngress: Ingress
): Array<Summary> {
const newIngressPaths = newIngress.Paths?.flatMap((path) => path.Path) || [];
const oldIngressPaths = oldIngress.Paths?.flatMap((path) => path.Path) || [];
const isAnyNewPathMissingOldPath = newIngressPaths.some(
(path) => !oldIngressPaths.includes(path)
);
const isAnyOldPathMissingNewPath = oldIngressPaths.some(
(path) => !newIngressPaths.includes(path)
);
if (isAnyNewPathMissingOldPath || isAnyOldPathMissingNewPath) {
return [
{
action: 'Update',
kind: 'Ingress',
name: oldIngress.Name,
},
];
}
return [];
}

View File

@ -1,4 +1,3 @@
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
import clsx from 'clsx'; import clsx from 'clsx';
import { StorageClass } from '@/react/portainer/environments/types'; import { StorageClass } from '@/react/portainer/environments/types';
@ -221,8 +220,7 @@ export function PersistedFolderItem({
function isToggleVolumeTypeVisible() { function isToggleVolumeTypeVisible() {
return ( return (
!(isEdit && isExistingPersistedFolder()) && // if it's not an edit of an existing persisted folder !(isEdit && isExistingPersistedFolder()) && // if it's not an edit of an existing persisted folder
applicationValues.ApplicationType !== applicationValues.ApplicationType !== 'StatefulSet' && // and if it's not a statefulset
KubernetesApplicationTypes.STATEFULSET && // and if it's not a statefulset
applicationValues.Containers.length <= 1 // and if there is only one container); applicationValues.Containers.length <= 1 // and if there is only one container);
); );
} }

View File

@ -1,9 +1,9 @@
import { FormikErrors } from 'formik'; import { FormikErrors } from 'formik';
import { useMemo } from 'react'; import { useMemo } from 'react';
import uuidv4 from 'uuid/v4';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { StorageClass } from '@/react/portainer/environments/types'; import { StorageClass } from '@/react/portainer/environments/types';
import { KubernetesApplicationTypes } from '@/kubernetes/models/application/models';
import { Option } from '@@/form-components/PortainerSelect'; import { Option } from '@@/form-components/PortainerSelect';
import { InlineLoader } from '@@/InlineLoader'; import { InlineLoader } from '@@/InlineLoader';
@ -43,11 +43,7 @@ export function PersistedFoldersFormSection({
const PVCOptions = usePVCOptions(availableVolumes); const PVCOptions = usePVCOptions(availableVolumes);
return ( return (
<FormSection <FormSection title="Persisted folders" titleSize="sm">
title="Persisted folders"
titleSize="sm"
titleClassName="control-label !text-[0.9em]"
>
{storageClasses.length === 0 && ( {storageClasses.length === 0 && (
<TextTip color="blue"> <TextTip color="blue">
No storage option is available to persist data, contact your No storage option is available to persist data, contact your
@ -81,17 +77,21 @@ export function PersistedFoldersFormSection({
initialValues={initialValues} initialValues={initialValues}
/> />
)} )}
itemBuilder={() => ({ itemBuilder={() => {
persistentVolumeClaimName: const newVolumeClaimName = `${applicationValues.Name}-${uuidv4()}`;
availableVolumes[0]?.PersistentVolumeClaim.Name || '', return {
containerPath: '', persistentVolumeClaimName:
size: '', availableVolumes[0]?.PersistentVolumeClaim.Name ||
sizeUnit: 'GB', newVolumeClaimName,
storageClass: storageClasses[0], containerPath: '',
useNewVolume: true, size: '',
existingVolume: undefined, sizeUnit: 'GB',
needsDeletion: false, storageClass: storageClasses[0],
})} useNewVolume: true,
existingVolume: undefined,
needsDeletion: false,
};
}}
addLabel="Add persisted folder" addLabel="Add persisted folder"
canUndoDelete={isEdit} canUndoDelete={isEdit}
/> />
@ -100,9 +100,7 @@ export function PersistedFoldersFormSection({
function isDeleteButtonHidden() { function isDeleteButtonHidden() {
return ( return (
(isEdit && (isEdit && applicationValues.ApplicationType === 'StatefulSet') ||
applicationValues.ApplicationType ===
KubernetesApplicationTypes.STATEFULSET) ||
applicationValues.Containers.length >= 1 applicationValues.Containers.length >= 1
); );
} }

View File

@ -35,11 +35,7 @@ export function PlacementFormSection({ values, onChange, errors }: Props) {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<FormSection <FormSection title="Placement preferences and constraints" titleSize="sm">
title="Placement preferences and constraints"
titleSize="sm"
titleClassName="control-label !text-[0.9em]"
>
{values.placements?.length > 0 && ( {values.placements?.length > 0 && (
<TextTip color="blue"> <TextTip color="blue">
Deploy this application on nodes that respect <b>ALL</b> of the Deploy this application on nodes that respect <b>ALL</b> of the

View File

@ -7,6 +7,7 @@ export const appOwnerLabel = 'io.portainer.kubernetes.application.owner';
export const appNoteAnnotation = 'io.portainer.kubernetes.application.note'; export const appNoteAnnotation = 'io.portainer.kubernetes.application.note';
export const appDeployMethodLabel = 'io.portainer.kubernetes.application.kind'; export const appDeployMethodLabel = 'io.portainer.kubernetes.application.kind';
export const defaultDeploymentUniqueLabel = 'pod-template-hash'; export const defaultDeploymentUniqueLabel = 'pod-template-hash';
export const appNameLabel = 'io.portainer.kubernetes.application.name';
export const appRevisionAnnotation = 'deployment.kubernetes.io/revision'; export const appRevisionAnnotation = 'deployment.kubernetes.io/revision';
@ -30,4 +31,4 @@ export const appKindToDeploymentTypeMap: Record<
StatefulSet: 'Replicated', StatefulSet: 'Replicated',
DaemonSet: 'Global', DaemonSet: 'Global',
Pod: null, Pod: null,
}; } as const;

View File

@ -11,9 +11,44 @@ import {
import { Pod, PodList } from 'kubernetes-types/core/v1'; import { Pod, PodList } from 'kubernetes-types/core/v1';
import { RawExtension } from 'kubernetes-types/runtime'; import { RawExtension } from 'kubernetes-types/runtime';
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
import { Annotation } from '../annotations/types';
import { Ingress } from '../ingresses/types';
import { AutoScalingFormValues } from './components/AutoScalingFormSection/types';
import { ServiceFormValues } from './CreateView/application-services/types';
import { PersistedFolderFormValue } from './components/PersistedFoldersFormSection/types';
import { ConfigurationFormValues } from './components/ConfigurationsFormSection/types';
import {
Placement,
PlacementType,
} from './components/PlacementFormSection/types';
export type ApplicationFormValues = { export type ApplicationFormValues = {
Containers: Array<unknown>; Containers: Array<unknown>;
ApplicationType: number; // KubernetesApplicationTypes ApplicationType: AppKind;
ResourcePool: unknown;
Name: string;
StackName?: string;
ApplicationOwner?: string;
ImageModel: unknown;
Note?: string;
MemoryLimit?: number;
CpuLimit?: number;
DeploymentType?: DeploymentType;
ReplicaCount?: number;
AutoScaler?: AutoScalingFormValues;
Services?: Array<ServiceFormValues>;
OriginalIngresses?: Array<Ingress>;
EnvironmentVariables?: EnvVarValues;
DataAccessPolicy?: AppDataAccessPolicy;
PersistedFolders?: Array<PersistedFolderFormValue>;
ConfigMaps?: Array<ConfigurationFormValues>;
Secrets?: Array<ConfigurationFormValues>;
PlacementType?: PlacementType;
Placements?: Array<Placement>;
Annotations?: Array<Annotation>;
}; };
export type Application = Deployment | DaemonSet | StatefulSet | Pod; export type Application = Deployment | DaemonSet | StatefulSet | Pod;
@ -30,8 +65,12 @@ export type ApplicationList =
export type AppKind = 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod'; export type AppKind = 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod';
export type AppType = AppKind | 'Helm';
export type DeploymentType = 'Replicated' | 'Global'; export type DeploymentType = 'Replicated' | 'Global';
export type AppDataAccessPolicy = 'Isolated' | 'Shared';
type Patch = { type Patch = {
op: 'replace' | 'add' | 'remove'; op: 'replace' | 'add' | 'remove';
path: string; path: string;

View File

@ -8,6 +8,8 @@ import { useSecrets } from '@/react/kubernetes/configs/secret.service';
import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries'; import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries';
import { notifyError, notifySuccess } from '@/portainer/services/notifications'; import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { useAuthorizations } from '@/react/hooks/useUser'; import { useAuthorizations } from '@/react/hooks/useUser';
import { Annotation } from '@/react/kubernetes/annotations/types';
import { prepareAnnotations } from '@/react/kubernetes/utils';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
@ -22,7 +24,6 @@ import {
useUpdateIngress, useUpdateIngress,
useIngressControllers, useIngressControllers,
} from '../queries'; } from '../queries';
import { Annotation } from '../../annotations/types';
import { import {
Rule, Rule,
@ -35,7 +36,6 @@ import { IngressForm } from './IngressForm';
import { import {
prepareTLS, prepareTLS,
preparePaths, preparePaths,
prepareAnnotations,
prepareRuleFromIngress, prepareRuleFromIngress,
checkIfPathExistsWithHost, checkIfPathExistsWithHost,
} from './utils'; } from './utils';

View File

@ -37,14 +37,6 @@ export function preparePaths(ingressName: string, hosts: Host[]) {
); );
} }
export function prepareAnnotations(annotations: Annotation[]) {
const result: Record<string, string> = {};
annotations.forEach((a) => {
result[a.Key] = a.Value;
});
return result;
}
function getSecretByHost(host: string, tls?: TLS[]) { function getSecretByHost(host: string, tls?: TLS[]) {
let secret = ''; let secret = '';
if (tls) { if (tls) {

View File

@ -1,3 +1,5 @@
import { Annotation } from './annotations/types';
export function parseCpu(cpu: string) { export function parseCpu(cpu: string) {
let res = parseInt(cpu, 10); let res = parseInt(cpu, 10);
if (cpu.endsWith('m')) { if (cpu.endsWith('m')) {
@ -7,3 +9,14 @@ export function parseCpu(cpu: string) {
} }
return res; return res;
} }
export function prepareAnnotations(annotations: Annotation[]) {
const result = annotations.reduce(
(acc, a) => {
acc[a.Key] = a.Value;
return acc;
},
{} as Record<string, string>
);
return result;
}