1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 07:49:41 +02:00

refactor(app): placement form section [EE-6386] (#10818)
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run

Co-authored-by: testa113 <testa113>
This commit is contained in:
Ali 2024-01-03 11:00:50 +13:00 committed by GitHub
parent 2d77e71085
commit 9fc7187e24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 349 additions and 220 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
export { PlacementFormSection } from './PlacementFormSection';
export { placementsValidation as placementValidation } from './placementValidation';

View file

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

View file

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