diff --git a/app/assets/css/app.css b/app/assets/css/app.css index b521dd890..cdb30d4ac 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -599,10 +599,6 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { padding-left: 0; } -.switch input { - display: none; -} - .small-select { display: inline-block; padding: 0px 6px; @@ -618,17 +614,26 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { margin-left: 21px; } +/* switch box */ +:root { + --switch-size: 24px; +} + +.switch input { + display: none; +} + .switch i, .bootbox-form .checkbox i { display: inline-block; vertical-align: middle; cursor: pointer; - padding-right: 24px; + padding-right: var(--switch-size); transition: all ease 0.2s; -webkit-transition: all ease 0.2s; -moz-transition: all ease 0.2s; -o-transition: all ease 0.2s; - border-radius: 24px; + border-radius: var(--switch-size); box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, 0.5); } @@ -636,9 +641,9 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { .bootbox-form .checkbox i:before { display: block; content: ''; - width: 24px; - height: 24px; - border-radius: 24px; + width: var(--switch-size); + height: var(--switch-size); + border-radius: var(--switch-size); background: white; box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.5); } @@ -646,11 +651,19 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { .switch :checked + i, .bootbox-form .checkbox :checked ~ i { padding-right: 0; - padding-left: 24px; + padding-left: var(--switch-size); -webkit-box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.5), inset 0 0 40px #337ab7; -moz-box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.5), inset 0 0 40px #337ab7; box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.5), inset 0 0 40px #337ab7; } +/* !switch box */ + +/* small switch box */ +.switch.small { + --switch-size: 12px; +} + +/* !small switch box */ .boxselector_wrapper { display: flex; diff --git a/app/kubernetes/components/datatables/applications-stacks-datatable/applicationsStacksDatatableController.js b/app/kubernetes/components/datatables/applications-stacks-datatable/applicationsStacksDatatableController.js index cc77ba205..faeef91c3 100644 --- a/app/kubernetes/components/datatables/applications-stacks-datatable/applicationsStacksDatatableController.js +++ b/app/kubernetes/components/datatables/applications-stacks-datatable/applicationsStacksDatatableController.js @@ -21,7 +21,7 @@ angular.module('portainer.docker').controller('KubernetesApplicationsStacksDatat showSystem: false, }); - this.onSettingsRepeaterChange = function () { + this.onSettingsShowSystemChange = function () { DatatableService.setDataTableSettings(this.tableKey, this.settings); }; diff --git a/app/kubernetes/converters/pod.js b/app/kubernetes/converters/pod.js deleted file mode 100644 index af3d7713b..000000000 --- a/app/kubernetes/converters/pod.js +++ /dev/null @@ -1,32 +0,0 @@ -import _ from 'lodash-es'; -import { KubernetesPod } from 'Kubernetes/models/pod/models'; -class KubernetesPodConverter { - static computeStatus(statuses) { - const containerStatuses = _.map(statuses, 'state'); - const running = _.filter(containerStatuses, (s) => s.running).length; - const waiting = _.filter(containerStatuses, (s) => s.waiting).length; - if (waiting) { - return 'Waiting'; - } else if (!running) { - return 'Terminated'; - } - return 'Running'; - } - - static apiToPod(data) { - const res = new KubernetesPod(); - res.Id = data.metadata.uid; - res.Name = data.metadata.name; - res.Namespace = data.metadata.namespace; - res.Images = _.map(data.spec.containers, 'image'); - res.Status = KubernetesPodConverter.computeStatus(data.status.containerStatuses); - res.Restarts = _.sumBy(data.status.containerStatuses, 'restartCount'); - res.Node = data.spec.nodeName; - res.CreationDate = data.status.startTime; - res.Containers = data.spec.containers; - res.Labels = data.metadata.labels; - return res; - } -} - -export default KubernetesPodConverter; diff --git a/app/kubernetes/filters/applicationFilters.js b/app/kubernetes/filters/applicationFilters.js index d0f37f78c..b58e783cb 100644 --- a/app/kubernetes/filters/applicationFilters.js +++ b/app/kubernetes/filters/applicationFilters.js @@ -2,6 +2,7 @@ import _ from 'lodash-es'; import { KubernetesApplicationDataAccessPolicies } from 'Kubernetes/models/application/models'; import { KubernetesServiceTypes } from 'Kubernetes/models/service/models'; import { KubernetesApplicationTypes, KubernetesApplicationTypeStrings } from 'Kubernetes/models/application/models'; +import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models'; angular .module('portainer.kubernetes') @@ -99,4 +100,23 @@ angular return 'All the instances of this application are sharing the same data.'; } }; + }) + .filter('kubernetesApplicationConstraintNodeAffinityValue', function () { + 'use strict'; + return function (values, operator) { + if (operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN || operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.NOT_IN) { + return values; + } else if ( + operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS || + operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.DOES_NOT_EXIST + ) { + return ''; + } else if ( + operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.GREATER_THAN || + operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.LOWER_THAN + ) { + return values[0]; + } + return ''; + }; }); diff --git a/app/kubernetes/models/application/models/constants.js b/app/kubernetes/models/application/models/constants.js new file mode 100644 index 000000000..f8cfa0ff1 --- /dev/null +++ b/app/kubernetes/models/application/models/constants.js @@ -0,0 +1,40 @@ +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, +}); + +export const KubernetesApplicationTypeStrings = Object.freeze({ + DEPLOYMENT: 'Deployment', + DAEMONSET: 'DaemonSet', + STATEFULSET: 'StatefulSet', +}); + +export const KubernetesApplicationPublishingTypes = Object.freeze({ + INTERNAL: 1, + CLUSTER: 2, + LOAD_BALANCER: 3, +}); + +export const KubernetesApplicationQuotaDefaults = { + CpuLimit: 0.1, + MemoryLimit: 64, // MB +}; + +export const KubernetesPortainerApplicationStackNameLabel = 'io.portainer.kubernetes.application.stack'; + +export const KubernetesPortainerApplicationNameLabel = 'io.portainer.kubernetes.application.name'; + +export const KubernetesPortainerApplicationOwnerLabel = 'io.portainer.kubernetes.application.owner'; + +export const KubernetesPortainerApplicationNote = 'io.portainer.kubernetes.application.note'; diff --git a/app/kubernetes/models/application/models.js b/app/kubernetes/models/application/models/index.js similarity index 68% rename from app/kubernetes/models/application/models.js rename to app/kubernetes/models/application/models/index.js index 32a953091..dbd7b8fbb 100644 --- a/app/kubernetes/models/application/models.js +++ b/app/kubernetes/models/application/models/index.js @@ -1,43 +1,4 @@ -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, -}); - -export const KubernetesApplicationTypeStrings = Object.freeze({ - DEPLOYMENT: 'Deployment', - DAEMONSET: 'DaemonSet', - STATEFULSET: 'StatefulSet', -}); - -export const KubernetesApplicationPublishingTypes = Object.freeze({ - INTERNAL: 1, - CLUSTER: 2, - LOAD_BALANCER: 3, -}); - -export const KubernetesApplicationQuotaDefaults = { - CpuLimit: 0.1, - MemoryLimit: 64, // MB -}; - -export const KubernetesPortainerApplicationStackNameLabel = 'io.portainer.kubernetes.application.stack'; - -export const KubernetesPortainerApplicationNameLabel = 'io.portainer.kubernetes.application.name'; - -export const KubernetesPortainerApplicationOwnerLabel = 'io.portainer.kubernetes.application.owner'; - -export const KubernetesPortainerApplicationNote = 'io.portainer.kubernetes.application.note'; +export * from './constants'; /** * KubernetesApplication Model (Composite) diff --git a/app/kubernetes/models/pod/models.js b/app/kubernetes/models/pod/models.js deleted file mode 100644 index 3989913c6..000000000 --- a/app/kubernetes/models/pod/models.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * KubernetesPod Model - */ -const _KubernetesPod = Object.freeze({ - Id: '', - Name: '', - Namespace: '', - Images: [], - Status: '', - Restarts: 0, - Node: '', - CreationDate: '', - Containers: [], - Labels: [], -}); - -export class KubernetesPod { - constructor() { - Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPod))); - } -} diff --git a/app/kubernetes/converters/node.js b/app/kubernetes/node/converter.js similarity index 96% rename from app/kubernetes/converters/node.js rename to app/kubernetes/node/converter.js index 7548a1678..ecc8a70a8 100644 --- a/app/kubernetes/converters/node.js +++ b/app/kubernetes/node/converter.js @@ -1,6 +1,6 @@ import _ from 'lodash-es'; -import { KubernetesNode, KubernetesNodeDetails } from 'Kubernetes/models/node/models'; +import { KubernetesNode, KubernetesNodeDetails } from 'Kubernetes/node/models'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; class KubernetesNodeConverter { @@ -11,6 +11,7 @@ class KubernetesNodeConverter { res.Id = data.metadata.uid; const hostName = _.find(data.status.addresses, { type: 'Hostname' }); res.Name = hostName ? hostName.address : data.metadata.Name; + res.Labels = data.metadata.labels; res.Role = _.has(data.metadata.labels, 'node-role.kubernetes.io/master') ? 'Master' : 'Worker'; const ready = _.find(data.status.conditions, { type: KubernetesNodeConditionTypes.READY }); @@ -39,6 +40,7 @@ class KubernetesNodeConverter { res.Version = data.status.nodeInfo.kubeletVersion; const internalIP = _.find(data.status.addresses, { type: 'InternalIP' }); res.IPAddress = internalIP ? internalIP.address : '-'; + res.Taints = data.spec.taints ? data.spec.taints : []; return res; } diff --git a/app/kubernetes/filters/nodeFilters.js b/app/kubernetes/node/filters.js similarity index 100% rename from app/kubernetes/filters/nodeFilters.js rename to app/kubernetes/node/filters.js diff --git a/app/kubernetes/models/node/models.js b/app/kubernetes/node/models.js similarity index 96% rename from app/kubernetes/models/node/models.js rename to app/kubernetes/node/models.js index aeee2789b..8b77bfece 100644 --- a/app/kubernetes/models/node/models.js +++ b/app/kubernetes/node/models.js @@ -4,12 +4,14 @@ const _KubernetesNode = Object.freeze({ Id: '', Name: '', + Labels: {}, Role: '', Status: '', CPU: 0, Memory: '', Version: '', IPAddress: '', + Taints: [], }); export class KubernetesNode { diff --git a/app/kubernetes/rest/node.js b/app/kubernetes/node/rest.js similarity index 100% rename from app/kubernetes/rest/node.js rename to app/kubernetes/node/rest.js diff --git a/app/kubernetes/services/nodeService.js b/app/kubernetes/node/service.js similarity index 95% rename from app/kubernetes/services/nodeService.js rename to app/kubernetes/node/service.js index 58b2d787e..30aa9157c 100644 --- a/app/kubernetes/services/nodeService.js +++ b/app/kubernetes/node/service.js @@ -2,7 +2,7 @@ import angular from 'angular'; import _ from 'lodash-es'; import PortainerError from 'Portainer/error'; -import KubernetesNodeConverter from 'Kubernetes/converters/node'; +import KubernetesNodeConverter from 'Kubernetes/node/converter'; import { KubernetesCommonParams } from 'Kubernetes/models/common/params'; class KubernetesNodeService { diff --git a/app/kubernetes/pod/converter.js b/app/kubernetes/pod/converter.js new file mode 100644 index 000000000..121f312bc --- /dev/null +++ b/app/kubernetes/pod/converter.js @@ -0,0 +1,54 @@ +import _ from 'lodash-es'; +import { KubernetesPod, KubernetesPodToleration, KubernetesPodAffinity } from 'Kubernetes/pod/models'; + +function computeStatus(statuses) { + const containerStatuses = _.map(statuses, 'state'); + const running = _.filter(containerStatuses, (s) => s.running).length; + const waiting = _.filter(containerStatuses, (s) => s.waiting).length; + if (waiting) { + return 'Waiting'; + } else if (!running) { + return 'Terminated'; + } + return 'Running'; +} + +function computeAffinity(affinity) { + const res = new KubernetesPodAffinity(); + if (affinity) { + res.NodeAffinity = affinity.nodeAffinity || {}; + } + return res; +} + +function computeTolerations(tolerations) { + return _.map(tolerations, (item) => { + const res = new KubernetesPodToleration(); + res.Key = item.key; + res.Operator = item.operator; + res.Value = item.value; + res.TolerationSeconds = item.tolerationSeconds; + res.Effect = item.effect; + return res; + }); +} + +export default class KubernetesPodConverter { + static apiToModel(data) { + const res = new KubernetesPod(); + res.Id = data.metadata.uid; + res.Name = data.metadata.name; + res.Namespace = data.metadata.namespace; + res.Images = _.map(data.spec.containers, 'image'); + res.Status = computeStatus(data.status.containerStatuses); + res.Restarts = _.sumBy(data.status.containerStatuses, 'restartCount'); + res.Node = data.spec.nodeName; + res.CreationDate = data.status.startTime; + res.Containers = data.spec.containers; + res.Labels = data.metadata.labels; + res.Affinity = computeAffinity(data.spec.affinity); + res.NodeSelector = data.spec.nodeSelector; + res.Tolerations = computeTolerations(data.spec.tolerations); + return res; + } +} diff --git a/app/kubernetes/filters/podFilters.js b/app/kubernetes/pod/filters.js similarity index 100% rename from app/kubernetes/filters/podFilters.js rename to app/kubernetes/pod/filters.js diff --git a/app/kubernetes/pod/models/affinities.js b/app/kubernetes/pod/models/affinities.js new file mode 100644 index 000000000..e0bafb202 --- /dev/null +++ b/app/kubernetes/pod/models/affinities.js @@ -0,0 +1,65 @@ +export const KubernetesPodNodeAffinityNodeSelectorRequirementOperators = Object.freeze({ + IN: 'In', + NOT_IN: 'NotIn', + EXISTS: 'Exists', + DOES_NOT_EXIST: 'DoesNotExist', + GREATER_THAN: 'Gt', + LOWER_THAN: 'Lt', +}); + +/** + * KubernetesPodAffinity Model + */ +const _KubernetesPodAffinity = Object.freeze({ + NodeAffinity: {}, + // PodAffinity: {}, + // PodAntiAffinity: {}, +}); + +export class KubernetesPodAffinity { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPodAffinity))); + } +} + +/** + * KubernetesPodNodeAffinity Model + */ +const _KubernetesPodNodeAffinity = Object.freeze({ + PreferredDuringSchedulingIgnoredDuringExecution: [], + RequiredDuringSchedulingIgnoredDuringExecution: {}, +}); + +export class KubernetesPodNodeAffinity { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPodNodeAffinity))); + } +} + +/** + * KubernetesPodPodAffinity Model + */ +const _KubernetesPodPodAffinity = Object.freeze({ + PreferredDuringSchedulingIgnoredDuringExecution: [], + equiredDuringSchedulingIgnoredDuringExecution: [], +}); + +export class KubernetesPodPodAffinity { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPodPodAffinity))); + } +} + +/** + * KubernetesPodPodAntiAffinity Model + */ +const _KubernetesPodPodAntiAffinity = Object.freeze({ + preferredDuringSchedulingIgnoredDuringExecution: [], + requiredDuringSchedulingIgnoredDuringExecution: [], +}); + +export class KubernetesPodPodAntiAffinity { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPodPodAntiAffinity))); + } +} diff --git a/app/kubernetes/pod/models/index.js b/app/kubernetes/pod/models/index.js new file mode 100644 index 000000000..cf76e0386 --- /dev/null +++ b/app/kubernetes/pod/models/index.js @@ -0,0 +1,42 @@ +export * from './affinities'; + +/** + * KubernetesPod Model + */ +const _KubernetesPod = Object.freeze({ + Id: '', + Name: '', + Namespace: '', + Images: [], + Status: '', + Restarts: 0, + Node: '', + CreationDate: '', + Containers: [], + Labels: [], + Affinity: {}, // KubernetesPodAffinity + Tolerations: [], // KubernetesPodToleration[] +}); + +export class KubernetesPod { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPod))); + } +} + +/** + * KubernetesPodToleration Model + */ +const _KubernetesPodToleration = Object.freeze({ + Key: '', + Operator: '', + Value: '', + TolerationSeconds: 0, + Effect: '', +}); + +export class KubernetesPodToleration { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPodToleration))); + } +} diff --git a/app/kubernetes/services/podService.js b/app/kubernetes/pod/service.js similarity index 95% rename from app/kubernetes/services/podService.js rename to app/kubernetes/pod/service.js index 49c733e62..90bca7be5 100644 --- a/app/kubernetes/services/podService.js +++ b/app/kubernetes/pod/service.js @@ -3,7 +3,7 @@ import angular from 'angular'; import PortainerError from 'Portainer/error'; import { KubernetesCommonParams } from 'Kubernetes/models/common/params'; -import KubernetesPodConverter from 'Kubernetes/converters/pod'; +import KubernetesPodConverter from 'Kubernetes/pod/converter'; class KubernetesPodService { /* @ngInject */ @@ -21,7 +21,7 @@ class KubernetesPodService { async getAllAsync(namespace) { try { const data = await this.KubernetesPods(namespace).get().$promise; - return _.map(data.items, (item) => KubernetesPodConverter.apiToPod(item)); + return _.map(data.items, (item) => KubernetesPodConverter.apiToModel(item)); } catch (err) { throw new PortainerError('Unable to retrieve pods', err); } diff --git a/app/kubernetes/views/applications/edit/application.html b/app/kubernetes/views/applications/edit/application.html index 40147d896..47eff6d7c 100644 --- a/app/kubernetes/views/applications/edit/application.html +++ b/app/kubernetes/views/applications/edit/application.html @@ -128,6 +128,24 @@ + Placement +
+ + The placement component helps you understand whether or not this application can be deployed on a specific node. +
+ +
+ + Events
@@ -147,7 +165,7 @@ > - + YAML
diff --git a/app/kubernetes/views/applications/edit/applicationController.js b/app/kubernetes/views/applications/edit/applicationController.js index f8706c265..0cfa9d206 100644 --- a/app/kubernetes/views/applications/edit/applicationController.js +++ b/app/kubernetes/views/applications/edit/applicationController.js @@ -1,9 +1,91 @@ import angular from 'angular'; -import _ from 'lodash-es'; +import * as _ from 'lodash-es'; +import * as JsonPatch from 'fast-json-patch'; import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes } from 'Kubernetes/models/application/models'; import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper'; import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import { KubernetesServiceTypes } from 'Kubernetes/models/service/models'; +import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models'; + +function computeTolerations(nodes, application) { + const pod = application.Pods[0]; + _.forEach(nodes, (n) => { + n.AcceptsApplication = true; + n.Expanded = false; + if (!pod) { + return; + } + n.UnmetTaints = []; + _.forEach(n.Taints, (t) => { + const matchKeyMatchValueMatchEffect = _.find(pod.Tolerations, { Key: t.key, Operator: 'Equal', Value: t.value, Effect: t.effect }); + const matchKeyAnyValueMatchEffect = _.find(pod.Tolerations, { Key: t.key, Operator: 'Exists', Effect: t.effect }); + const matchKeyMatchValueAnyEffect = _.find(pod.Tolerations, { Key: t.key, Operator: 'Equal', Value: t.value, Effect: '' }); + const matchKeyAnyValueAnyEffect = _.find(pod.Tolerations, { Key: t.key, Operator: 'Exists', Effect: '' }); + const anyKeyAnyValueAnyEffect = _.find(pod.Tolerations, { Key: '', Operator: 'Exists', Effect: '' }); + + if (!matchKeyMatchValueMatchEffect && !matchKeyAnyValueMatchEffect && !matchKeyMatchValueAnyEffect && !matchKeyAnyValueAnyEffect && !anyKeyAnyValueAnyEffect) { + n.AcceptsApplication = false; + n.UnmetTaints.push(t); + } else { + n.AcceptsApplication = true; + } + }); + }); + return nodes; +} + +// For node requirement format depending on operator value +// see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#nodeselectorrequirement-v1-core +// Some operators require empty "values" field, some only one element in "values" field, etc + +function computeAffinities(nodes, application) { + const pod = application.Pods[0]; + _.forEach(nodes, (n) => { + if (pod.NodeSelector) { + const patch = JsonPatch.compare(n.Labels, pod.NodeSelector); + _.remove(patch, { op: 'remove' }); + n.UnmatchedNodeSelectorLabels = _.map(patch, (i) => { + return { key: _.trimStart(i.path, '/'), value: i.value }; + }); + if (n.UnmatchedNodeSelectorLabels.length) { + n.AcceptsApplication = false; + } + } + + if (pod.Affinity.NodeAffinity.requiredDuringSchedulingIgnoredDuringExecution) { + const unmatchedTerms = _.map(pod.Affinity.NodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms, (t) => { + const unmatchedExpressions = _.map(t.matchExpressions, (e) => { + const exists = {}.hasOwnProperty.call(n.Labels, e.key); + const isIn = exists && _.includes(e.values, n.Labels[e.key]); + if ( + (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS && exists) || + (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.DOES_NOT_EXIST && !exists) || + (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN && isIn) || + (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.NOT_IN && !isIn) || + (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.GREATER_THAN && exists && parseInt(n.Labels[e.key]) > parseInt(e.values[0])) || + (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.LOWER_THAN && exists && parseInt(n.Labels[e.key]) < parseInt(e.values[0])) + ) { + return; + } + return e; + }); + return _.without(unmatchedExpressions, undefined); + }); + _.remove(unmatchedTerms, (i) => i.length === 0); + n.UnmatchedNodeAffinities = unmatchedTerms; + if (n.UnmatchedNodeAffinities.length) { + n.AcceptsApplication = false; + } + } + }); + return nodes; +} + +function computePlacements(nodes, application) { + nodes = computeTolerations(nodes, application); + nodes = computeAffinities(nodes, application); + return nodes; +} class KubernetesApplicationController { /* @ngInject */ @@ -18,6 +100,7 @@ class KubernetesApplicationController { KubernetesEventService, KubernetesStackService, KubernetesPodService, + KubernetesNodeService, KubernetesNamespaceHelper ) { this.$async = $async; @@ -31,6 +114,7 @@ class KubernetesApplicationController { this.KubernetesEventService = KubernetesEventService; this.KubernetesStackService = KubernetesStackService; this.KubernetesPodService = KubernetesPodService; + this.KubernetesNodeService = KubernetesNodeService; this.KubernetesNamespaceHelper = KubernetesNamespaceHelper; @@ -103,7 +187,6 @@ class KubernetesApplicationController { /** * ROLLBACK */ - async rollbackApplicationAsync() { try { // await this.KubernetesApplicationService.rollback(this.application, this.formValues.SelectedRevision); @@ -196,7 +279,11 @@ class KubernetesApplicationController { async getApplicationAsync() { try { this.state.dataLoading = true; - this.application = await this.KubernetesApplicationService.get(this.state.params.namespace, this.state.params.name); + const [application, nodes] = await Promise.all([ + this.KubernetesApplicationService.get(this.state.params.namespace, this.state.params.name), + this.KubernetesNodeService.get(), + ]); + this.application = application; this.formValues.Note = this.application.Note; if (this.application.Note) { this.state.expandedNote = true; @@ -204,6 +291,8 @@ class KubernetesApplicationController { if (this.application.CurrentRevision) { this.formValues.SelectedRevision = _.find(this.application.Revisions, { revision: this.application.CurrentRevision.revision }); } + + this.placements = computePlacements(nodes, this.application); } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve application details'); } finally { diff --git a/app/kubernetes/views/applications/edit/components/placements-datatable/controller.js b/app/kubernetes/views/applications/edit/components/placements-datatable/controller.js new file mode 100644 index 000000000..e35fbc74d --- /dev/null +++ b/app/kubernetes/views/applications/edit/components/placements-datatable/controller.js @@ -0,0 +1,71 @@ +import * as _ from 'lodash-es'; + +angular.module('portainer.docker').controller('KubernetesApplicationPlacementsDatatableController', function ($scope, $controller, DatatableService, Authentication) { + angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); + this.state = Object.assign(this.state, { + expandedItems: [], + expandAll: false, + }); + + this.expandItem = function (item, expanded) { + if (!this.itemCanExpand(item)) { + return; + } + + item.Expanded = expanded; + if (!expanded) { + item.Highlighted = false; + } + }; + + this.itemCanExpand = function (item) { + return !item.AcceptsApplication; + }; + + this.hasExpandableItems = function () { + return _.filter(this.state.filteredDataSet, (item) => this.itemCanExpand(item)).length; + }; + + this.expandAll = function () { + this.state.expandAll = !this.state.expandAll; + _.forEach(this.state.filteredDataSet, (item) => { + if (this.itemCanExpand(item)) { + this.expandItem(item, this.state.expandAll); + } + }); + }; + + this.$onInit = function () { + this.isAdmin = Authentication.isAdmin(); + this.setDefaults(); + this.prepareTableFromDataset(); + + this.state.orderBy = this.orderBy; + var storedOrder = DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + this.onTextFilterChange(); + } + + var storedFilters = DatatableService.getDataTableFilters(this.tableKey); + if (storedFilters !== null) { + this.filters = storedFilters; + } + if (this.filters && this.filters.state) { + this.filters.state.open = false; + } + + var storedSettings = DatatableService.getDataTableSettings(this.tableKey); + if (storedSettings !== null) { + this.settings = storedSettings; + this.settings.open = false; + } + this.onSettingsRepeaterChange(); + }; +}); diff --git a/app/kubernetes/views/applications/edit/components/placements-datatable/index.js b/app/kubernetes/views/applications/edit/components/placements-datatable/index.js new file mode 100644 index 000000000..21a02d6f3 --- /dev/null +++ b/app/kubernetes/views/applications/edit/components/placements-datatable/index.js @@ -0,0 +1,15 @@ +angular.module('portainer.kubernetes').component('kubernetesApplicationPlacementsDatatable', { + templateUrl: './template.html', + controller: 'KubernetesApplicationPlacementsDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + refreshCallback: '<', + loading: '<', + removeAction: '<', + }, +}); diff --git a/app/kubernetes/views/applications/edit/components/placements-datatable/template.html b/app/kubernetes/views/applications/edit/components/placements-datatable/template.html new file mode 100644 index 000000000..6b85f9e0c --- /dev/null +++ b/app/kubernetes/views/applications/edit/components/placements-datatable/template.html @@ -0,0 +1,184 @@ +
+ + +
+
{{ $ctrl.titleText }}
+
+ + Table settings + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Node + + + +
+ + + + + + + {{ item.Name }} +
+ This application is missing a toleration for the taint {{ taint.key }}{{ taint.value ? '=' + taint.value : '' }}:{{ taint.effect }} +
+ Placement constraint not respected for that node. +
+ This application can only be scheduled on a node where the label {{ label.key }} is set to {{ label.value }} +
+ Placement label not respected for that node. +
+ This application can only be scheduled on nodes respecting one of the following labels combination: +
+ + {{ term.key }} {{ term.operator }} {{ term.values | kubernetesApplicationConstraintNodeAffinityValue: term.operator }} + + {{ $last ? '' : ' + ' }} +
Loading...
No node available.
+
+ +
+
+