mirror of https://github.com/portainer/portainer
feat(k8s/application): expose tolerations and affinities (#4063)
* feat(k8s/application): expose placement conditions * feat(k8s/applications): minor UI update * feat(k8s/application): update message for admin and non admin users * feat(kubernetes/applications): minor UI update Co-authored-by: Anthony Lapenna <lapenna.anthony@gmail.com>pull/4128/head
parent
63bf654d8d
commit
4431d748c2
|
@ -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;
|
||||
|
|
|
@ -21,7 +21,7 @@ angular.module('portainer.docker').controller('KubernetesApplicationsStacksDatat
|
|||
showSystem: false,
|
||||
});
|
||||
|
||||
this.onSettingsRepeaterChange = function () {
|
||||
this.onSettingsShowSystemChange = function () {
|
||||
DatatableService.setDataTableSettings(this.tableKey, this.settings);
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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 '';
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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';
|
|
@ -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)
|
|
@ -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)));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -4,12 +4,14 @@
|
|||
const _KubernetesNode = Object.freeze({
|
||||
Id: '',
|
||||
Name: '',
|
||||
Labels: {},
|
||||
Role: '',
|
||||
Status: '',
|
||||
CPU: 0,
|
||||
Memory: '',
|
||||
Version: '',
|
||||
IPAddress: '',
|
||||
Taints: [],
|
||||
});
|
||||
|
||||
export class KubernetesNode {
|
|
@ -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 {
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)));
|
||||
}
|
||||
}
|
|
@ -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)));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -128,6 +128,24 @@
|
|||
</uib-tab>
|
||||
|
||||
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">
|
||||
<uib-tab-heading> <i class="fas fa-compress-arrows-alt space-right" aria-hidden="true"></i> Placement </uib-tab-heading>
|
||||
<div class="small text-muted" style="padding: 20px;">
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
The placement component helps you understand whether or not this application can be deployed on a specific node.
|
||||
</div>
|
||||
<kubernetes-application-placements-datatable
|
||||
title-text="Placement constraints/preferences"
|
||||
title-icon="fa-compress-arrows-alt"
|
||||
dataset="ctrl.placements"
|
||||
table-key="kubernetes.application.placements"
|
||||
order-by="Name"
|
||||
reverse-order="false"
|
||||
loading="ctrl.state.dataLoading"
|
||||
refresh-callback="ctrl.getApplication"
|
||||
></kubernetes-application-placements-datatable>
|
||||
</uib-tab>
|
||||
|
||||
<uib-tab index="2" classes="btn-sm" select="ctrl.selectTab(2)">
|
||||
<uib-tab-heading>
|
||||
<i class="fa fa-history space-right" aria-hidden="true"></i> Events
|
||||
<div ng-if="ctrl.hasEventWarnings()">
|
||||
|
@ -147,7 +165,7 @@
|
|||
></kubernetes-events-datatable>
|
||||
</uib-tab>
|
||||
|
||||
<uib-tab index="2" ng-if="ctrl.application.Yaml" select="ctrl.showEditor()" classes="btn-sm">
|
||||
<uib-tab index="3" ng-if="ctrl.application.Yaml" select="ctrl.showEditor()" classes="btn-sm">
|
||||
<uib-tab-heading> <i class="fa fa-code space-right" aria-hidden="true"></i> YAML </uib-tab-heading>
|
||||
<div style="padding-right: 25px;" ng-if="ctrl.state.showEditorTab">
|
||||
<kubernetes-yaml-inspector key="application-yaml" data="ctrl.application.Yaml"></kubernetes-yaml-inspector>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
});
|
|
@ -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: '<',
|
||||
},
|
||||
});
|
|
@ -0,0 +1,184 @@
|
|||
<div class="datatable">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
|
||||
<div class="settings">
|
||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
|
||||
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Table settings</span>
|
||||
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
|
||||
<div class="tableMenu">
|
||||
<div class="menuHeader">
|
||||
Table settings
|
||||
</div>
|
||||
<div class="menuContent">
|
||||
<div>
|
||||
<div class="md-checkbox">
|
||||
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()" />
|
||||
<label for="setting_auto_refresh">Auto refresh</label>
|
||||
</div>
|
||||
<div ng-if="$ctrl.settings.repeater.autoRefresh">
|
||||
<label for="settings_refresh_rate">
|
||||
Refresh rate
|
||||
</label>
|
||||
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
|
||||
<option value="10">10s</option>
|
||||
<option value="30">30s</option>
|
||||
<option value="60">1min</option>
|
||||
<option value="120">2min</option>
|
||||
<option value="300">5min</option>
|
||||
</select>
|
||||
<span>
|
||||
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 2%;">
|
||||
<a ng-click="$ctrl.expandAll()" ng-if="$ctrl.hasExpandableItems()">
|
||||
<i ng-class="{ 'fas fa-angle-down': $ctrl.state.expandAll, 'fas fa-angle-right': !$ctrl.state.expandAll }" aria-hidden="true"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th style="width: 98%;">
|
||||
<a ng-click="$ctrl.changeOrderBy('Name')">
|
||||
Node
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
dir-paginate-start="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | filter: $ctrl.isDisplayed | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit: $ctrl.tableKey))"
|
||||
ng-class="{ active: item.Checked }"
|
||||
ng-style="{ background: item.Highlighted ? '#d5e8f3' : '' }"
|
||||
ng-click="$ctrl.expandItem(item, !item.Expanded)"
|
||||
pagination-id="$ctrl.tableKey"
|
||||
>
|
||||
<td>
|
||||
<a ng-if="$ctrl.itemCanExpand(item)">
|
||||
<i ng-class="{ 'fas fa-angle-down': item.Expanded, 'fas fa-angle-right': !item.Expanded }" class="space-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
<i ng-if="item.AcceptsApplication" class="fa fa-check green-icon" aria-hidden="true"></i>
|
||||
<i ng-if="!item.AcceptsApplication" class="fa fa-times red-icon" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td>
|
||||
{{ item.Name }}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- ADMIN + UNMET TAINTS -->
|
||||
<tr ng-if="$ctrl.isAdmin" ng-show="item.Expanded" ng-repeat="taint in item.UnmetTaints" ng-style="{ background: item.Highlighted ? '#d5e8f3' : '#f5f5f5' }">
|
||||
<td colspan="2">
|
||||
This application is missing a toleration for the taint <code>{{ taint.key }}{{ taint.value ? '=' + taint.value : '' }}:{{ taint.effect }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- !ADMIN + UNMET TAINTS -->
|
||||
<!-- USER + UNMET TAINTS -->
|
||||
<tr ng-if="!$ctrl.isAdmin && item.UnmetTaints.length" ng-show="item.Expanded" ng-style="{ background: item.Highlighted ? '#d5e8f3' : '#f5f5f5' }">
|
||||
<td colspan="2">
|
||||
Placement constraint not respected for that node.
|
||||
</td>
|
||||
</tr>
|
||||
<!-- ! USER + UNMET TAINTS -->
|
||||
<!-- ADMIN + UNMET NODE SELECTOR LABELS -->
|
||||
<tr
|
||||
ng-if="$ctrl.isAdmin"
|
||||
ng-show="item.Expanded"
|
||||
ng-repeat="label in item.UnmatchedNodeSelectorLabels"
|
||||
ng-style="{ background: item.Highlighted ? '#d5e8f3' : '#f5f5f5' }"
|
||||
>
|
||||
<td colspan="2">
|
||||
This application can only be scheduled on a node where the label <code>{{ label.key }}</code> is set to <code>{{ label.value }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- ! ADMIN + UNMET NODE SELECTOR LABELS -->
|
||||
<!-- USER + UNMET NODE SELECTOR LABELS || UNMET NODE AFFINITIES -->
|
||||
<tr
|
||||
ng-if="!$ctrl.isAdmin && (item.UnmatchedNodeSelectorLabels.length || item.UnmatchedNodeAffinities.length)"
|
||||
ng-show="item.Expanded"
|
||||
ng-style="{ background: item.Highlighted ? '#d5e8f3' : '#f5f5f5' }"
|
||||
>
|
||||
<td colspan="2">
|
||||
Placement label not respected for that node.
|
||||
</td>
|
||||
</tr>
|
||||
<!-- ! USER + UNMET NODE SELECTOR LABELS || UNMET NODE AFFINITIES -->
|
||||
<!-- ADMIN + UNMET NODE AFFINITIES -->
|
||||
<tr ng-if="$ctrl.isAdmin" ng-show="item.Expanded && item.UnmatchedNodeAffinities.length" ng-style="{ background: item.Highlighted ? '#d5e8f3' : '#f5f5f5' }">
|
||||
<td colspan="2">
|
||||
This application can only be scheduled on nodes respecting one of the following labels combination:
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
dir-paginate-end
|
||||
ng-if="$ctrl.isAdmin"
|
||||
ng-show="item.Expanded"
|
||||
ng-repeat="aff in item.UnmatchedNodeAffinities"
|
||||
ng-style="{ background: item.Highlighted ? '#d5e8f3' : '#f5f5f5' }"
|
||||
>
|
||||
<td></td>
|
||||
<td>
|
||||
<code ng-repeat-start="term in aff track by $index">
|
||||
{{ term.key }} {{ term.operator }} {{ term.values | kubernetesApplicationConstraintNodeAffinityValue: term.operator }}
|
||||
</code>
|
||||
<span ng-repeat-end>{{ $last ? '' : ' + ' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- ! ADMIN + UNMET NODE AFFINITIES -->
|
||||
<tr ng-if="$ctrl.loading">
|
||||
<td colspan="2" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.loading && (!$ctrl.dataset || $ctrl.state.filteredDataSet.length === 0)">
|
||||
<td colspan="2" class="text-center text-muted">No node available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer" ng-if="$ctrl.dataset">
|
||||
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
|
||||
<div class="paginationControls">
|
||||
<form class="form-inline">
|
||||
<span class="limitSelector">
|
||||
<span style="margin-right: 5px;">
|
||||
Items per page
|
||||
</span>
|
||||
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
|
||||
<option value="0">All</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</span>
|
||||
<dir-pagination-controls max-size="5" pagination-id="$ctrl.tableKey"></dir-pagination-controls>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
Loading…
Reference in New Issue