Skip to content

Commit

Permalink
MGMT-19152: Add network bonding configuration in the UI (#2683)
Browse files Browse the repository at this point in the history
* Add network bonding configuration in the UI

* Add networking bonding configuration in the UI

* Adjusting code

* mend

* Change bonds help text

* Adjusting form view validations

* Adjusting code from review comments

* Validate across all MAC addresses and not differentiate between primary and secondary ones

* When 'Use bond' is check we shouldn't have the extra mac-address field

* Changes in getHostValidationSchema

* Change collapsed view host in networking configuration
  • Loading branch information
ammont82 authored Nov 6, 2024
1 parent 29bbfef commit b7f8a32
Show file tree
Hide file tree
Showing 13 changed files with 371 additions and 46 deletions.
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"
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) {
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

0 comments on commit b7f8a32

Please sign in to comment.