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
xAt0mZ 2020-07-30 00:25:59 +02:00 committed by GitHub
parent 63bf654d8d
commit 4431d748c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 635 additions and 112 deletions

View File

@ -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;

View File

@ -21,7 +21,7 @@ angular.module('portainer.docker').controller('KubernetesApplicationsStacksDatat
showSystem: false,
});
this.onSettingsRepeaterChange = function () {
this.onSettingsShowSystemChange = function () {
DatatableService.setDataTableSettings(this.tableKey, this.settings);
};

View File

@ -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;

View File

@ -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 '';
};
});

View File

@ -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';

View File

@ -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)

View File

@ -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)));
}
}

View File

@ -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;
}

View File

@ -4,12 +4,14 @@
const _KubernetesNode = Object.freeze({
Id: '',
Name: '',
Labels: {},
Role: '',
Status: '',
CPU: 0,
Memory: '',
Version: '',
IPAddress: '',
Taints: [],
});
export class KubernetesNode {

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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)));
}
}

View File

@ -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)));
}
}

View File

@ -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);
}

View File

@ -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>

View File

@ -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 {

View File

@ -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();
};
});

View File

@ -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: '<',
},
});

View File

@ -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>