mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
feat(app): migrate app parent view to react [EE-5361] (#10086)
Co-authored-by: testa113 <testa113>
This commit is contained in:
parent
531f88b947
commit
841ca1ebd4
42 changed files with 1448 additions and 810 deletions
|
@ -0,0 +1,73 @@
|
|||
import { Minimize2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
BasicTableSettings,
|
||||
RefreshableTableSettings,
|
||||
} from '@@/datatables/types';
|
||||
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
|
||||
import { TableSettingsMenu } from '@@/datatables';
|
||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { NodePlacementRowData } from '../types';
|
||||
|
||||
import { SubRow } from './PlacementsDatatableSubRow';
|
||||
import { columns } from './columns';
|
||||
|
||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
|
||||
|
||||
type Props = {
|
||||
isLoading: boolean;
|
||||
dataset: NodePlacementRowData[];
|
||||
hasPlacementWarning: boolean;
|
||||
tableState: TableSettings & {
|
||||
setSearch: (value: string) => void;
|
||||
search: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function PlacementsDatatable({
|
||||
isLoading,
|
||||
dataset,
|
||||
hasPlacementWarning,
|
||||
tableState,
|
||||
}: Props) {
|
||||
return (
|
||||
<ExpandableDatatable
|
||||
isLoading={isLoading}
|
||||
getRowCanExpand={(row) => !row.original.acceptsApplication}
|
||||
title="Placement constraints/preferences"
|
||||
titleIcon={Minimize2}
|
||||
dataset={dataset}
|
||||
settingsManager={tableState}
|
||||
getRowId={(row) => row.name}
|
||||
columns={columns}
|
||||
disableSelect
|
||||
description={
|
||||
hasPlacementWarning ? (
|
||||
<TextTip>
|
||||
Based on the placement rules, the application pod can't be
|
||||
scheduled on any nodes.
|
||||
</TextTip>
|
||||
) : (
|
||||
<TextTip color="blue">
|
||||
The placement table helps you understand whether or not this
|
||||
application can be deployed on a specific node.
|
||||
</TextTip>
|
||||
)
|
||||
}
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
<TableSettingsMenuAutoRefresh
|
||||
value={tableState.autoRefreshRate}
|
||||
onChange={tableState.setAutoRefreshRate}
|
||||
/>
|
||||
</TableSettingsMenu>
|
||||
)}
|
||||
emptyContentLabel="No node available."
|
||||
renderSubRow={(row) => (
|
||||
<SubRow node={row.original} cellCount={row.getVisibleCells().length} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,190 @@
|
|||
import clsx from 'clsx';
|
||||
import { Fragment } from 'react';
|
||||
import { Taint } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { nodeAffinityValues } from '@/kubernetes/filters/application';
|
||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||
|
||||
import { Affinity, Label, NodePlacementRowData } from '../types';
|
||||
|
||||
interface SubRowProps {
|
||||
node: NodePlacementRowData;
|
||||
cellCount: number;
|
||||
}
|
||||
|
||||
export function SubRow({ node, cellCount }: SubRowProps) {
|
||||
const authorized = useAuthorizations(
|
||||
'K8sApplicationErrorDetailsR',
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
if (!authorized) {
|
||||
<>
|
||||
{isDefined(node.unmetTaints) && (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': node.highlighted,
|
||||
'datatable-unhighlighted': !node.highlighted,
|
||||
})}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
Placement constraint not respected for that node.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{(isDefined(node.unmatchedNodeSelectorLabels) ||
|
||||
isDefined(node.unmatchedNodeAffinities)) && (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': node.highlighted,
|
||||
'datatable-unhighlighted': !node.highlighted,
|
||||
})}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
Placement label not respected for that node.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDefined(node.unmetTaints) && (
|
||||
<UnmetTaintsInfo
|
||||
taints={node.unmetTaints}
|
||||
cellCount={cellCount}
|
||||
isHighlighted={node.highlighted}
|
||||
/>
|
||||
)}
|
||||
{isDefined(node.unmatchedNodeSelectorLabels) && (
|
||||
<UnmatchedLabelsInfo
|
||||
labels={node.unmatchedNodeSelectorLabels}
|
||||
cellCount={cellCount}
|
||||
isHighlighted={node.highlighted}
|
||||
/>
|
||||
)}
|
||||
{isDefined(node.unmatchedNodeAffinities) && (
|
||||
<UnmatchedAffinitiesInfo
|
||||
affinities={node.unmatchedNodeAffinities}
|
||||
cellCount={cellCount}
|
||||
isHighlighted={node.highlighted}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isDefined<T>(arr?: Array<T>): arr is Array<T> {
|
||||
return !!arr && arr.length > 0;
|
||||
}
|
||||
|
||||
function UnmetTaintsInfo({
|
||||
taints,
|
||||
isHighlighted,
|
||||
cellCount,
|
||||
}: {
|
||||
taints: Array<Taint>;
|
||||
isHighlighted: boolean;
|
||||
cellCount: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{taints.map((taint) => (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': isHighlighted,
|
||||
'datatable-unhighlighted': !isHighlighted,
|
||||
})}
|
||||
key={taint.key}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
This application is missing a toleration for the taint
|
||||
<code className="space-left">
|
||||
{taint.key}
|
||||
{taint.value ? `=${taint.value}` : ''}:{taint.effect}
|
||||
</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UnmatchedLabelsInfo({
|
||||
labels,
|
||||
isHighlighted,
|
||||
cellCount,
|
||||
}: {
|
||||
labels: Array<Label>;
|
||||
isHighlighted: boolean;
|
||||
cellCount: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{labels.map((label) => (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': isHighlighted,
|
||||
'datatable-unhighlighted': !isHighlighted,
|
||||
})}
|
||||
key={label.key}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UnmatchedAffinitiesInfo({
|
||||
affinities,
|
||||
isHighlighted,
|
||||
cellCount,
|
||||
}: {
|
||||
affinities: Array<Affinity>;
|
||||
isHighlighted: boolean;
|
||||
cellCount: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': isHighlighted,
|
||||
'datatable-unhighlighted': !isHighlighted,
|
||||
})}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
This application can only be scheduled on nodes respecting one of the
|
||||
following labels combination:
|
||||
</td>
|
||||
</tr>
|
||||
{affinities.map((aff) => (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': isHighlighted,
|
||||
'datatable-unhighlighted': !isHighlighted,
|
||||
})}
|
||||
>
|
||||
<td />
|
||||
<td colSpan={cellCount - 1}>
|
||||
{aff.map((term, index) => (
|
||||
<Fragment key={index}>
|
||||
<code>
|
||||
{term.key} {term.operator}{' '}
|
||||
{nodeAffinityValues(term.values, term.operator)}
|
||||
</code>
|
||||
<span>{index === aff.length - 1 ? '' : ' + '}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { NodePlacementRowData } from '../../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<NodePlacementRowData>();
|
|
@ -0,0 +1,15 @@
|
|||
import { buildExpandColumn } from '@@/datatables/expand-column';
|
||||
|
||||
import { NodePlacementRowData } from '../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
import { status } from './status';
|
||||
|
||||
export const columns = [
|
||||
buildExpandColumn<NodePlacementRowData>(),
|
||||
status,
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Node',
|
||||
id: 'node',
|
||||
}),
|
||||
];
|
|
@ -0,0 +1,25 @@
|
|||
import { Check, X } from 'lucide-react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const status = columnHelper.accessor('acceptsApplication', {
|
||||
header: '',
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => {
|
||||
const acceptsApplication = getValue();
|
||||
return (
|
||||
<Icon
|
||||
icon={acceptsApplication ? Check : X}
|
||||
mode={acceptsApplication ? 'success' : 'danger'}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
width: 30,
|
||||
},
|
||||
enableResizing: false,
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
export { PlacementsDatatable } from './PlacementsDatatable';
|
||||
export {
|
||||
usePlacementTableState,
|
||||
usePlacementTableData,
|
||||
} from './usePlacementTableData';
|
|
@ -0,0 +1,249 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { useMemo } from 'react';
|
||||
import { Pod, Taint, Node } from 'kubernetes-types/core/v1';
|
||||
import _ from 'lodash';
|
||||
import * as JsonPatch from 'fast-json-patch';
|
||||
|
||||
import { useNodesQuery } from '@/react/kubernetes/cluster/HomeView/nodes.service';
|
||||
|
||||
import {
|
||||
BasicTableSettings,
|
||||
RefreshableTableSettings,
|
||||
createPersistedStore,
|
||||
refreshableSettings,
|
||||
} from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { useApplication, useApplicationPods } from '../../application.queries';
|
||||
import { NodePlacementRowData } from '../types';
|
||||
|
||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
|
||||
|
||||
function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettings>(storageKey, 'node', (set) => ({
|
||||
...refreshableSettings(set),
|
||||
}));
|
||||
}
|
||||
|
||||
const storageKey = 'kubernetes.application.placements';
|
||||
const placementsSettingsStore = createStore(storageKey);
|
||||
|
||||
export function usePlacementTableState() {
|
||||
return useTableState(placementsSettingsStore, storageKey);
|
||||
}
|
||||
|
||||
export function usePlacementTableData() {
|
||||
const placementsTableState = usePlacementTableState();
|
||||
const autoRefreshRate = placementsTableState.autoRefreshRate * 1000; // ms to seconds
|
||||
|
||||
const stateAndParams = useCurrentStateAndParams();
|
||||
const {
|
||||
params: {
|
||||
namespace,
|
||||
name,
|
||||
'resource-type': resourceType,
|
||||
endpointId: environmentId,
|
||||
},
|
||||
} = stateAndParams;
|
||||
const { data: application, ...applicationQuery } = useApplication(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
resourceType,
|
||||
{ autoRefreshRate }
|
||||
);
|
||||
const { data: pods, ...podsQuery } = useApplicationPods(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
application,
|
||||
{ autoRefreshRate }
|
||||
);
|
||||
const { data: nodes, ...nodesQuery } = useNodesQuery(environmentId, {
|
||||
autoRefreshRate,
|
||||
});
|
||||
|
||||
const placementsData = useMemo(
|
||||
() => (nodes && pods ? computePlacements(nodes, pods) : []),
|
||||
[nodes, pods]
|
||||
);
|
||||
|
||||
const isPlacementsTableLoading =
|
||||
applicationQuery.isLoading || nodesQuery.isLoading || podsQuery.isLoading;
|
||||
|
||||
const hasPlacementWarning = useMemo(() => {
|
||||
const notAllowedOnEveryNode = placementsData.every(
|
||||
(nodePlacement) => !nodePlacement.acceptsApplication
|
||||
);
|
||||
return !isPlacementsTableLoading && notAllowedOnEveryNode;
|
||||
}, [isPlacementsTableLoading, placementsData]);
|
||||
|
||||
return {
|
||||
placementsData,
|
||||
isPlacementsTableLoading,
|
||||
hasPlacementWarning,
|
||||
};
|
||||
}
|
||||
|
||||
export function computePlacements(
|
||||
nodes: Node[],
|
||||
pods: Pod[]
|
||||
): NodePlacementRowData[] {
|
||||
const pod = pods?.[0];
|
||||
if (!pod) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const placementDataFromTolerations: NodePlacementRowData[] =
|
||||
computeTolerations(nodes, pod);
|
||||
const placementDataFromAffinities: NodePlacementRowData[] = computeAffinities(
|
||||
nodes,
|
||||
placementDataFromTolerations,
|
||||
pod
|
||||
);
|
||||
return placementDataFromAffinities;
|
||||
}
|
||||
|
||||
function computeTolerations(nodes: Node[], pod: Pod): NodePlacementRowData[] {
|
||||
const tolerations = pod.spec?.tolerations || [];
|
||||
const nodePlacements: NodePlacementRowData[] = nodes.map((node) => {
|
||||
let acceptsApplication = true;
|
||||
const unmetTaints: Taint[] = [];
|
||||
const taints = node.spec?.taints || [];
|
||||
taints.forEach((taint) => {
|
||||
const matchKeyMatchValueMatchEffect = _.find(tolerations, {
|
||||
key: taint.key,
|
||||
operator: 'Equal',
|
||||
value: taint.value,
|
||||
effect: taint.effect,
|
||||
});
|
||||
const matchKeyAnyValueMatchEffect = _.find(tolerations, {
|
||||
key: taint.key,
|
||||
operator: 'Exists',
|
||||
effect: taint.effect,
|
||||
});
|
||||
const matchKeyMatchValueAnyEffect = _.find(tolerations, {
|
||||
key: taint.key,
|
||||
operator: 'Equal',
|
||||
value: taint.value,
|
||||
effect: '',
|
||||
});
|
||||
const matchKeyAnyValueAnyEffect = _.find(tolerations, {
|
||||
key: taint.key,
|
||||
operator: 'Exists',
|
||||
effect: '',
|
||||
});
|
||||
const anyKeyAnyValueAnyEffect = _.find(tolerations, {
|
||||
key: '',
|
||||
operator: 'Exists',
|
||||
effect: '',
|
||||
});
|
||||
if (
|
||||
!matchKeyMatchValueMatchEffect &&
|
||||
!matchKeyAnyValueMatchEffect &&
|
||||
!matchKeyMatchValueAnyEffect &&
|
||||
!matchKeyAnyValueAnyEffect &&
|
||||
!anyKeyAnyValueAnyEffect
|
||||
) {
|
||||
acceptsApplication = false;
|
||||
unmetTaints?.push(taint);
|
||||
} else {
|
||||
acceptsApplication = true;
|
||||
}
|
||||
});
|
||||
return {
|
||||
name: node.metadata?.name || '',
|
||||
acceptsApplication,
|
||||
unmetTaints,
|
||||
highlighted: false,
|
||||
};
|
||||
});
|
||||
|
||||
return nodePlacements;
|
||||
}
|
||||
|
||||
// Node requirement depending on the operator value
|
||||
// https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity
|
||||
function computeAffinities(
|
||||
nodes: Node[],
|
||||
nodePlacements: NodePlacementRowData[],
|
||||
pod: Pod
|
||||
): NodePlacementRowData[] {
|
||||
const nodePlacementsFromAffinities: NodePlacementRowData[] = nodes.map(
|
||||
(node, nodeIndex) => {
|
||||
let { acceptsApplication } = nodePlacements[nodeIndex];
|
||||
|
||||
if (pod.spec?.nodeSelector) {
|
||||
const patch = JsonPatch.compare(
|
||||
node.metadata?.labels || {},
|
||||
pod.spec.nodeSelector
|
||||
);
|
||||
_.remove(patch, { op: 'remove' });
|
||||
const unmatchedNodeSelectorLabels = patch.map((operation) => ({
|
||||
key: _.trimStart(operation.path, '/'),
|
||||
value: operation.op,
|
||||
}));
|
||||
if (unmatchedNodeSelectorLabels.length) {
|
||||
acceptsApplication = false;
|
||||
}
|
||||
}
|
||||
|
||||
const basicNodeAffinity =
|
||||
pod.spec?.affinity?.nodeAffinity
|
||||
?.requiredDuringSchedulingIgnoredDuringExecution;
|
||||
if (basicNodeAffinity) {
|
||||
const unmatchedTerms = basicNodeAffinity.nodeSelectorTerms.map(
|
||||
(selectorTerm) => {
|
||||
const unmatchedExpressions = selectorTerm.matchExpressions?.flatMap(
|
||||
(matchExpression) => {
|
||||
const exists = {}.hasOwnProperty.call(
|
||||
node.metadata?.labels,
|
||||
matchExpression.key
|
||||
);
|
||||
const isIn =
|
||||
exists &&
|
||||
_.includes(
|
||||
matchExpression.values,
|
||||
node.metadata?.labels?.[matchExpression.key]
|
||||
);
|
||||
if (
|
||||
(matchExpression.operator === 'Exists' && exists) ||
|
||||
(matchExpression.operator === 'DoesNotExist' && !exists) ||
|
||||
(matchExpression.operator === 'In' && isIn) ||
|
||||
(matchExpression.operator === 'NotIn' && !isIn) ||
|
||||
(matchExpression.operator === 'Gt' &&
|
||||
exists &&
|
||||
parseInt(
|
||||
node.metadata?.labels?.[matchExpression.key] || '',
|
||||
10
|
||||
) > parseInt(matchExpression.values?.[0] || '', 10)) ||
|
||||
(matchExpression.operator === 'Lt' &&
|
||||
exists &&
|
||||
parseInt(
|
||||
node.metadata?.labels?.[matchExpression.key] || '',
|
||||
10
|
||||
) < parseInt(matchExpression.values?.[0] || '', 10))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [true];
|
||||
}
|
||||
);
|
||||
|
||||
return unmatchedExpressions;
|
||||
}
|
||||
);
|
||||
|
||||
_.remove(unmatchedTerms, (i) => i?.length === 0);
|
||||
if (unmatchedTerms.length) {
|
||||
acceptsApplication = false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...nodePlacements[nodeIndex],
|
||||
acceptsApplication,
|
||||
};
|
||||
}
|
||||
);
|
||||
return nodePlacementsFromAffinities;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue