Local modifications to ClusterLabs/Anvil by Alteeve
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1691 lines
49 KiB

import {
Box as MUIBox,
BoxProps as MUIBoxProps,
iconButtonClasses as muiIconButtonClasses,
useMediaQuery,
useTheme,
} from '@mui/material';
import {
Add as MUIAddIcon,
Check as MUICheckIcon,
Close as MUICloseIcon,
DragHandle as MUIDragHandleIcon,
} from '@mui/icons-material';
import {
DataGrid as MUIDataGrid,
DataGridProps as MUIDataGridProps,
gridClasses as muiGridClasses,
} from '@mui/x-data-grid';
import { Netmask } from 'netmask';
import {
Dispatch,
FC,
forwardRef,
MutableRefObject,
SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { v4 as uuidv4 } from 'uuid';
import API_BASE_URL from '../lib/consts/API_BASE_URL';
import { BLUE, GREY } from '../lib/consts/DEFAULT_THEME';
import NETWORK_TYPES from '../lib/consts/NETWORK_TYPES';
import { REP_IPV4, REP_IPV4_CSV } from '../lib/consts/REG_EXP_PATTERNS';
import api from '../lib/api';
import BriefNetworkInterface from './BriefNetworkInterface';
import Decorator from './Decorator';
import DropArea from './DropArea';
import FlexBox from './FlexBox';
import handleAPIError from '../lib/handleAPIError';
import IconButton from './IconButton';
import InputWithRef, { InputForwardedRefContent } from './InputWithRef';
import { Message } from './MessageBox';
import MessageGroup, { MessageGroupForwardedRefContent } from './MessageGroup';
import OutlinedInputWithLabel from './OutlinedInputWithLabel';
import { InnerPanel, InnerPanelHeader } from './Panels';
import periodicFetch from '../lib/fetchers/periodicFetch';
import SelectWithLabel from './SelectWithLabel';
import Spinner from './Spinner';
import { createTestInputFunction, testNotBlank } from '../lib/test_input';
import { BodyText, MonoText, SmallText } from './Text';
type NetworkInput = {
inputUUID: string;
interfaces: (NetworkInterfaceOverviewMetadata | undefined)[];
ipAddress: string;
ipAddressInputRef?: MutableRefObject<InputForwardedRefContent<'string'>>;
isRequired?: boolean;
name?: string;
subnetMask: string;
subnetMaskInputRef?: MutableRefObject<InputForwardedRefContent<'string'>>;
type: string;
typeCount: number;
};
type NetworkInterfaceInputMap = Record<
string,
{
metadata: NetworkInterfaceOverviewMetadata;
isApplied?: boolean;
}
>;
type NetworkInitFormValues = {
dns?: string;
gateway?: string;
gatewayInterface?: string;
networks: Omit<NetworkInput, 'ipAddressInputRef' | 'subnetMaskInputRef'>[];
};
type NetworkInitFormForwardedRefContent = MessageGroupForwardedRefContent & {
get?: () => NetworkInitFormValues;
};
type GetNetworkTypeCountFunction = (
targetType: string,
options?: {
inputs?: NetworkInput[] | undefined;
lastIndex?: number | undefined;
},
) => number;
type TestInputToToggleSubmitDisabled = (
options?: Pick<
TestInputFunctionOptions,
'excludeTestIds' | 'excludeTestIdsRe' | 'inputs' | 'isContinueOnFailure'
>,
) => void;
const CLASS_PREFIX = 'NetworkInitForm';
const CLASSES = {
ifaceNotApplied: `${CLASS_PREFIX}-network-interface-not-applied`,
};
const INITIAL_IFACES = [undefined, undefined];
const MSG_ID_API = 'api';
const MAX_INTERFACES_PER_NETWORK = 2;
const IT_IDS = {
dnsCSV: 'dns',
gateway: 'gateway',
networkInterfaces: (prefix: string) => `${prefix}Interface`,
networkIPAddress: (prefix: string) => `${prefix}IPAddress`,
networkName: (prefix: string) => `${prefix}Name`,
networkSubnetMask: (prefix: string) => `${prefix}SubnetMask`,
networkSubnetConflict: (prefix: string) => `${prefix}NetworkSubnetConflict`,
};
const NETWORK_INTERFACE_TEMPLATE = Array.from(
{ length: MAX_INTERFACES_PER_NETWORK },
(unused, index) => index + 1,
);
const MAP_TO_NETWORK_TYPE_DEFAULTS: Record<
string,
{ ip: (sequence: number | string, postfix?: string) => string; mask: string }
> = {
bcn: {
ip: (sequence, postfix = '') => `10.20${sequence}.${postfix}`,
mask: '255.255.0.0',
},
ifn: { ip: () => '', mask: '' },
mn: { ip: () => '10.199.', mask: '255.255.0.0' },
sn: {
ip: (sequence, postfix = '') => `10.10${sequence}.${postfix}`,
mask: '255.255.0.0',
},
};
const createInputTestPrefix = (uuid: string) => `network${uuid}`;
const createNetworkInput = ({
inputUUID = uuidv4(),
interfaces = [...INITIAL_IFACES],
ipAddress = '',
name: initName,
subnetMask = '',
type = '',
typeCount = 0,
...rest
}: Partial<NetworkInput> = {}): NetworkInput => {
let name = initName;
if (!initName) {
if (NETWORK_TYPES[type] && typeCount > 0) {
name = `${NETWORK_TYPES[type]} ${typeCount}`;
} else {
name = 'Unknown Network';
}
}
return {
inputUUID,
interfaces,
ipAddress,
name,
subnetMask,
type,
typeCount,
...rest,
};
};
const createNetworkInterfaceTableColumns = (
handleDragMouseDown: (
row: NetworkInterfaceOverviewMetadata,
...eventArgs: Parameters<Exclude<MUIBoxProps['onMouseDown'], undefined>>
) => void,
networkInterfaceInputMap: NetworkInterfaceInputMap,
): MUIDataGridProps['columns'] => [
{
align: 'center',
field: '',
renderCell: ({ row }) => {
const { isApplied } =
networkInterfaceInputMap[row.networkInterfaceUUID] ?? false;
let cursor = 'grab';
let handleMouseDown: MUIBoxProps['onMouseDown'] = (...eventArgs) => {
handleDragMouseDown(row, ...eventArgs);
};
let icon = <MUIDragHandleIcon />;
if (isApplied) {
cursor = 'auto';
handleMouseDown = undefined;
icon = <MUICheckIcon sx={{ color: BLUE }} />;
}
return (
<MUIBox
onMouseDown={handleMouseDown}
sx={{
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
'&:hover': { cursor },
}}
>
{icon}
</MUIBox>
);
},
sortable: false,
width: 1,
},
{
field: 'networkInterfaceName',
flex: 1,
headerName: 'Name',
renderCell: ({ row: { networkInterfaceState } = {}, value }) => (
<MUIBox
sx={{
display: 'flex',
flexDirection: 'row',
'& > :not(:first-child)': { marginLeft: '.5em' },
}}
>
<Decorator
colour={networkInterfaceState === 'up' ? 'ok' : 'off'}
sx={{ height: 'auto' }}
/>
<SmallText text={value} />
</MUIBox>
),
},
{
field: 'networkInterfaceMACAddress',
flex: 1,
headerName: 'MAC',
renderCell: ({ value }) => <MonoText text={value} />,
},
{
field: 'networkInterfaceState',
flex: 1,
headerName: 'State',
renderCell: ({ value }) => {
const state = String(value);
return (
<SmallText
text={`${state.charAt(0).toUpperCase()}${state.substring(1)}`}
/>
);
},
},
{
field: 'networkInterfaceSpeed',
flex: 1,
headerName: 'Speed',
renderCell: ({ value }) => (
<SmallText text={`${parseFloat(value).toLocaleString()} Mbps`} />
),
},
{
field: 'networkInterfaceOrder',
flex: 1,
headerName: 'Order',
},
];
const NetworkForm: FC<{
allowMigrationNetwork?: boolean;
createDropMouseUpHandler?: (
interfaces: (NetworkInterfaceOverviewMetadata | undefined)[],
interfaceIndex: number,
) => MUIBoxProps['onMouseUp'];
getNetworkTypeCount: GetNetworkTypeCountFunction;
hostDetail?: Partial<Pick<APIHostDetail, 'hostType' | 'sequence'>>;
networkIndex: number;
networkInput: NetworkInput;
networkInterfaceCount: number;
networkInterfaceInputMap: NetworkInterfaceInputMap;
removeNetwork: (index: number) => void;
setMessageRe: (re: RegExp, message?: Message) => void;
setNetworkInputs: Dispatch<SetStateAction<NetworkInput[]>>;
setNetworkInterfaceInputMap: Dispatch<
SetStateAction<NetworkInterfaceInputMap>
>;
testInput: (options?: TestInputFunctionOptions) => boolean;
testInputToToggleSubmitDisabled: TestInputToToggleSubmitDisabled;
}> = ({
allowMigrationNetwork,
createDropMouseUpHandler,
getNetworkTypeCount,
hostDetail: { hostType, sequence } = {},
networkIndex,
networkInput,
networkInterfaceCount,
networkInterfaceInputMap,
removeNetwork,
setMessageRe,
setNetworkInputs,
setNetworkInterfaceInputMap,
testInput,
testInputToToggleSubmitDisabled,
}) => {
const theme = useTheme();
const breakpointMedium = useMediaQuery(theme.breakpoints.up('md'));
const breakpointLarge = useMediaQuery(theme.breakpoints.up('lg'));
const ipAddressInputRef = useRef<InputForwardedRefContent<'string'>>({});
const subnetMaskInputRef = useRef<InputForwardedRefContent<'string'>>({});
const {
inputUUID,
interfaces,
ipAddress,
isRequired,
subnetMask,
type,
typeCount,
} = networkInput;
const inputTestPrefix = useMemo(
() => createInputTestPrefix(inputUUID),
[inputUUID],
);
const interfacesInputTestId = useMemo(
() => IT_IDS.networkInterfaces(inputTestPrefix),
[inputTestPrefix],
);
const ipAddressInputTestId = useMemo(
() => IT_IDS.networkIPAddress(inputTestPrefix),
[inputTestPrefix],
);
const subnetMaskInputTestId = useMemo(
() => IT_IDS.networkSubnetMask(inputTestPrefix),
[inputTestPrefix],
);
const subnetConflictInputMessageKeyPrefix = useMemo(
() => IT_IDS.networkSubnetConflict(inputTestPrefix),
[inputTestPrefix],
);
const isNode = useMemo(() => hostType === 'node', [hostType]);
const netIfTemplate = useMemo(
() =>
!isNode && networkInterfaceCount <= 2 ? [1] : NETWORK_INTERFACE_TEMPLATE,
[isNode, networkInterfaceCount],
);
const netTypeList = useMemo(() => {
const { bcn, ifn, mn, sn } = NETWORK_TYPES;
return isNode &&
networkInterfaceCount >= 8 &&
(allowMigrationNetwork || type === 'mn')
? { bcn, ifn, mn, sn }
: { bcn, ifn, sn };
}, [allowMigrationNetwork, isNode, networkInterfaceCount, type]);
const setIpAndMask = useCallback(
(nInput: NetworkInput, ip: string, mask: string) => {
const {
current: { getIsChangedByUser: getIpModded, setValue: setIp },
} = ipAddressInputRef;
const {
current: { getIsChangedByUser: getMaskModded, setValue: setMask },
} = subnetMaskInputRef;
if (!getIpModded?.call(null)) {
nInput.ipAddress = ip;
setIp?.call(null, ip);
}
if (!getMaskModded?.call(null)) {
nInput.subnetMask = mask;
setMask?.call(null, mask);
}
},
[],
);
useEffect((): void => {
if (hostType !== 'striker' || type === 'ifn') return;
const changedByUser =
ipAddressInputRef.current.getIsChangedByUser?.call(null);
if (changedByUser || !Number(sequence)) return;
ipAddressInputRef.current.setValue?.call(
null,
ipAddress.replace(/^((?:\d+\.){3})\d*$/, `$1${sequence}`),
);
}, [hostType, ipAddress, sequence, type]);
useEffect(() => {
const { ipAddressInputRef: ipRef, subnetMaskInputRef: maskRef } =
networkInput;
if (ipRef !== ipAddressInputRef || maskRef !== subnetMaskInputRef) {
networkInput.ipAddressInputRef = ipAddressInputRef;
networkInput.subnetMaskInputRef = subnetMaskInputRef;
setNetworkInputs((previous) => [...previous]);
}
}, [networkInput, setNetworkInputs]);
return (
<InnerPanel>
<InnerPanelHeader>
<SelectWithLabel
id={`network-${inputUUID}-name`}
isReadOnly={isRequired}
inputLabelProps={{ isNotifyRequired: true }}
label="Network name"
selectItems={Object.entries(netTypeList).map(
([networkType, networkTypeName]) => {
let count = getNetworkTypeCount(networkType, {
lastIndex: networkIndex,
});
if (networkType !== type) {
count += 1;
}
const displayValue = `${networkTypeName} ${count}`;
return { value: networkType, displayValue };
},
)}
selectProps={{
onChange: ({ target: { value } }) => {
const networkType = String(value);
networkInput.type = networkType;
const networkTypeCount = getNetworkTypeCount(networkType, {
lastIndex: networkIndex,
});
networkInput.typeCount = networkTypeCount;
networkInput.name = `${NETWORK_TYPES[networkType]} ${networkTypeCount}`;
const networkTypeDefaults =
MAP_TO_NETWORK_TYPE_DEFAULTS[networkType];
if (networkTypeDefaults) {
const { ip, mask } = networkTypeDefaults;
let postfix: string | undefined;
if (hostType === 'striker' && networkType === 'bcn') {
postfix = '4.';
}
setIpAndMask(networkInput, ip(networkTypeCount, postfix), mask);
}
setNetworkInputs((previous) => [...previous]);
},
renderValue: breakpointLarge
? undefined
: (value) => `${String(value).toUpperCase()} ${typeCount}`,
value: type,
}}
/>
{!isRequired && (
<IconButton
onClick={() => {
removeNetwork(networkIndex);
}}
sx={{
padding: '.2em',
position: 'absolute',
right: '-9px',
top: '-4px',
}}
>
<MUICloseIcon fontSize="small" />
</IconButton>
)}
</InnerPanelHeader>
<MUIBox
sx={{
display: 'flex',
flexDirection: 'column',
margin: '.6em',
'& > :not(:first-child)': {
marginTop: '1em',
},
}}
>
{netIfTemplate.map((linkNumber) => {
const linkName = `Link ${linkNumber}`;
const networkInterfaceIndex = linkNumber - 1;
const networkInterface = interfaces[networkInterfaceIndex];
const { networkInterfaceUUID = '' } = networkInterface ?? {};
const emptyDropAreaContent = breakpointMedium ? (
<BodyText text="Drop to add interface." />
) : (
<MUIAddIcon
sx={{
alignSelf: 'center',
color: GREY,
}}
/>
);
return (
<MUIBox
key={`network-${inputUUID}-link-${linkNumber}`}
sx={{
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
'& > :not(:first-child)': {
marginLeft: '1em',
},
'& > :last-child': {
flexGrow: 1,
},
}}
>
<BodyText sx={{ whiteSpace: 'nowrap' }} text={linkName} />
<DropArea
onMouseUp={(...args) => {
createDropMouseUpHandler
?.call(null, interfaces, networkInterfaceIndex)
?.call(null, ...args);
testInputToToggleSubmitDisabled({
inputs: {
[interfacesInputTestId]: {
isIgnoreOnCallbacks: false,
},
},
isContinueOnFailure: true,
});
}}
>
{networkInterface ? (
<BriefNetworkInterface
key={`network-interface-${networkInterfaceUUID}`}
networkInterface={networkInterface}
onClose={() => {
interfaces[networkInterfaceIndex] = undefined;
networkInterfaceInputMap[networkInterfaceUUID].isApplied =
false;
setNetworkInterfaceInputMap((previous) => ({
...previous,
}));
testInputToToggleSubmitDisabled({
inputs: {
[interfacesInputTestId]: {
isIgnoreOnCallbacks: false,
},
},
isContinueOnFailure: true,
});
}}
/>
) : (
emptyDropAreaContent
)}
</DropArea>
</MUIBox>
);
})}
<InputWithRef
input={
<OutlinedInputWithLabel
id={`network-${inputUUID}-ip-address`}
inputProps={{
onBlur: ({ target: { value } }) => {
testInput({ inputs: { [ipAddressInputTestId]: { value } } });
},
}}
inputLabelProps={{ isNotifyRequired: true }}
label="IP address"
onChange={({ target: { value } }) => {
testInputToToggleSubmitDisabled({
inputs: { [ipAddressInputTestId]: { value } },
});
setMessageRe(
RegExp(
`(?:^(?:${ipAddressInputTestId}|${subnetConflictInputMessageKeyPrefix})|${inputUUID}$)`,
),
);
}}
value={ipAddress}
/>
}
ref={ipAddressInputRef}
/>
<InputWithRef
input={
<OutlinedInputWithLabel
id={`network-${inputUUID}-subnet-mask`}
inputProps={{
onBlur: ({ target: { value } }) => {
testInput({ inputs: { [subnetMaskInputTestId]: { value } } });
},
}}
inputLabelProps={{ isNotifyRequired: true }}
label="Subnet mask"
onChange={({ target: { value } }) => {
testInputToToggleSubmitDisabled({
inputs: { [subnetMaskInputTestId]: { value } },
});
setMessageRe(
RegExp(
`(?:^(?:${subnetMaskInputTestId}|${subnetConflictInputMessageKeyPrefix})|${inputUUID}$)`,
),
);
}}
value={subnetMask}
/>
}
ref={subnetMaskInputRef}
/>
</MUIBox>
</InnerPanel>
);
};
NetworkForm.defaultProps = {
allowMigrationNetwork: true,
createDropMouseUpHandler: undefined,
hostDetail: undefined,
};
const NetworkInitForm = forwardRef<
NetworkInitFormForwardedRefContent,
{
expectHostDetail?: boolean;
hostDetail?: APIHostDetail;
hostSequence?: string;
toggleSubmitDisabled?: (testResult: boolean) => void;
}
>(
(
{
expectHostDetail = false,
hostDetail,
hostSequence,
toggleSubmitDisabled,
},
ref,
) => {
let hostType: string | undefined;
let hostUUID = 'local';
let sequence = hostSequence;
if (!expectHostDetail) {
hostType = 'striker';
} else if (hostDetail) {
({ hostType, hostUUID, sequence } = hostDetail);
}
const initRequiredNetworks: NetworkInput[] = useMemo(() => {
const result: NetworkInput[] = [];
if (hostType === 'striker') {
const ipAddress = sequence ? `10.201.4.${sequence}` : '10.201.4.';
result.push(
createNetworkInput({
ipAddress,
isRequired: true,
subnetMask: '255.255.0.0',
type: 'bcn',
typeCount: 1,
}),
createNetworkInput({
isRequired: true,
type: 'ifn',
typeCount: 1,
}),
);
return result;
}
result.push(
createNetworkInput({
ipAddress: '10.201.',
isRequired: true,
subnetMask: '255.255.0.0',
type: 'bcn',
typeCount: 1,
}),
createNetworkInput({
isRequired: true,
type: 'ifn',
typeCount: 1,
}),
createNetworkInput({
ipAddress: '10.101.',
isRequired: true,
subnetMask: '255.255.0.0',
type: 'sn',
typeCount: 1,
}),
);
return result;
}, [hostType, sequence]);
const requiredNetworks = useMemo<Partial<Record<NetworkType, number>>>(
() =>
hostType === 'node' ? { bcn: 1, ifn: 1, sn: 1 } : { bcn: 1, ifn: 1 },
[hostType],
);
const [dragMousePosition, setDragMousePosition] = useState<{
x: number;
y: number;
}>({ x: 0, y: 0 });
const [networkInterfaceInputMap, setNetworkInterfaceInputMap] =
useState<NetworkInterfaceInputMap>({});
const [networkInputs, setNetworkInputs] =
useState<NetworkInput[]>(initRequiredNetworks);
const [networkInterfaceHeld, setNetworkInterfaceHeld] = useState<
NetworkInterfaceOverviewMetadata | undefined
>();
const [gatewayInterface, setGatewayInterface] = useState<string>('');
const dnsCSVInputRef = useRef<InputForwardedRefContent<'string'>>({});
const gatewayInputRef = useRef<InputForwardedRefContent<'string'>>({});
/** Avoid state here to prevent triggering multiple renders when reading
* host detail. */
const readHostDetailRef = useRef<boolean>(true);
const messageGroupRef = useRef<MessageGroupForwardedRefContent>({});
const {
data: networkInterfaces = [],
isLoading: isLoadingNetworkInterfaces,
} = periodicFetch<NetworkInterfaceOverviewMetadata[]>(
`${API_BASE_URL}/init/network-interface/${hostUUID}`,
{
refreshInterval: 2000,
onSuccess: (data) => {
const map = data.reduce<NetworkInterfaceInputMap>(
(result, metadata) => {
const { networkInterfaceUUID } = metadata;
result[networkInterfaceUUID] = networkInterfaceInputMap[
networkInterfaceUUID
] ?? { metadata };
return result;
},
{},
);
setNetworkInterfaceInputMap(map);
},
},
);
const isDisableAddNetworkButton: boolean = useMemo(
() =>
networkInputs.length >= networkInterfaces.length ||
Object.values(networkInterfaceInputMap).every(
({ isApplied }) => isApplied,
) ||
(hostType === 'node' && networkInterfaces.length <= 6),
[hostType, networkInputs, networkInterfaces, networkInterfaceInputMap],
);
const isLoadingHostDetail: boolean = useMemo(
() => expectHostDetail && !hostDetail,
[expectHostDetail, hostDetail],
);
/**
* Allow user to add migration network only if none exists.
*/
const allowMigrationNetwork: boolean = useMemo(
() => networkInputs.every(({ type }) => type !== 'mn'),
[networkInputs],
);
const setMessage = useCallback(
(key: string, message?: Message) =>
messageGroupRef.current.setMessage?.call(null, key, message),
[],
);
const setMessageRe = useCallback(
(re: RegExp, message?: Message) =>
messageGroupRef.current.setMessageRe?.call(null, re, message),
[],
);
const setDnsInputMessage = useCallback(
(message?: Message) => setMessage(IT_IDS.dnsCSV, message),
[setMessage],
);
const setGatewayInputMessage = useCallback(
(message?: Message) => setMessage(IT_IDS.gateway, message),
[setMessage],
);
const subnetContains = useCallback(
({
fn = 'every',
ip = '',
mask = '',
isNegateMatch = fn === 'every',
onMatch,
onMiss,
skipUUID,
}: {
fn?: Extract<keyof Array<NetworkInput>, 'every' | 'some'>;
ip?: string;
isNegateMatch?: boolean;
mask?: string;
onMatch?: (otherInput: NetworkInput) => void;
onMiss?: (otherInput: NetworkInput) => void;
skipUUID?: string;
}) => {
const skipReturn = fn === 'every';
const match = (
a: Netmask,
{ b, bIP = '' }: { aIP?: string; b?: Netmask; bIP?: string },
) => a.contains(b ?? bIP) || (b !== undefined && b.contains(a));
let subnet: Netmask | undefined;
try {
subnet = new Netmask(`${ip}/${mask}`);
// TODO: find a way to express the netmask creation error
// eslint-disable-next-line no-empty
} catch (netmaskError) {}
return networkInputs[fn]((networkInput) => {
const { inputUUID, ipAddressInputRef, subnetMaskInputRef } =
networkInput;
if (inputUUID === skipUUID) {
return skipReturn;
}
const otherIP = ipAddressInputRef?.current.getValue?.call(null);
const otherMask = subnetMaskInputRef?.current.getValue?.call(null);
let isMatch = false;
try {
const otherSubnet = new Netmask(`${otherIP}/${otherMask}`);
isMatch = match(otherSubnet, { b: subnet, bIP: ip });
// TODO: find a way to express the netmask creation error
// eslint-disable-next-line no-empty
} catch (netmaskError) {}
if (isMatch) {
onMatch?.call(null, networkInput);
} else {
onMiss?.call(null, networkInput);
}
return isNegateMatch ? !isMatch : isMatch;
});
},
[networkInputs],
);
const setMapNetwork = useCallback(
(value: 0 | 1) => {
api.put('/init/set-map-network', { value }).catch((error) => {
const emsg = handleAPIError(error);
emsg.children = (
<>
Failed to {value ? 'enable' : 'disable'} network mapping.{' '}
{emsg.children}
</>
);
setMessage(MSG_ID_API, emsg);
});
},
[setMessage],
);
const inputTests: InputTestBatches = useMemo(() => {
const tests: InputTestBatches = {
[IT_IDS.dnsCSV]: {
defaults: {
getValue: () => dnsCSVInputRef.current.getValue?.call(null),
onSuccess: () => {
setDnsInputMessage();
},
},
tests: [
{
onFailure: () => {
setDnsInputMessage({
children:
'Domain name servers should be a comma-separated list of IPv4 addresses without trailing comma(s).',
});
},
test: ({ value }) => REP_IPV4_CSV.test(value as string),
},
{ test: testNotBlank },
],
},
[IT_IDS.gateway]: {
defaults: {
getValue: () => gatewayInputRef.current.getValue?.call(null),
onSuccess: () => {
setGatewayInputMessage();
},
},
tests: [
{
onFailure: () => {
setGatewayInputMessage({
children: 'Gateway should be a valid IPv4 address.',
});
},
test: ({ value }) => REP_IPV4.test(value as string),
},
{
test: ({ value }) => {
let isDistinctIP = true;
const isIPInOneNetwork = subnetContains({
fn: 'some',
ip: value as string,
onMatch: ({ ipAddress, name, type, typeCount }) => {
if (value === ipAddress) {
isDistinctIP = false;
setGatewayInputMessage({
children: `Gateway cannot be the same as IP address in ${name}.`,
});
return;
}
setGatewayInterface(`${type}${typeCount}`);
},
});
if (!isIPInOneNetwork) {
setGatewayInputMessage({
children: "Gateway must be in one network's subnet.",
});
}
return isIPInOneNetwork && isDistinctIP;
},
},
{ test: testNotBlank },
],
},
};
networkInputs.forEach(
({
inputUUID,
interfaces,
ipAddressInputRef,
name,
subnetMaskInputRef,
}) => {
const inputTestPrefix = createInputTestPrefix(inputUUID);
const inputTestIDIfaces = IT_IDS.networkInterfaces(inputTestPrefix);
const inputTestIDIPAddress = IT_IDS.networkIPAddress(inputTestPrefix);
const inputTestIDSubnetMask =
IT_IDS.networkSubnetMask(inputTestPrefix);
const setNetworkIfacesInputMessage = (message?: Message) =>
setMessage(inputTestIDIfaces, message);
const setNetworkIPAddressInputMessage = (message?: Message) =>
setMessage(inputTestIDIPAddress, message);
const setNetworkSubnetMaskInputMessage = (message?: Message) =>
setMessage(inputTestIDSubnetMask, message);
const setNetworkSubnetConflictInputMessage = (
uuid: string,
otherUUID: string,
message?: Message,
) => {
const id = `${IT_IDS.networkSubnetConflict(
inputTestPrefix,
)}-${otherUUID}`;
const reverseID = `${IT_IDS.networkSubnetConflict(
createInputTestPrefix(otherUUID),
)}-${uuid}`;
setMessage(
messageGroupRef.current.exists?.call(null, reverseID)
? reverseID
: id,
message,
);
};
const testNetworkSubnetConflictWithDefaults = ({
ip = ipAddressInputRef?.current.getValue?.call(null),
mask = subnetMaskInputRef?.current.getValue?.call(null),
}: {
ip?: string;
mask?: string;
}) =>
subnetContains({
ip,
mask,
onMatch: ({ inputUUID: otherUUID, name: otherName }) => {
setNetworkSubnetConflictInputMessage(inputUUID, otherUUID, {
children: `"${name}" and "${otherName}" cannot be in the same subnet.`,
});
},
onMiss: ({ inputUUID: otherUUID }) => {
setNetworkSubnetConflictInputMessage(inputUUID, otherUUID);
},
skipUUID: inputUUID,
});
tests[inputTestIDIfaces] = {
defaults: {
getCompare: () => interfaces.map((iface) => iface !== undefined),
onSuccess: () => {
setNetworkIfacesInputMessage();
},
},
tests: [
{
onFailure: () => {
setNetworkIfacesInputMessage({
children: `${name} must have at least 1 interface.`,
});
},
test: ({ compare }) =>
(compare as boolean[]).some((ifaceSet) => ifaceSet),
},
{
onFailure: () => {
setNetworkIfacesInputMessage({
children: `${name} must have a Link 1 interface.`,
});
},
test: ({ compare: [iface1Exists, iface2Exists] }) =>
!(iface2Exists && !iface1Exists),
},
],
};
tests[inputTestIDIPAddress] = {
defaults: {
getValue: () => ipAddressInputRef?.current.getValue?.call(null),
onSuccess: () => {
setNetworkIPAddressInputMessage();
},
},
tests: [
{
onFailure: () => {
setNetworkIPAddressInputMessage({
children: `IP address in ${name} must be a valid IPv4 address.`,
});
},
test: ({ value }) => REP_IPV4.test(value as string),
},
{
test: ({ value }) =>
testNetworkSubnetConflictWithDefaults({
ip: value as string,
}),
},
{ test: testNotBlank },
],
};
tests[IT_IDS.networkName(inputTestPrefix)] = {
defaults: { value: name },
tests: [{ test: testNotBlank }],
};
tests[inputTestIDSubnetMask] = {
defaults: {
getValue: () => subnetMaskInputRef?.current.getValue?.call(null),
onSuccess: () => {
setNetworkSubnetMaskInputMessage();
},
},
tests: [
{
onFailure: () => {
setNetworkSubnetMaskInputMessage({
children: `Subnet mask in ${name} must be a valid IPv4 address.`,
});
},
test: ({ value }) => REP_IPV4.test(value as string),
},
{
test: ({ value }) =>
testNetworkSubnetConflictWithDefaults({
mask: value as string,
}),
},
{ test: testNotBlank },
],
};
},
);
return tests;
}, [
networkInputs,
setDnsInputMessage,
setGatewayInputMessage,
setMessage,
subnetContains,
]);
const testInput = useMemo(
() => createTestInputFunction(inputTests),
[inputTests],
);
const testInputToToggleSubmitDisabled: TestInputToToggleSubmitDisabled =
useCallback(
(options) => {
toggleSubmitDisabled?.call(
null,
testInput({
isIgnoreOnCallbacks: true,
isTestAll: true,
...options,
}),
);
},
[testInput, toggleSubmitDisabled],
);
const clearNetworkInterfaceHeld = useCallback(() => {
setNetworkInterfaceHeld(undefined);
}, []);
const createNetwork = useCallback(
(args: Partial<NetworkInput> = {}) => {
networkInputs.unshift(createNetworkInput(args));
toggleSubmitDisabled?.call(null, false);
setNetworkInputs([...networkInputs]);
},
[networkInputs, toggleSubmitDisabled],
);
const removeNetwork = useCallback(
(networkIndex: number) => {
const [{ inputUUID, interfaces }] = networkInputs.splice(
networkIndex,
1,
);
interfaces.forEach((iface) => {
if (iface === undefined) {
return;
}
const { networkInterfaceUUID } = iface;
networkInterfaceInputMap[networkInterfaceUUID].isApplied = false;
});
testInputToToggleSubmitDisabled({
excludeTestIdsRe: RegExp(inputUUID),
});
setNetworkInputs([...networkInputs]);
setNetworkInterfaceInputMap((previous) => ({
...previous,
}));
},
[
networkInputs,
networkInterfaceInputMap,
testInputToToggleSubmitDisabled,
],
);
const getNetworkTypeCount: GetNetworkTypeCountFunction = useCallback(
(
targetType: string,
{
inputs = networkInputs,
lastIndex = 0,
}: {
inputs?: NetworkInput[];
lastIndex?: number;
} = {},
) => {
let count = 0;
for (let index = inputs.length - 1; index >= lastIndex; index -= 1) {
if (inputs[index].type === targetType) {
count += 1;
}
}
return count;
},
[networkInputs],
);
const createDropMouseUpHandler:
| ((
interfaces: (NetworkInterfaceOverviewMetadata | undefined)[],
interfaceIndex: number,
) => MUIBoxProps['onMouseUp'])
| undefined = useMemo(() => {
if (networkInterfaceHeld === undefined) {
return undefined;
}
const { networkInterfaceUUID } = networkInterfaceHeld;
return (
interfaces: (NetworkInterfaceOverviewMetadata | undefined)[],
interfaceIndex: number,
) =>
() => {
const { networkInterfaceUUID: previousNetworkInterfaceUUID } =
interfaces[interfaceIndex] ?? {};
if (
previousNetworkInterfaceUUID &&
previousNetworkInterfaceUUID !== networkInterfaceUUID
) {
networkInterfaceInputMap[previousNetworkInterfaceUUID].isApplied =
false;
}
interfaces[interfaceIndex] = networkInterfaceHeld;
networkInterfaceInputMap[networkInterfaceUUID].isApplied = true;
};
}, [networkInterfaceHeld, networkInterfaceInputMap]);
const dragAreaDraggingSx: MUIBoxProps['sx'] = useMemo(
() =>
networkInterfaceHeld ? { cursor: 'grabbing', userSelect: 'none' } : {},
[networkInterfaceHeld],
);
const floatingNetworkInterface: JSX.Element = useMemo(() => {
if (networkInterfaceHeld === undefined) {
return <></>;
}
const { x, y } = dragMousePosition;
return (
<BriefNetworkInterface
isFloating
networkInterface={networkInterfaceHeld}
sx={{
left: `calc(${x}px + .4em)`,
position: 'absolute',
top: `calc(${y}px - 1.6em)`,
zIndex: 20,
}}
/>
);
}, [dragMousePosition, networkInterfaceHeld]);
const handleDragAreaMouseLeave: MUIBoxProps['onMouseLeave'] = useMemo(
() =>
networkInterfaceHeld
? () => {
clearNetworkInterfaceHeld();
}
: undefined,
[clearNetworkInterfaceHeld, networkInterfaceHeld],
);
const handleDragAreaMouseMove: MUIBoxProps['onMouseMove'] = useMemo(
() =>
networkInterfaceHeld
? ({ currentTarget, nativeEvent: { clientX, clientY } }) => {
const { left, top } = currentTarget.getBoundingClientRect();
setDragMousePosition({
x: clientX - left,
y: clientY - top,
});
}
: undefined,
[networkInterfaceHeld],
);
const handleDragAreaMouseUp: MUIBoxProps['onMouseUp'] = useMemo(
() =>
networkInterfaceHeld
? () => {
clearNetworkInterfaceHeld();
}
: undefined,
[clearNetworkInterfaceHeld, networkInterfaceHeld],
);
useEffect(() => {
if (
[
Object.keys(networkInterfaceInputMap).length > 0,
expectHostDetail,
hostDetail,
readHostDetailRef.current,
dnsCSVInputRef.current,
gatewayInputRef.current,
].every((condition) => Boolean(condition))
) {
readHostDetailRef.current = false;
const {
dns: pDns,
gateway: pGateway,
gatewayInterface: pGatewayInterface,
networks: pNetworks,
} = hostDetail as APIHostDetail;
if (
[pDns, pGateway, pGatewayInterface, pNetworks].some(
(condition) => !condition,
)
) {
return;
}
dnsCSVInputRef.current.setValue?.call(null, pDns);
gatewayInputRef.current.setValue?.call(null, pGateway);
const applied: string[] = [];
const inputs = Object.values(pNetworks as APIHostNetworkList).reduce<
NetworkInput[]
>((previous, { ip, link1Uuid, link2Uuid = '', subnetMask, type }) => {
const typeCount = getNetworkTypeCount(type, { inputs: previous }) + 1;
const isRequired = requiredNetworks[type] === typeCount;
const name = `${NETWORK_TYPES[type]} ${typeCount}`;
applied.push(link1Uuid, link2Uuid);
previous.push({
inputUUID: uuidv4(),
interfaces: [
networkInterfaceInputMap[link1Uuid]?.metadata,
networkInterfaceInputMap[link2Uuid]?.metadata,
],
ipAddress: ip,
isRequired,
name,
subnetMask,
type,
typeCount,
});
return previous;
}, []);
setGatewayInterface(pGatewayInterface as string);
setNetworkInterfaceInputMap((previous) => {
const result = { ...previous };
applied.forEach((uuid) => {
if (result[uuid]) {
result[uuid].isApplied = true;
}
});
return result;
});
setNetworkInputs(inputs);
testInputToToggleSubmitDisabled();
}
}, [
expectHostDetail,
getNetworkTypeCount,
hostDetail,
networkInterfaceInputMap,
requiredNetworks,
testInputToToggleSubmitDisabled,
]);
useEffect(() => {
// Enable network mapping on component mount.
setMapNetwork(1);
if (window) {
window.addEventListener(
'beforeunload',
() => {
// Cannot use async request (i.e., axios) because they won't be guaranteed to complete.
const request = new XMLHttpRequest();
request.open('PUT', `${API_BASE_URL}/init/set-map-network`, false);
request.send(null);
},
{ once: true },
);
}
return () => {
// Disable network mapping on component unmount.
setMapNetwork(0);
};
}, [setMapNetwork]);
useImperativeHandle(
ref,
() => ({
...messageGroupRef.current,
get: () => ({
dns: dnsCSVInputRef.current.getValue?.call(null),
gateway: gatewayInputRef.current.getValue?.call(null),
gatewayInterface,
networks: networkInputs.map(
({
inputUUID,
interfaces,
ipAddressInputRef,
name,
subnetMaskInputRef,
type,
typeCount,
}) => ({
inputUUID,
interfaces,
ipAddress: ipAddressInputRef?.current.getValue?.call(null) ?? '',
name,
subnetMask:
subnetMaskInputRef?.current.getValue?.call(null) ?? '',
type,
typeCount,
}),
),
}),
}),
[gatewayInterface, networkInputs],
);
const networkInputMinWidth = '13em';
const networkInputWidth = '25%';
return isLoadingNetworkInterfaces ? (
<Spinner />
) : (
<MUIBox
onMouseDown={({ clientX, clientY, currentTarget }) => {
const { left, top } = currentTarget.getBoundingClientRect();
setDragMousePosition({
x: clientX - left,
y: clientY - top,
});
}}
onMouseLeave={handleDragAreaMouseLeave}
onMouseMove={handleDragAreaMouseMove}
onMouseUp={handleDragAreaMouseUp}
sx={{ position: 'relative', ...dragAreaDraggingSx }}
>
{floatingNetworkInterface}
<MUIBox
sx={{
display: 'flex',
flexDirection: 'column',
'& > :not(:first-child, :nth-child(3))': {
marginTop: '1em',
},
}}
>
<MUIDataGrid
autoHeight
columns={createNetworkInterfaceTableColumns((row) => {
setNetworkInterfaceHeld(row);
}, networkInterfaceInputMap)}
componentsProps={{
row: {
onMouseDown: ({
target: {
parentElement: {
dataset: { id: networkInterfaceUUID = undefined } = {},
} = {},
} = {},
}: {
target?: { parentElement?: { dataset?: { id?: string } } };
}) => {
if (networkInterfaceUUID) {
const { isApplied, metadata } =
networkInterfaceInputMap[networkInterfaceUUID];
if (!isApplied) {
setNetworkInterfaceHeld(metadata);
}
}
},
},
}}
disableColumnMenu
disableSelectionOnClick
getRowClassName={({ row: { networkInterfaceUUID } }) => {
const { isApplied } =
networkInterfaceInputMap[networkInterfaceUUID] ?? false;
let className = '';
if (!isApplied) {
className += ` ${CLASSES.ifaceNotApplied}`;
}
return className;
}}
getRowId={({ networkInterfaceUUID }) => networkInterfaceUUID}
hideFooter
initialState={{
sorting: {
sortModel: [{ field: 'networkInterfaceName', sort: 'asc' }],
},
}}
rows={networkInterfaces}
sx={{
color: GREY,
[`& .${muiIconButtonClasses.root}`]: {
color: 'inherit',
},
[`& .${muiGridClasses.cell}:focus`]: {
outline: 'none',
},
[`& .${muiGridClasses.row}.${CLASSES.ifaceNotApplied}:hover`]: {
cursor: 'grab',
[`& .${muiGridClasses.cell} p`]: {
cursor: 'auto',
},
},
}}
/>
{!isLoadingHostDetail && (
<FlexBox
row
sx={{
'& > :first-child': {
alignSelf: 'start',
marginTop: '.7em',
},
'& > :last-child': {
flexGrow: 1,
},
}}
>
<MUIBox
sx={{
alignItems: 'strech',
display: 'flex',
flexDirection: 'row',
overflowX: 'auto',
paddingLeft: '.3em',
'& > div': {
marginBottom: '.8em',
marginTop: '.4em',
minWidth: networkInputMinWidth,
width: networkInputWidth,
},
'& > :not(:first-child)': {
marginLeft: '1em',
},
}}
>
{networkInputs.map((networkInput, networkIndex) => {
const { inputUUID } = networkInput;
return (
<NetworkForm
key={`network-${inputUUID}`}
{...{
allowMigrationNetwork,
createDropMouseUpHandler,
getNetworkTypeCount,
hostDetail: { hostType, sequence },
networkIndex,
networkInput,
networkInterfaceCount: networkInterfaces.length,
networkInterfaceInputMap,
removeNetwork,
setMessageRe,
setNetworkInputs,
setNetworkInterfaceInputMap,
testInput,
testInputToToggleSubmitDisabled,
}}
/>
);
})}
</MUIBox>
</FlexBox>
)}
<FlexBox
sm="row"
sx={{
marginTop: '.2em',
'& > :not(button)': {
minWidth: networkInputMinWidth,
width: { sm: networkInputWidth },
},
}}
>
<IconButton
disabled={isDisableAddNetworkButton}
onClick={() => {
createNetwork();
}}
>
<MUIAddIcon />
</IconButton>
<InputWithRef
input={
<OutlinedInputWithLabel
id="network-init-gateway"
inputProps={{
onBlur: ({ target: { value } }) => {
testInput({ inputs: { [IT_IDS.gateway]: { value } } });
},
}}
inputLabelProps={{ isNotifyRequired: true }}
onChange={({ target: { value } }) => {
testInputToToggleSubmitDisabled({
inputs: { [IT_IDS.gateway]: { value } },
});
setGatewayInputMessage();
}}
label="Gateway"
/>
}
ref={gatewayInputRef}
/>
<InputWithRef
input={
<OutlinedInputWithLabel
id="network-init-dns-csv"
inputProps={{
onBlur: ({ target: { value } }) => {
testInput({ inputs: { [IT_IDS.dnsCSV]: { value } } });
},
}}
inputLabelProps={{ isNotifyRequired: true }}
onChange={({ target: { value } }) => {
testInputToToggleSubmitDisabled({
inputs: { [IT_IDS.dnsCSV]: { value } },
});
setDnsInputMessage();
}}
label="Domain name server(s)"
/>
}
ref={dnsCSVInputRef}
/>
</FlexBox>
<MessageGroup
count={1}
defaultMessageType="warning"
ref={messageGroupRef}
/>
</MUIBox>
</MUIBox>
);
},
);
NetworkInitForm.defaultProps = {
expectHostDetail: false,
hostDetail: undefined,
hostSequence: undefined,
toggleSubmitDisabled: undefined,
};
NetworkInitForm.displayName = 'NetworkInitForm';
export type {
NetworkInitFormForwardedRefContent,
NetworkInitFormValues,
NetworkInput,
NetworkInterfaceInputMap,
};
export default NetworkInitForm;