1
0
Fork 0
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:
Ali 2023-08-27 23:01:35 +02:00 committed by GitHub
parent 531f88b947
commit 841ca1ebd4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1448 additions and 810 deletions

View file

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

View file

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

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { NodePlacementRowData } from '../../types';
export const columnHelper = createColumnHelper<NodePlacementRowData>();

View file

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

View file

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

View file

@ -0,0 +1,5 @@
export { PlacementsDatatable } from './PlacementsDatatable';
export {
usePlacementTableState,
usePlacementTableData,
} from './usePlacementTableData';

View file

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