Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MGMT-19152: Add network bonding configuration in the UI #2683

4 changes: 4 additions & 0 deletions libs/locales/lib/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,9 @@
"ai:Registering": "Registering",
"ai:Release domain resolution": "Release domain resolution",
"ai:Remove": "Remove",
"ai:Remove bond": "Remove bond",
"ai:Remove bond dialog": "Remove bond dialog",
"ai:Remove bond?": "Remove bond?",
"ai:Remove from the cluster": "Remove from the cluster",
"ai:Remove host": "Remove host",
"ai:Remove host?": "Remove host?",
Expand Down Expand Up @@ -749,6 +752,7 @@
"ai:Technology preview features provide early access to upcoming product innovations, enabling you to test functionality and provide feedback during the development process.": "Technology preview features provide early access to upcoming product innovations, enabling you to test functionality and provide feedback during the development process.",
"ai:The agent is not bound to a cluster.": "The agent is not bound to a cluster.",
"ai:The agent ran successfully": "The agent ran successfully",
"ai:The bond associated with the host will be removed.": "The bond associated with the host will be removed.",
"ai:The classic bullet-proof networking type": "The classic bullet-proof networking type",
"ai:The cluster can not be installed yet because there are no available hosts with {{cpuArchitecture}} architecture found. To continue:": "The cluster cannot be installed because there are no available hosts with {{cpuArchitecture}} architecture found. To continue:",
"ai:The cluster has 0 hosts. No workloads will be able to run.": "The cluster has 0 hosts. No workloads will be able to run.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ export type HostSummaryProps = {
numInterfaces: number;
hostIdx: number;
hasError: boolean;
bondPrimaryInterface: string;
bondSecondaryInterface: string;
};

const getLabelCollapsedHost = (
macAddress: string,
mappingValue: string,
bondPrimaryInterface: string,
bondSecondaryInterface: string,
) => {
if (bondPrimaryInterface !== '' && bondSecondaryInterface !== '') {
return `${bondPrimaryInterface}/${bondSecondaryInterface} -> ${mappingValue}`;
} else {
return `${macAddress} -> ${mappingValue}`;
}
};

const HostSummary: React.FC<HostSummaryProps> = ({
Expand All @@ -26,6 +41,8 @@ const HostSummary: React.FC<HostSummaryProps> = ({
numInterfaces,
hasError,
hostIdx,
bondPrimaryInterface,
bondSecondaryInterface,
}) => {
return (
<>
Expand All @@ -49,10 +66,14 @@ const HostSummary: React.FC<HostSummaryProps> = ({
{!hasError && (
<>
<FlexItem>
<Label
variant="outline"
data-testid="first-mapping-label"
>{`${macAddress} -> ${mappingValue}`}</Label>{' '}
<Label variant="outline" data-testid="first-mapping-label">
{getLabelCollapsedHost(
macAddress,
mappingValue,
bondPrimaryInterface,
bondSecondaryInterface,
)}
</Label>{' '}
</FlexItem>
{numInterfaces > 1 && (
<FlexItem>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as React from 'react';
import {
Button,
ButtonVariant,
Modal,
ModalBoxBody,
ModalBoxFooter,
Stack,
StackItem,
} from '@patternfly/react-core';

import { useTranslation } from '../../../../../../common/hooks/use-translation-wrapper';

type BondDeleteModalModalProps = {
isOpen: boolean;
onConfirm: VoidFunction;
onCancel: VoidFunction;
};

const BondDeleteModalModal = ({ isOpen, onConfirm, onCancel }: BondDeleteModalModalProps) => {
const { t } = useTranslation();

return (
<Modal
aria-label={t('ai:Remove bond dialog')}
title={t('ai:Remove bond?')}
isOpen={isOpen}
onClose={onCancel}
hasNoBodyWrapper
id="remove-bond-modal"
variant="medium"
titleIconVariant="warning"
>
<ModalBoxBody>
<Stack hasGutter>
<StackItem>{t('ai:The bond associated with the host will be removed.')}</StackItem>
</Stack>
</ModalBoxBody>
<ModalBoxFooter>
<Button onClick={onConfirm} variant={ButtonVariant.danger}>
{t('ai:Remove bond')}
</Button>
<Button onClick={onCancel} variant={ButtonVariant.secondary}>
{t('ai:Cancel')}
</Button>
</ModalBoxFooter>
</Modal>
);
};

export default BondDeleteModalModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import { SelectFieldProps } from '../../../../../../common/components/ui/formik/types';
import { SelectField } from '../../../../../../common';

type BondsSelectProps = {
onChange?: SelectFieldProps['onChange'];
name: string;
};

const bondsList = [
{ value: 'balance-rr', label: 'Balance-rr (0)', default: false },
{ value: 'active-backup', label: 'Active-Backup (1)', default: true },
{ value: 'balance-xor', label: 'Balance-xor (2)', default: false },
{ value: 'broadcast', label: 'Broadcast (3)', default: false },
{ value: '802.3ad', label: '802.3ad (4)', default: false },
{ value: 'balance-tlb', label: 'Balance-tlb (5)', default: false },
{ value: 'balance-alb', label: 'Balance-alb (6)', default: false },
];
const BondsSelect: React.FC<BondsSelectProps> = ({ onChange, name }) => {
const selectOptions = bondsList.map((version) => ({
label: version.label,
value: version.value,
}));
return (
<SelectField
name={name}
label="Bond type"
ammont82 marked this conversation as resolved.
Show resolved Hide resolved
options={selectOptions}
isRequired
onChange={onChange}
/>
);
};

export default BondsSelect;
Original file line number Diff line number Diff line change
@@ -1,38 +1,114 @@
import React from 'react';
import { FormGroup, Grid, TextContent, Text, TextVariants } from '@patternfly/react-core';
import { useField } from 'formik';
import { useField, useFormikContext } from 'formik';
import StaticIpHostsArray, { HostComponentProps } from '../StaticIpHostsArray';
import { getFieldId } from '../../../../../../common';
import { getFieldId, PopoverIcon } from '../../../../../../common';
import HostSummary from '../CollapsedHost';
import { FormViewHost, StaticProtocolType } from '../../data/dataTypes';
import { getProtocolVersionLabel, getShownProtocolVersions } from '../../data/protocolVersion';
import { getEmptyFormViewHost } from '../../data/emptyData';
import { OcmInputField } from '../../../../ui/OcmFormFields';
import { OcmCheckboxField, OcmInputField } from '../../../../ui/OcmFormFields';
import '../staticIp.css';
import BondsSelect from './BondsSelect';
import BondsConfirmationModal from './BondsConfirmationModal';

const getExpandedHostComponent = (protocolType: StaticProtocolType) => {
const Component: React.FC<HostComponentProps> = ({ fieldName, hostIdx }) => {
const [isModalOpen, setIsModalOpen] = React.useState<boolean>(false);
const { setFieldValue } = useFormikContext();
const [bondPrimaryField] = useField(`${fieldName}.bondPrimaryInterface`);
const [bondSecondaryField] = useField(`${fieldName}.bondSecondaryInterface`);
const [useBond] = useField(`${fieldName}.useBond`);

const handleUseBondChange = (checked: boolean) => {
if (!checked) {
ammont82 marked this conversation as resolved.
Show resolved Hide resolved
if (bondPrimaryField.value || bondSecondaryField.value) {
setIsModalOpen(true);
} else {
setFieldValue(`${fieldName}.useBond`, false);
setFieldValue(`${fieldName}.bondType`, 'active-backup');
setFieldValue(`${fieldName}.bondPrimaryInterface`, '');
setFieldValue(`${fieldName}.bondSecondaryInterface`, '');
}
}
};

const handleModalConfirm = () => {
setFieldValue(`${fieldName}.useBond`, false);
setFieldValue(`${fieldName}.bondType`, 'active-backup');
setFieldValue(`${fieldName}.bondPrimaryInterface`, '');
setFieldValue(`${fieldName}.bondSecondaryInterface`, '');
setIsModalOpen(false);
};

const handleModalCancel = () => {
setIsModalOpen(false);
};
return (
<Grid hasGutter>
<OcmInputField
name={`${fieldName}.macAddress`}
label="MAC Address"
isRequired
data-testid={`mac-address-${hostIdx}`}
/>
{getShownProtocolVersions(protocolType).map((protocolVersion) => (
<FormGroup
label={`IP address (${getProtocolVersionLabel(protocolVersion)})`}
fieldId={getFieldId(`${fieldName}.ips.${protocolVersion}`, 'input')}
key={protocolVersion}
>
<>
<Grid hasGutter>
<FormGroup>
<OcmCheckboxField
label={
<>
{'Use bond'}{' '}
<PopoverIcon
noVerticalAlign
bodyContent="Bonds help you to combine network interfaces for increased bandwidth and ensure redundancy. To bond more than 2 network interfaces per host, use the YAML view."
/>
</>
}
onChange={(value) => handleUseBondChange(value)}
name={`${fieldName}.useBond`}
/>
</FormGroup>
{useBond.value && (
<Grid hasGutter className="pf-v5-u-ml-lg">
<FormGroup fieldId={`bond-type-${hostIdx}`}>
<BondsSelect name={`${fieldName}.bondType`} data-testid={`bond-type-${hostIdx}`} />
</FormGroup>
<OcmInputField
name={`${fieldName}.bondPrimaryInterface`}
label="Port 1 MAC Address"
data-testid={`bond-primary-interface-${hostIdx}`}
isRequired
/>{' '}
<OcmInputField
name={`${fieldName}.bondSecondaryInterface`}
label="Port 2 MAC Adddress"
data-testid={`bond-secondary-interface-${hostIdx}`}
isRequired
/>
</Grid>
)}
{!useBond.value && (
<OcmInputField
name={`${fieldName}.ips.${protocolVersion}`}
name={`${fieldName}.macAddress`}
label="MAC Address"
isRequired
data-testid={`${protocolVersion}-address-${hostIdx}`}
/>{' '}
</FormGroup>
))}
</Grid>
data-testid={`mac-address-${hostIdx}`}
/>
)}
{getShownProtocolVersions(protocolType).map((protocolVersion) => (
<FormGroup
label={`IP address (${getProtocolVersionLabel(protocolVersion)})`}
fieldId={getFieldId(`${fieldName}.ips.${protocolVersion}`, 'input')}
key={protocolVersion}
>
<OcmInputField
name={`${fieldName}.ips.${protocolVersion}`}
isRequired
data-testid={`${protocolVersion}-address-${hostIdx}`}
/>{' '}
</FormGroup>
))}
</Grid>
<BondsConfirmationModal
isOpen={isModalOpen}
onConfirm={handleModalConfirm}
onCancel={handleModalCancel}
/>
</>
);
};
return Component;
Expand All @@ -47,14 +123,17 @@ const getCollapsedHostComponent = (protocolType: StaticProtocolType) => {
(protocolVersion) => value.ips[protocolVersion],
);
const mapValue = ipAddresses.join(', ');

return (
<HostSummary
title="MAC to IP mapping"
title={value.useBond ? 'Bonds to IP mapping' : 'MAC to IP mapping'}
numInterfaces={1}
macAddress={value.macAddress}
mappingValue={mapValue}
hostIdx={hostIdx}
hasError={!!error}
bondPrimaryInterface={value.bondPrimaryInterface}
bondSecondaryInterface={value.bondSecondaryInterface}
/>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,29 @@ const getAllIpv6Addresses: UniqueStringArrayExtractor<FormViewHostsValues> = (

const getAllMacAddresses: UniqueStringArrayExtractor<FormViewHostsValues> = (
values: FormViewHostsValues,
) => values.hosts.map((host) => host.macAddress);
) => {
return values.hosts.map((host) => host.macAddress);
};

const getAllBondInterfaces: UniqueStringArrayExtractor<FormViewHostsValues> = (
values: FormViewHostsValues,
) => {
return values.hosts.flatMap((host) => [
host.bondPrimaryInterface.toLowerCase(),
host.bondSecondaryInterface.toLowerCase(),
]);
};

const getHostValidationSchema = (networkWideValues: FormViewNetworkWideValues) =>
Yup.object({
macAddress: macAddressValidationSchema
.required(requiredMsg)
.concat(getUniqueValidationSchema(getAllMacAddresses)),
macAddress: Yup.mixed().when('useBond', {
is: false,
then: () =>
macAddressValidationSchema
.required(requiredMsg)
.concat(getUniqueValidationSchema(getAllMacAddresses)),
otherwise: () => Yup.mixed().notRequired(),
}),
ips: Yup.object({
ipv4: showIpv4(networkWideValues.protocolType)
? getInMachineNetworkValidationSchema(
Expand Down Expand Up @@ -63,6 +79,18 @@ const getHostValidationSchema = (networkWideValues: FormViewNetworkWideValues) =
)
: Yup.string(),
}),
bondPrimaryInterface: Yup.mixed().when('useBond', {
is: true,
then: () =>
macAddressValidationSchema.concat(getUniqueValidationSchema(getAllBondInterfaces)),
otherwise: () => Yup.mixed().notRequired(),
}),
bondSecondaryInterface: Yup.mixed().when('useBond', {
is: true,
then: () =>
macAddressValidationSchema.concat(getUniqueValidationSchema(getAllBondInterfaces)),
otherwise: () => Yup.mixed().notRequired(),
}),
});

export const getFormViewHostsValidationSchema = (networkWideValues: FormViewNetworkWideValues) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const CollapsedHost: React.FC<HostComponentProps> = ({ fieldName, hostIdx }) =>
mappingValue={mapValue}
hostIdx={hostIdx}
hasError={hasError}
bondPrimaryInterface=""
bondSecondaryInterface=""
/>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export type MachineNetworks = { [protocolVersion in ProtocolVersion]: string };
export type FormViewHost = {
macAddress: string;
ips: HostIps;
useBond: boolean;
bondType: string;
bondPrimaryInterface: string;
bondSecondaryInterface: string;
};

export type StaticFormData = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export const getEmptyFormViewHost = (): FormViewHost => {
return {
macAddress: '',
ips: getEmptyHostIps(),
useBond: false,
bondType: 'active-backup',
bondPrimaryInterface: '',
bondSecondaryInterface: '',
};
};

Expand Down
Loading
Loading