mirror of https://github.com/portainer/portainer
refactor(app): placement form section [EE-6386] (#10818)
Co-authored-by: testa113 <testa113>pull/10908/head
parent
2d77e71085
commit
9fc7187e24
@ -1,30 +0,0 @@
|
||||
import { AlignJustify, Sliders } from 'lucide-react';
|
||||
|
||||
import { KubernetesApplicationPlacementTypes } from '@/kubernetes/models/application/models';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
export const placementOptions: ReadonlyArray<BoxSelectorOption<number>> = [
|
||||
{
|
||||
id: 'placement_hard',
|
||||
value: KubernetesApplicationPlacementTypes.MANDATORY,
|
||||
icon: Sliders,
|
||||
iconType: 'badge',
|
||||
label: 'Mandatory',
|
||||
description: (
|
||||
<>
|
||||
Schedule this application <b>ONLY</b> on nodes that match <b>ALL</b>{' '}
|
||||
Rules
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'placement_soft',
|
||||
value: KubernetesApplicationPlacementTypes.PREFERRED,
|
||||
icon: AlignJustify,
|
||||
iconType: 'badge',
|
||||
label: 'Preferred',
|
||||
description:
|
||||
'Schedule this application on nodes that match the rules if possible',
|
||||
},
|
||||
] as const;
|
@ -0,0 +1,140 @@
|
||||
import { FormikErrors } from 'formik';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useNodesQuery } from '@/react/kubernetes/cluster/HomeView/nodes.service';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { InputList } from '@@/form-components/InputList';
|
||||
|
||||
import { PlacementsFormValues, NodeLabels, Placement } from './types';
|
||||
import { PlacementItem } from './PlacementItem';
|
||||
import { PlacementTypeBoxSelector } from './PlacementTypeBoxSelector';
|
||||
|
||||
type Props = {
|
||||
values: PlacementsFormValues;
|
||||
onChange: (values: PlacementsFormValues) => void;
|
||||
errors?: FormikErrors<PlacementsFormValues>;
|
||||
};
|
||||
|
||||
export function PlacementFormSection({ values, onChange, errors }: Props) {
|
||||
// node labels are all of the unique node labels across all nodes
|
||||
const nodesLabels = useNodeLabels();
|
||||
// available node labels are the node labels that are not already in use by a placement
|
||||
const availableNodeLabels = useAvailableNodeLabels(
|
||||
nodesLabels,
|
||||
values.placements
|
||||
);
|
||||
const firstAvailableNodeLabel = Object.keys(availableNodeLabels)[0] || '';
|
||||
const firstAvailableNodeLabelValue =
|
||||
availableNodeLabels[firstAvailableNodeLabel]?.[0] || '';
|
||||
const nonDeletedPlacements = values.placements.filter(
|
||||
(placement) => !placement.needsDeletion
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<FormSection
|
||||
title="Placement preferences and constraints"
|
||||
titleSize="sm"
|
||||
titleClassName="control-label !text-[0.9em]"
|
||||
>
|
||||
{values.placements?.length > 0 && (
|
||||
<TextTip color="blue">
|
||||
Deploy this application on nodes that respect <b>ALL</b> of the
|
||||
following placement rules. Placement rules are based on node labels.
|
||||
</TextTip>
|
||||
)}
|
||||
<InputList
|
||||
value={values.placements}
|
||||
onChange={(placements) => onChange({ ...values, placements })}
|
||||
renderItem={(item, onChange, index, error) => (
|
||||
<PlacementItem
|
||||
item={item}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
index={index}
|
||||
nodesLabels={nodesLabels}
|
||||
availableNodeLabels={availableNodeLabels}
|
||||
/>
|
||||
)}
|
||||
itemBuilder={() => ({
|
||||
label: firstAvailableNodeLabel,
|
||||
value: firstAvailableNodeLabelValue,
|
||||
needsDeletion: false,
|
||||
})}
|
||||
errors={errors?.placements}
|
||||
addLabel="Add rule"
|
||||
canUndoDelete
|
||||
deleteButtonDataCy="k8sAppCreate-deletePlacementButton"
|
||||
disabled={Object.keys(availableNodeLabels).length === 0}
|
||||
addButtonError={
|
||||
Object.keys(availableNodeLabels).length === 0
|
||||
? 'There are no node labels available to add.'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</FormSection>
|
||||
{nonDeletedPlacements.length >= 1 && (
|
||||
<FormSection
|
||||
title="Placement policy"
|
||||
titleSize="sm"
|
||||
titleClassName="control-label !text-[0.9em]"
|
||||
>
|
||||
<TextTip color="blue">
|
||||
Specify the policy associated to the placement rules.
|
||||
</TextTip>
|
||||
<PlacementTypeBoxSelector
|
||||
placementType={values.placementType}
|
||||
onChange={(placementType) => onChange({ ...values, placementType })}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useAvailableNodeLabels(
|
||||
nodeLabels: NodeLabels,
|
||||
placements: Placement[]
|
||||
): NodeLabels {
|
||||
return useMemo(() => {
|
||||
const existingPlacementLabels = placements.map(
|
||||
(placement) => placement.label
|
||||
);
|
||||
const availableNodeLabels = Object.keys(nodeLabels).filter(
|
||||
(label) => !existingPlacementLabels.includes(label)
|
||||
);
|
||||
return availableNodeLabels.reduce((acc, label) => {
|
||||
acc[label] = nodeLabels[label];
|
||||
return acc;
|
||||
}, {} as NodeLabels);
|
||||
}, [nodeLabels, placements]);
|
||||
}
|
||||
|
||||
function useNodeLabels(): NodeLabels {
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: nodes } = useNodesQuery(environmentId);
|
||||
|
||||
// all node label pairs (some might have the same key but different values)
|
||||
const nodeLabelPairs =
|
||||
nodes?.flatMap((node) =>
|
||||
Object.entries(node.metadata?.labels || {}).map(([k, v]) => ({
|
||||
key: k,
|
||||
value: v,
|
||||
}))
|
||||
) || [];
|
||||
|
||||
// get unique node labels with each label's possible values
|
||||
const uniqueLabels = new Set(nodeLabelPairs.map((pair) => pair.key));
|
||||
// create a NodeLabels object with each label's possible values
|
||||
const nodesLabels = Array.from(uniqueLabels).reduce((acc, key) => {
|
||||
acc[key] = nodeLabelPairs
|
||||
.filter((pair) => pair.key === key)
|
||||
.map((pair) => pair.value);
|
||||
return acc;
|
||||
}, {} as NodeLabels);
|
||||
|
||||
return nodesLabels;
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { ItemProps } from '@@/form-components/InputList';
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { isErrorType } from '@@/form-components/formikUtils';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { NodeLabels, Placement } from './types';
|
||||
|
||||
interface PlacementItemProps extends ItemProps<Placement> {
|
||||
nodesLabels: NodeLabels;
|
||||
availableNodeLabels: NodeLabels;
|
||||
}
|
||||
|
||||
export function PlacementItem({
|
||||
onChange,
|
||||
item,
|
||||
error,
|
||||
index,
|
||||
nodesLabels,
|
||||
availableNodeLabels,
|
||||
}: PlacementItemProps) {
|
||||
const labelOptions = Object.keys(availableNodeLabels).map((label) => ({
|
||||
label,
|
||||
value: label,
|
||||
}));
|
||||
const valueOptions = nodesLabels[item.label]?.map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}));
|
||||
const placementError = isErrorType(error) ? error : undefined;
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex w-full gap-2">
|
||||
<div className="basis-1/2 grow">
|
||||
<Select
|
||||
options={labelOptions}
|
||||
value={{ label: item.label, value: item.label }}
|
||||
noOptionsMessage={() => 'No available node labels.'}
|
||||
onChange={(labelOption) => {
|
||||
const newValues = nodesLabels[labelOption?.value || ''];
|
||||
onChange({
|
||||
...item,
|
||||
value: newValues?.[0] || '',
|
||||
label: labelOption?.value || '',
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
className={clsx({ striked: !!item.needsDeletion })}
|
||||
isDisabled={!!item.needsDeletion}
|
||||
data-cy={`k8sAppCreate-placementLabel_${index}`}
|
||||
/>
|
||||
{placementError?.label && (
|
||||
<FormError>{placementError.label}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="basis-1/2 grow">
|
||||
<Select
|
||||
options={valueOptions}
|
||||
value={valueOptions?.find((option) => option.value === item.value)}
|
||||
onChange={(valueOption) =>
|
||||
onChange({ ...item, value: valueOption?.value || '' })
|
||||
}
|
||||
size="sm"
|
||||
className={clsx({ striked: !!item.needsDeletion })}
|
||||
isDisabled={!!item.needsDeletion}
|
||||
data-cy={`k8sAppCreate-placementName_${index}`}
|
||||
/>
|
||||
{placementError?.value && (
|
||||
<FormError>{placementError.value}</FormError>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
import { Sliders, AlignJustify } from 'lucide-react';
|
||||
|
||||
import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
import { PlacementType } from './types';
|
||||
|
||||
type Props = {
|
||||
placementType: PlacementType;
|
||||
onChange: (placementType: PlacementType) => void;
|
||||
};
|
||||
|
||||
export const placementOptions: ReadonlyArray<BoxSelectorOption<PlacementType>> =
|
||||
[
|
||||
{
|
||||
id: 'placement_hard',
|
||||
value: 'mandatory',
|
||||
icon: Sliders,
|
||||
iconType: 'badge',
|
||||
label: 'Mandatory',
|
||||
description: (
|
||||
<>
|
||||
Schedule this application <b>ONLY</b> on nodes that match <b>ALL</b>{' '}
|
||||
Rules
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'placement_soft',
|
||||
value: 'preferred',
|
||||
icon: AlignJustify,
|
||||
iconType: 'badge',
|
||||
label: 'Preferred',
|
||||
description:
|
||||
'Schedule this application on nodes that match the rules if possible',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function PlacementTypeBoxSelector({ placementType, onChange }: Props) {
|
||||
return (
|
||||
<BoxSelector<PlacementType>
|
||||
value={placementType}
|
||||
options={placementOptions}
|
||||
onChange={(placementType) => onChange(placementType)}
|
||||
radioName="placementType"
|
||||
slim
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export { PlacementFormSection } from './PlacementFormSection';
|
||||
export { placementsValidation as placementValidation } from './placementValidation';
|
@ -0,0 +1,16 @@
|
||||
import { SchemaOf, array, boolean, mixed, object, string } from 'yup';
|
||||
|
||||
import { PlacementsFormValues } from './types';
|
||||
|
||||
export function placementsValidation(): SchemaOf<PlacementsFormValues> {
|
||||
return object({
|
||||
placementType: mixed().oneOf(['mandatory', 'preferred']).required(),
|
||||
placements: array(
|
||||
object({
|
||||
label: string().required('Node label is required.'),
|
||||
value: string().required('Node value is required.'),
|
||||
needsDeletion: boolean(),
|
||||
}).required()
|
||||
),
|
||||
});
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
export type Placement = {
|
||||
label: string;
|
||||
value: string;
|
||||
needsDeletion?: boolean;
|
||||
};
|
||||
|
||||
export type PlacementType = 'mandatory' | 'preferred';
|
||||
|
||||
export type PlacementsFormValues = {
|
||||
placementType: PlacementType;
|
||||
placements: Placement[];
|
||||
};
|
||||
|
||||
export type NodeLabels = Record<string, string[]>;
|
Loading…
Reference in new issue