diff --git a/app/kubernetes/filters/application.ts b/app/kubernetes/filters/application.ts
new file mode 100644
index 000000000..1bb7033bf
--- /dev/null
+++ b/app/kubernetes/filters/application.ts
@@ -0,0 +1,33 @@
+import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from '../pod/models';
+
+export function nodeAffinityValues(
+ values: string | string[],
+ operator: KubernetesPodNodeAffinityNodeSelectorRequirementOperators
+) {
+ if (
+ operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN ||
+ operator ===
+ KubernetesPodNodeAffinityNodeSelectorRequirementOperators.NOT_IN
+ ) {
+ return values;
+ }
+
+ if (
+ operator ===
+ KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS ||
+ operator ===
+ KubernetesPodNodeAffinityNodeSelectorRequirementOperators.DOES_NOT_EXIST
+ ) {
+ return '';
+ }
+
+ if (
+ operator ===
+ KubernetesPodNodeAffinityNodeSelectorRequirementOperators.GREATER_THAN ||
+ operator ===
+ KubernetesPodNodeAffinityNodeSelectorRequirementOperators.LOWER_THAN
+ ) {
+ return values[0];
+ }
+ return '';
+}
diff --git a/app/kubernetes/filters/applicationFilters.js b/app/kubernetes/filters/applicationFilters.js
index 69f9c2979..8b4fe62c0 100644
--- a/app/kubernetes/filters/applicationFilters.js
+++ b/app/kubernetes/filters/applicationFilters.js
@@ -1,7 +1,7 @@
import _ from 'lodash-es';
import { KubernetesApplicationDataAccessPolicies } from 'Kubernetes/models/application/models';
import { KubernetesApplicationTypes, KubernetesApplicationTypeStrings } from 'Kubernetes/models/application/models';
-import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models';
+import { nodeAffinityValues } from './application';
angular
.module('portainer.kubernetes')
@@ -65,22 +65,7 @@ angular
})
.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 '';
- };
+ return nodeAffinityValues;
})
.filter('kubernetesNodeLabelHumanReadbleText', function () {
'use strict';
diff --git a/app/kubernetes/pod/models/affinities.js b/app/kubernetes/pod/models/affinities.ts
similarity index 62%
rename from app/kubernetes/pod/models/affinities.js
rename to app/kubernetes/pod/models/affinities.ts
index 08b38f9d0..d2e2e607f 100644
--- a/app/kubernetes/pod/models/affinities.js
+++ b/app/kubernetes/pod/models/affinities.ts
@@ -1,11 +1,11 @@
-export const KubernetesPodNodeAffinityNodeSelectorRequirementOperators = Object.freeze({
- IN: 'In',
- NOT_IN: 'NotIn',
- EXISTS: 'Exists',
- DOES_NOT_EXIST: 'DoesNotExist',
- GREATER_THAN: 'Gt',
- LOWER_THAN: 'Lt',
-});
+export enum KubernetesPodNodeAffinityNodeSelectorRequirementOperators {
+ IN = 'In',
+ NOT_IN = 'NotIn',
+ EXISTS = 'Exists',
+ DOES_NOT_EXIST = 'DoesNotExist',
+ GREATER_THAN = 'Gt',
+ LOWER_THAN = 'Lt',
+}
/**
* KubernetesPodAffinity Model
diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts
index c45e66eb1..212a9b676 100644
--- a/app/kubernetes/react/components/index.ts
+++ b/app/kubernetes/react/components/index.ts
@@ -19,6 +19,7 @@ import {
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withFormValidation } from '@/react-tools/withFormValidation';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
+import { PlacementsDatatable } from '@/react/kubernetes/applications/ItemView/PlacementsDatatable';
export const ngModule = angular
.module('portainer.kubernetes.react.components', [])
@@ -107,6 +108,10 @@ export const ngModule = angular
withUIRouter(withReactQuery(withUserProvider(ApplicationDetailsWidget))),
[]
)
+ )
+ .component(
+ 'kubernetesApplicationPlacementsDatatable',
+ r2a(withCurrentUser(PlacementsDatatable), ['dataset', 'onRefresh'])
);
export const componentsModule = ngModule.name;
diff --git a/app/kubernetes/views/applications/edit/application.html b/app/kubernetes/views/applications/edit/application.html
index 32bd244c3..c6996653d 100644
--- a/app/kubernetes/views/applications/edit/application.html
+++ b/app/kubernetes/views/applications/edit/application.html
@@ -40,15 +40,11 @@
The placement component helps you understand whether or not this application can be deployed on a specific node.
+
diff --git a/app/kubernetes/views/applications/edit/components/placements-datatable/controller.js b/app/kubernetes/views/applications/edit/components/placements-datatable/controller.js
deleted file mode 100644
index ea550309a..000000000
--- a/app/kubernetes/views/applications/edit/components/placements-datatable/controller.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import _ 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
deleted file mode 100644
index 21a02d6f3..000000000
--- a/app/kubernetes/views/applications/edit/components/placements-datatable/index.js
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index 3ef0d33ef..000000000
--- a/app/kubernetes/views/applications/edit/components/placements-datatable/template.html
+++ /dev/null
@@ -1,191 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
- |
-
- {{ 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. |
-
-
-
-
-
-
diff --git a/app/react/kubernetes/applications/ItemView/PlacementsDatatable/PlacementsDatatable.tsx b/app/react/kubernetes/applications/ItemView/PlacementsDatatable/PlacementsDatatable.tsx
new file mode 100644
index 000000000..e9bd8e493
--- /dev/null
+++ b/app/react/kubernetes/applications/ItemView/PlacementsDatatable/PlacementsDatatable.tsx
@@ -0,0 +1,66 @@
+import { Minimize2 } from 'lucide-react';
+
+import {
+ BasicTableSettings,
+ createPersistedStore,
+ refreshableSettings,
+ RefreshableTableSettings,
+} from '@@/datatables/types';
+import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
+import { useRepeater } from '@@/datatables/useRepeater';
+import { TableSettingsMenu } from '@@/datatables';
+import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
+import { useTableState } from '@@/datatables/useTableState';
+
+import { Node } from '../types';
+
+import { SubRow } from './PlacementsDatatableSubRow';
+import { columns } from './columns';
+
+interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
+
+function createStore(storageKey: string) {
+ return createPersistedStore(storageKey, 'node', (set) => ({
+ ...refreshableSettings(set),
+ }));
+}
+
+const storageKey = 'kubernetes.application.placements';
+const settingsStore = createStore(storageKey);
+
+export function PlacementsDatatable({
+ dataset,
+ onRefresh,
+}: {
+ dataset: Node[];
+ onRefresh: () => Promise;
+}) {
+ const tableState = useTableState(settingsStore, storageKey);
+
+ useRepeater(tableState.autoRefreshRate, onRefresh);
+
+ return (
+ !row.original.AcceptsApplication}
+ title="Placement constraints/preferences"
+ titleIcon={Minimize2}
+ dataset={dataset}
+ settingsManager={tableState}
+ columns={columns}
+ disableSelect
+ noWidget
+ renderTableSettings={() => (
+
+
+
+ )}
+ emptyContentLabel="No node available."
+ renderSubRow={(row) => (
+
+ )}
+ />
+ );
+}
diff --git a/app/react/kubernetes/applications/ItemView/PlacementsDatatable/PlacementsDatatableSubRow.tsx b/app/react/kubernetes/applications/ItemView/PlacementsDatatable/PlacementsDatatableSubRow.tsx
new file mode 100644
index 000000000..8e86563e2
--- /dev/null
+++ b/app/react/kubernetes/applications/ItemView/PlacementsDatatable/PlacementsDatatableSubRow.tsx
@@ -0,0 +1,189 @@
+import clsx from 'clsx';
+import { Fragment } from 'react';
+
+import { nodeAffinityValues } from '@/kubernetes/filters/application';
+import { useAuthorizations } from '@/react/hooks/useUser';
+
+import { Affinity, Label, Node, Taint } from '../types';
+
+interface SubRowProps {
+ node: Node;
+ cellCount: number;
+}
+
+export function SubRow({ node, cellCount }: SubRowProps) {
+ const authorized = useAuthorizations(
+ 'K8sApplicationErrorDetailsR',
+ undefined,
+ true
+ );
+
+ if (!authorized) {
+ <>
+ {isDefined(node.UnmetTaints) && (
+
+
+ Placement constraint not respected for that node.
+ |
+
+ )}
+
+ {(isDefined(node.UnmatchedNodeSelectorLabels) ||
+ isDefined(node.UnmatchedNodeAffinities)) && (
+
+
+ Placement label not respected for that node.
+ |
+
+ )}
+ >;
+ }
+
+ return (
+ <>
+ {isDefined(node.UnmetTaints) && (
+
+ )}
+ {isDefined(node.UnmatchedNodeSelectorLabels) && (
+
+ )}
+ {isDefined(node.UnmatchedNodeAffinities) && (
+
+ )}
+ >
+ );
+}
+
+function isDefined(arr?: Array): arr is Array {
+ return !!arr && arr.length > 0;
+}
+
+function UnmetTaintsInfo({
+ taints,
+ isHighlighted,
+ cellCount,
+}: {
+ taints: Array;
+ isHighlighted: boolean;
+ cellCount: number;
+}) {
+ return (
+ <>
+ {taints.map((taint) => (
+
+
+ This application is missing a toleration for the taint
+
+ {taint.Key}
+ {taint.Value ? `=${taint.Value}` : ''}:{taint.Effect}
+
+ |
+
+ ))}
+ >
+ );
+}
+
+function UnmatchedLabelsInfo({
+ labels,
+ isHighlighted,
+ cellCount,
+}: {
+ labels: Array