import { Dispatch, ReactNode, SetStateAction, useCallback, useEffect, useMemo, useState, } from 'react'; import { Box, Dialog, DialogProps, Grid } from '@mui/material'; import { Close as CloseIcon } from '@mui/icons-material'; import { DataSizeUnit } from 'format-data-size'; import { v4 as uuidv4 } from 'uuid'; import { BLUE, RED, TEXT } from '../lib/consts/DEFAULT_THEME'; import api from '../lib/api'; import Autocomplete from './Autocomplete'; import ConfirmDialog from './ConfirmDialog'; import ContainedButton from './ContainedButton'; import FlexBox from './FlexBox'; import { dsize, dsizeToByte } from '../lib/format_data_size_wrappers'; import IconButton, { IconButtonProps } from './IconButton'; import MessageBox, { MessageBoxProps } from './MessageBox'; import OutlinedInputWithLabel from './OutlinedInputWithLabel'; import OutlinedLabeledInputWithSelect from './OutlinedLabeledInputWithSelect'; import { Panel, PanelHeader } from './Panels'; import SelectWithLabel from './SelectWithLabel'; import Spinner from './Spinner'; import { testInput as baseTestInput, testMax, testNotBlank, testRange, } from '../lib/test_input'; import { BodyText, HeaderText, InlineMonoText } from './Text'; type InputMessage = Partial>; type ProvisionServerDialogProps = { dialogProps: DialogProps; onClose: IconButtonProps['onClick']; }; type HostMetadataForProvisionServerHost = { hostUUID: string; hostName: string; hostCPUCores: number; hostMemory: string; }; type ServerMetadataForProvisionServer = { serverUUID: string; serverName: string; serverCPUCores: number; serverMemory: string; }; type StorageGroupMetadataForProvisionServer = { storageGroupUUID: string; storageGroupName: string; storageGroupSize: string; storageGroupFree: string; }; type FileMetadataForProvisionServer = { fileUUID: string; fileName: string; }; type OrganizedServerMetadataForProvisionServer = Omit< ServerMetadataForProvisionServer, 'serverMemory' > & { serverMemory: bigint; }; type OrganizedStorageGroupMetadataForProvisionServer = Omit< StorageGroupMetadataForProvisionServer, 'storageGroupSize' | 'storageGroupFree' > & { anvilUUID: string; anvilName: string; storageGroupSize: bigint; storageGroupFree: bigint; }; type AnvilDetailMetadataForProvisionServer = { anvilUUID: string; anvilName: string; anvilDescription?: string; anvilTotalCPUCores: number; anvilTotalMemory: string; anvilTotalAllocatedCPUCores: number; anvilTotalAllocatedMemory: string; anvilTotalAvailableCPUCores: number; anvilTotalAvailableMemory: string; hosts: Array; servers: Array; storageGroups: Array; files: Array; }; type OrganizedAnvilDetailMetadataForProvisionServer = Omit< AnvilDetailMetadataForProvisionServer, | 'anvilTotalMemory' | 'anvilTotalAllocatedMemory' | 'anvilTotalAvailableMemory' | 'hosts' | 'servers' | 'storageGroups' > & { anvilTotalMemory: bigint; anvilTotalAllocatedMemory: bigint; anvilTotalAvailableMemory: bigint; hosts: Array< Omit & { hostMemory: bigint; } >; servers: Array; storageGroupUUIDs: string[]; storageGroups: Array; fileUUIDs: string[]; }; type AnvilUUIDMapToData = { [uuid: string]: OrganizedAnvilDetailMetadataForProvisionServer; }; type FileUUIDMapToData = { [uuid: string]: FileMetadataForProvisionServer; }; type ServerNameMapToData = { [name: string]: OrganizedServerMetadataForProvisionServer; }; type StorageGroupUUIDMapToData = { [uuid: string]: OrganizedStorageGroupMetadataForProvisionServer; }; type OSAutoCompleteOption = { label: string; key: string }; type FilterAnvilsFunction = ( allAnvils: OrganizedAnvilDetailMetadataForProvisionServer[], storageGroupUUIDMapToData: StorageGroupUUIDMapToData, cpuCores: number, memory: bigint, vdSizes: bigint[], storageGroupUUIDs: string[], fileUUIDs: string[], options?: { includeAnvilUUIDs?: string[]; includeFileUUIDs?: string[]; includeStorageGroupUUIDs?: string[]; }, ) => { anvils: OrganizedAnvilDetailMetadataForProvisionServer[]; anvilUUIDs: string[]; fileUUIDs: string[]; maxCPUCores: number; maxMemory: bigint; maxVirtualDiskSizes: bigint[]; storageGroupUUIDs: string[]; }; type VirtualDiskStates = { stateIds: string[]; inputMaxes: string[]; inputSizeMessages: Array; inputSizes: string[]; inputStorageGroupUUIDMessages: Array; inputStorageGroupUUIDs: string[]; inputUnits: DataSizeUnit[]; maxes: bigint[]; sizes: bigint[]; }; type UpdateLimitsFunction = (options?: { allAnvils?: OrganizedAnvilDetailMetadataForProvisionServer[]; cpuCores?: number; fileUUIDs?: string[]; includeAnvilUUIDs?: string[]; includeFileUUIDs?: string[]; includeStorageGroupUUIDs?: string[]; inputMemoryUnit?: DataSizeUnit; memory?: bigint; storageGroupUUIDMapToData?: StorageGroupUUIDMapToData; virtualDisks?: VirtualDiskStates; }) => Pick< ReturnType, 'maxCPUCores' | 'maxMemory' | 'maxVirtualDiskSizes' > & { formattedMaxMemory: string; formattedMaxVDSizes: string[]; }; const BIGINT_ZERO = BigInt(0); const DATA_SIZE_UNIT_SELECT_ITEMS: SelectItem[] = [ { value: 'B' }, { value: 'KiB' }, { value: 'MiB' }, { value: 'GiB' }, { value: 'TiB' }, ]; const INITIAL_DATA_SIZE_UNIT: DataSizeUnit = 'GiB'; const CPU_CORES_MIN = 1; // Unit: bytes; 64 KiB const MEMORY_MIN = BigInt(65536); // Unit: bytes; 100 MiB const VIRTUAL_DISK_SIZE_MIN = BigInt(104857600); const PROVISION_BUTTON_STYLES = { backgroundColor: BLUE, color: TEXT, '&:hover': { backgroundColor: BLUE, }, }; const createMaxValueButton = ( maxValue: string, { onButtonClick, }: { onButtonClick?: ContainedButtonProps['onClick']; }, ) => ( {`Max: ${maxValue}`} ); const createSelectItemDisplay = ({ endAdornment, mainLabel, subLabel, }: { endAdornment?: ReactNode; mainLabel?: string; subLabel?: string; } = {}) => ( :first-child': { flexGrow: 1 }, }} > {mainLabel && } {subLabel && } {endAdornment} ); const organizeAnvils = (data: AnvilDetailMetadataForProvisionServer[]) => { const allFiles: Record = {}; const result = data.reduce<{ anvils: OrganizedAnvilDetailMetadataForProvisionServer[]; anvilSelectItems: SelectItem[]; anvilUUIDMapToData: AnvilUUIDMapToData; files: FileMetadataForProvisionServer[]; fileSelectItems: SelectItem[]; fileUUIDMapToData: FileUUIDMapToData; serverNameMapToData: ServerNameMapToData; storageGroups: OrganizedStorageGroupMetadataForProvisionServer[]; storageGroupSelectItems: SelectItem[]; storageGroupUUIDMapToData: StorageGroupUUIDMapToData; }>( (reduceContainer, anvil) => { const { anvilUUID, anvilName, anvilTotalMemory, anvilTotalAllocatedMemory, anvilTotalAvailableMemory, hosts, servers, storageGroups, files, } = anvil; const { anvilStorageGroups, anvilStorageGroupUUIDs } = storageGroups.reduce<{ anvilStorageGroups: OrganizedStorageGroupMetadataForProvisionServer[]; anvilStorageGroupUUIDs: string[]; }>( (reducedStorageGroups, storageGroup) => { const resultStorageGroup = { ...storageGroup, anvilUUID, anvilName, storageGroupSize: BigInt(storageGroup.storageGroupSize), storageGroupFree: BigInt(storageGroup.storageGroupFree), humanizedStorageGroupFree: '', }; dsize(storageGroup.storageGroupFree, { fromUnit: 'B', onSuccess: { string: (value, unit) => { resultStorageGroup.humanizedStorageGroupFree = `${value} ${unit}`; }, }, precision: 0, toUnit: 'ibyte', }); reducedStorageGroups.anvilStorageGroupUUIDs.push( storageGroup.storageGroupUUID, ); reducedStorageGroups.anvilStorageGroups.push(resultStorageGroup); reduceContainer.storageGroups.push(resultStorageGroup); reduceContainer.storageGroupSelectItems.push({ displayValue: createSelectItemDisplay({ endAdornment: ( ), mainLabel: storageGroup.storageGroupName, subLabel: anvilName, }), value: storageGroup.storageGroupUUID, }); reduceContainer.storageGroupUUIDMapToData[ storageGroup.storageGroupUUID ] = resultStorageGroup; return reducedStorageGroups; }, { anvilStorageGroups: [], anvilStorageGroupUUIDs: [], }, ); const fileUUIDs: string[] = []; files.forEach((file) => { const { fileUUID } = file; fileUUIDs.push(fileUUID); allFiles[fileUUID] = file; }); const resultAnvil = { ...anvil, anvilTotalMemory: BigInt(anvilTotalMemory), anvilTotalAllocatedMemory: BigInt(anvilTotalAllocatedMemory), anvilTotalAvailableMemory: BigInt(anvilTotalAvailableMemory), humanizedAnvilTotalAvailableMemory: '', hosts: hosts.map((host) => ({ ...host, hostMemory: BigInt(host.hostMemory), })), servers: servers.map(({ serverMemory, serverName, ...serverRest }) => { const resultServer = { ...serverRest, serverMemory: BigInt(serverMemory), serverName, }; reduceContainer.serverNameMapToData[serverName] = resultServer; return resultServer; }), storageGroupUUIDs: anvilStorageGroupUUIDs, storageGroups: anvilStorageGroups, fileUUIDs, }; dsize(anvilTotalAvailableMemory, { fromUnit: 'B', onSuccess: { string: (value, unit) => { resultAnvil.humanizedAnvilTotalAvailableMemory = `${value} ${unit}`; }, }, precision: 0, toUnit: 'ibyte', }); reduceContainer.anvils.push(resultAnvil); reduceContainer.anvilSelectItems.push({ displayValue: createSelectItemDisplay({ endAdornment: ( ), mainLabel: resultAnvil.anvilName, subLabel: resultAnvil.anvilDescription, }), value: anvilUUID, }); reduceContainer.anvilUUIDMapToData[anvilUUID] = resultAnvil; return reduceContainer; }, { anvils: [], anvilSelectItems: [], anvilUUIDMapToData: {}, files: [], fileSelectItems: [], fileUUIDMapToData: {}, serverNameMapToData: {}, storageGroups: [], storageGroupSelectItems: [], storageGroupUUIDMapToData: {}, }, ); Object.values(allFiles).forEach((distinctFile) => { result.files.push(distinctFile); result.fileSelectItems.push({ displayValue: distinctFile.fileName, value: distinctFile.fileUUID, }); result.fileUUIDMapToData[distinctFile.fileUUID] = distinctFile; }); return result; }; const filterAnvils: FilterAnvilsFunction = ( organizedAnvils: OrganizedAnvilDetailMetadataForProvisionServer[], storageGroupUUIDMapToData: StorageGroupUUIDMapToData, cpuCores: number, memory: bigint, vdSizes: bigint[], storageGroupUUIDs: string[], fileUUIDs: string[], { includeAnvilUUIDs = [], includeFileUUIDs = [], includeStorageGroupUUIDs = [], } = {}, ) => { let testIncludeAnvil: (uuid: string) => boolean = () => true; let testIncludeFile: (uuid: string) => boolean = () => true; let testIncludeStorageGroup: (uuid: string) => boolean = () => true; if (includeAnvilUUIDs.length > 0) { testIncludeAnvil = (uuid: string) => includeAnvilUUIDs.includes(uuid); } if (includeFileUUIDs.length > 0) { testIncludeFile = (uuid: string) => includeFileUUIDs.includes(uuid); } if (includeStorageGroupUUIDs.length > 0) { testIncludeStorageGroup = (uuid: string) => includeStorageGroupUUIDs.includes(uuid); } const resultFileUUIDs: Record = {}; const storageGroupTotals = storageGroupUUIDs.reduce<{ all: bigint; [uuid: string]: bigint; }>( (totals, uuid, index) => { const vdSize: bigint = vdSizes[index] ?? BIGINT_ZERO; totals.all += vdSize; if (uuid === '') { return totals; } if (totals[uuid] === undefined) { totals[uuid] = BIGINT_ZERO; } totals[uuid] += vdSize; return totals; }, { all: BIGINT_ZERO }, ); const result = organizedAnvils.reduce<{ anvils: OrganizedAnvilDetailMetadataForProvisionServer[]; anvilUUIDs: string[]; fileUUIDs: string[]; maxCPUCores: number; maxMemory: bigint; maxVirtualDiskSizes: bigint[]; storageGroupUUIDs: string[]; }>( (reduceContainer, organizedAnvil) => { const { anvilUUID } = organizedAnvil; if (testIncludeAnvil(anvilUUID)) { const { anvilTotalCPUCores, anvilTotalAvailableMemory, files, fileUUIDs: anvilFileUUIDs, storageGroups, } = organizedAnvil; const anvilStorageGroupUUIDs: string[] = []; let anvilStorageGroupFreeMax: bigint = BIGINT_ZERO; let anvilStorageGroupFreeTotal: bigint = BIGINT_ZERO; // Summarize storage groups in this anvil node to produce all UUIDs, max // free space, and total free space. storageGroups.forEach(({ storageGroupUUID, storageGroupFree }) => { if (testIncludeStorageGroup(storageGroupUUID)) { anvilStorageGroupUUIDs.push(storageGroupUUID); anvilStorageGroupFreeTotal += storageGroupFree; if (storageGroupFree > anvilStorageGroupFreeMax) { anvilStorageGroupFreeMax = storageGroupFree; } } }); const usableTests: (() => boolean)[] = [ // Does this anvil node have at least one storage group? () => storageGroups.length > 0, // Does this anvil node have enough CPU cores? () => cpuCores <= anvilTotalCPUCores, // Does this anvil node have enough memory? () => memory <= anvilTotalAvailableMemory, // For every virtual disk: // 1. Does this anvil node have the selected storage group which // will contain the VD? // 2. Does the selected storage group OR any storage group on this // anvil node have enough free space? () => storageGroupUUIDs.every((uuid, index) => { const vdSize = vdSizes[index] ?? BIGINT_ZERO; let hasStorageGroup = true; let hasEnoughStorage = vdSize <= anvilStorageGroupFreeMax; if (uuid !== '') { hasStorageGroup = anvilStorageGroupUUIDs.includes(uuid); hasEnoughStorage = vdSize <= storageGroupUUIDMapToData[uuid].storageGroupFree; } return hasStorageGroup && hasEnoughStorage; }), // Do storage groups on this anvil node have enough free space to // contain multiple VDs? () => Object.entries(storageGroupTotals).every(([uuid, total]) => uuid === 'all' ? total <= anvilStorageGroupFreeTotal : total <= storageGroupUUIDMapToData[uuid].storageGroupFree, ), // Does this anvil node have access to selected files? () => fileUUIDs.every( (fileUUID) => fileUUID === '' || anvilFileUUIDs.includes(fileUUID), ), ]; // If an anvil doesn't pass all tests, then it and its parts shouldn't be used. if (usableTests.every((test) => test())) { reduceContainer.anvils.push(organizedAnvil); reduceContainer.anvilUUIDs.push(anvilUUID); reduceContainer.maxCPUCores = Math.max( anvilTotalCPUCores, reduceContainer.maxCPUCores, ); if (anvilTotalAvailableMemory > reduceContainer.maxMemory) { reduceContainer.maxMemory = anvilTotalAvailableMemory; } files.forEach(({ fileUUID }) => { if (testIncludeFile(fileUUID)) { resultFileUUIDs[fileUUID] = true; } }); reduceContainer.storageGroupUUIDs.push(...anvilStorageGroupUUIDs); reduceContainer.maxVirtualDiskSizes.fill(anvilStorageGroupFreeMax); } } return reduceContainer; }, { anvils: [], anvilUUIDs: [], fileUUIDs: [], maxCPUCores: 0, maxMemory: BIGINT_ZERO, maxVirtualDiskSizes: storageGroupUUIDs.map(() => BIGINT_ZERO), storageGroupUUIDs: [], }, ); result.fileUUIDs = Object.keys(resultFileUUIDs); storageGroupUUIDs.forEach((uuid: string, uuidIndex: number) => { if (uuid !== '') { result.maxVirtualDiskSizes[uuidIndex] = storageGroupUUIDMapToData[uuid].storageGroupFree; } }); return result; }; // const convertSelectValueToArray = (value: unknown) => // typeof value === 'string' ? value.split(',') : (value as string[]); const createVirtualDiskForm = ( virtualDisks: VirtualDiskStates, vdIndex: number, setVirtualDisks: Dispatch>, storageGroupSelectItems: SelectItem[], includeStorageGroupUUIDs: string[], updateLimits: UpdateLimitsFunction, storageGroupUUIDMapToData: StorageGroupUUIDMapToData, testInput: TestInputFunction, ) => { const get = ( key: Key, gIndex: number = vdIndex, ) => virtualDisks[key][gIndex] as VirtualDiskStates[Key][number]; const set = ( key: Key, value: VirtualDiskStates[Key][number], sIndex: number = vdIndex, ) => { virtualDisks[key][sIndex] = value; setVirtualDisks({ ...virtualDisks }); }; const changeVDSize = (cvsValue: bigint = BIGINT_ZERO) => { set('sizes', cvsValue); const { formattedMaxVDSizes, maxVirtualDiskSizes } = updateLimits({ virtualDisks, }); testInput({ inputs: { [`vd${vdIndex}Size`]: { displayMax: `${formattedMaxVDSizes[vdIndex]}`, max: maxVirtualDiskSizes[vdIndex], value: cvsValue, }, }, }); }; const handleVDSizeChange = ({ value = get('inputSizes'), unit = get('inputUnits'), }: { value?: string; unit?: DataSizeUnit; }) => { if (value !== get('inputSizes')) { set('inputSizes', value); } if (unit !== get('inputUnits')) { set('inputUnits', unit); } dsizeToByte( value, unit, (convertedVDSize) => changeVDSize(convertedVDSize), () => changeVDSize(), ); }; const handleVDStorageGroupChange = (uuid = get('inputStorageGroupUUIDs')) => { if (uuid !== get('inputStorageGroupUUIDs')) { set('inputStorageGroupUUIDs', uuid); } updateLimits({ virtualDisks }); }; return ( :not(:first-child)': { marginTop: '1em', }, }} > { set('inputSizes', get('inputMaxes')); changeVDSize(get('maxes')); }, }, ), onChange: ({ target: { value } }) => { handleVDSizeChange({ value }); }, type: 'number', value: get('inputSizes'), }, inputLabelProps: { isNotifyRequired: get('sizes') === BIGINT_ZERO, }, }} selectItems={DATA_SIZE_UNIT_SELECT_ITEMS} selectWithLabelProps={{ selectProps: { onChange: ({ target: { value } }) => { const selectedUnit = value as DataSizeUnit; handleVDSizeChange({ unit: selectedUnit }); }, value: get('inputUnits'), }, }} /> !( includeStorageGroupUUIDs.includes(value) && get('sizes') <= storageGroupUUIDMapToData[value].storageGroupFree ) } inputLabelProps={{ isNotifyRequired: get('inputStorageGroupUUIDs').length === 0, }} messageBoxProps={get('inputStorageGroupUUIDMessages')} selectItems={storageGroupSelectItems} selectProps={{ onChange: ({ target: { value } }) => { const selectedStorageGroupUUID = value as string; handleVDStorageGroupChange(selectedStorageGroupUUID); }, onClearIndicatorClick: () => handleVDStorageGroupChange(''), renderValue: (value) => { const { anvilName: rvAnvilName = '?', storageGroupName: rvStorageGroupName = `Unknown (${value})`, } = storageGroupUUIDMapToData[value as string] ?? {}; return `${rvStorageGroupName} (${rvAnvilName})`; }, value: get('inputStorageGroupUUIDs'), }} /> ); }; const addVirtualDisk = ({ existingVirtualDisks: virtualDisks = { stateIds: [], inputMaxes: [], inputSizeMessages: [], inputSizes: [], inputStorageGroupUUIDMessages: [], inputStorageGroupUUIDs: [], inputUnits: [], maxes: [], sizes: [], }, stateId = uuidv4(), inputMax = '0', inputSize = '', inputSizeMessage = undefined, inputStorageGroupUUID = '', inputStorageGroupUUIDMessage = undefined, inputUnit = INITIAL_DATA_SIZE_UNIT, max = BIGINT_ZERO, setVirtualDisks, size = BIGINT_ZERO, }: { existingVirtualDisks?: VirtualDiskStates; stateId?: string; inputMax?: string; inputSize?: string; inputSizeMessage?: InputMessage | undefined; inputStorageGroupUUID?: string; inputStorageGroupUUIDMessage?: InputMessage | undefined; inputUnit?: DataSizeUnit; max?: bigint; setVirtualDisks?: Dispatch>; size?: bigint; } = {}) => { const { stateIds, inputMaxes, inputSizeMessages, inputSizes, inputStorageGroupUUIDMessages, inputStorageGroupUUIDs, inputUnits, maxes, sizes, } = virtualDisks; stateIds.push(stateId); inputMaxes.push(inputMax); inputSizeMessages.push(inputSizeMessage); inputSizes.push(inputSize); inputStorageGroupUUIDMessages.push(inputStorageGroupUUIDMessage); inputStorageGroupUUIDs.push(inputStorageGroupUUID); inputUnits.push(inputUnit); maxes.push(max); sizes.push(size); setVirtualDisks?.call(null, { ...virtualDisks }); return virtualDisks; }; const filterBlanks: (array: string[]) => string[] = (array: string[]) => array.filter((value) => value !== ''); const getDisplayDsizeOptions = ( onSuccessString: (value: string, unit: DataSizeUnit) => void, ): Parameters[1] => ({ fromUnit: 'B', onSuccess: { string: onSuccessString, }, precision: 0, toUnit: 'ibyte', }); let displayMemoryMin: string; let displayVirtualDiskSizeMin: string; dsize( MEMORY_MIN, getDisplayDsizeOptions((value, unit) => { displayMemoryMin = `${value} ${unit}`; }), ); dsize( VIRTUAL_DISK_SIZE_MIN, getDisplayDsizeOptions((value, unit) => { displayVirtualDiskSizeMin = `${value} ${unit}`; }), ); const ProvisionServerDialog = ({ dialogProps: { open }, onClose: onCloseProvisionServerDialog, }: ProvisionServerDialogProps): JSX.Element => { const [allAnvils, setAllAnvils] = useState< OrganizedAnvilDetailMetadataForProvisionServer[] >([]); // Provision is impossible when one of anvil node list, file list, or storage // group list is empty. const [anvilUUIDMapToData, setAnvilUUIDMapToData] = useState({}); const [fileUUIDMapToData, setFileUUIDMapToData] = useState( {}, ); const [serverNameMapToData, setServerNameMapToData] = useState({}); const [storageGroupUUIDMapToData, setStorageGroupUUIDMapToData] = useState({}); const [anvilSelectItems, setAnvilSelectItems] = useState([]); const [fileSelectItems, setFileSelectItems] = useState([]); const [osAutocompleteOptions, setOSAutocompleteOptions] = useState< OSAutoCompleteOption[] >([]); const [storageGroupSelectItems, setStorageGroupSelectItems] = useState< SelectItem[] >([]); const [inputServerNameValue, setInputServerNameValue] = useState(''); const [inputServerNameMessage, setInputServerNameMessage] = useState< InputMessage | undefined >(); const [inputCPUCoresValue, setInputCPUCoresValue] = useState(1); const [inputCPUCoresMax, setInputCPUCoresMax] = useState(0); const [inputCPUCoresMessage, setInputCPUCoresMessage] = useState< InputMessage | undefined >(); const [memory, setMemory] = useState(BIGINT_ZERO); const [memoryMax, setMemoryMax] = useState(BIGINT_ZERO); const [inputMemoryMessage, setInputMemoryMessage] = useState< InputMessage | undefined >(); const [inputMemoryMax, setInputMemoryMax] = useState('0'); const [inputMemoryValue, setInputMemoryValue] = useState(''); const [inputMemoryUnit, setInputMemoryUnit] = useState( INITIAL_DATA_SIZE_UNIT, ); const [virtualDisks, setVirtualDisks] = useState( addVirtualDisk(), ); const [inputInstallISOFileUUID, setInputInstallISOFileUUID] = useState(''); const [inputInstallISOMessage, setInputInstallISOMessage] = useState< InputMessage | undefined >(); const [inputDriverISOFileUUID, setInputDriverISOFileUUID] = useState(''); const [inputDriverISOMessage] = useState(); const [inputAnvilValue, setInputAnvilValue] = useState(''); const [inputAnvilMessage, setInputAnvilMessage] = useState< InputMessage | undefined >(); const [inputOptimizeForOSValue, setInputOptimizeForOSValue] = useState(null); const [inputOptimizeForOSMessage, setInputOptimizeForOSMessage] = useState< InputMessage | undefined >(); const [includeAnvilUUIDs, setIncludeAnvilUUIDs] = useState([]); const [includeFileUUIDs, setIncludeFileUUIDs] = useState([]); const [includeStorageGroupUUIDs, setIncludeStorageGroupUUIDs] = useState< string[] >([]); const [isProvisionServerDataReady, setIsProvisionServerDataReady] = useState(false); const [isOpenProvisionConfirmDialog, setIsOpenProvisionConfirmDialog] = useState(false); const [isProvisionRequestInProgress, setIsProvisionRequestInProgress] = useState(false); const [successfulProvisionCount, setSuccessfulProvisionCount] = useState(0); const inputCpuCoresOptions = useMemo(() => { const result: number[] = []; for (let i = CPU_CORES_MIN; i <= inputCPUCoresMax; i += 1) { result.push(i); } return result; }, [inputCPUCoresMax]); const inputTests: InputTestBatches = { serverName: { defaults: { onSuccess: () => { setInputServerNameMessage(undefined); }, value: inputServerNameValue, }, isRequired: true, tests: [ { onFailure: () => { setInputServerNameMessage({ text: 'The server name length must be 1 to 16 characters.', type: 'warning', }); }, test: ({ value }) => { const { length } = value as string; return length >= 1 && length <= 16; }, }, { onFailure: () => { setInputServerNameMessage({ text: 'The server name is expected to only contain alphanumeric, hyphen, or underscore characters.', type: 'warning', }); }, test: ({ value }) => /^[a-zA-Z0-9_-]+$/.test(value as string), }, { onFailure: () => { setInputServerNameMessage({ text: `This server name already exists, please choose another name.`, type: 'warning', }); }, test: ({ value }) => serverNameMapToData[value as string] === undefined, }, ], }, cpuCores: { defaults: { max: inputCPUCoresMax, min: CPU_CORES_MIN, onSuccess: () => { setInputCPUCoresMessage(undefined); }, value: inputCPUCoresValue, }, isRequired: true, tests: [ { onFailure: () => { setInputCPUCoresMessage({ text: 'Non available.', type: 'warning', }); }, test: testMax, }, { onFailure: ({ displayMax, displayMin }) => { setInputCPUCoresMessage({ text: `The number of CPU cores is expected to be between ${displayMin} and ${displayMax}.`, type: 'warning', }); }, test: testRange, }, ], }, memory: { defaults: { displayMax: `${inputMemoryMax} ${inputMemoryUnit}`, displayMin: displayMemoryMin, max: memoryMax, min: MEMORY_MIN, onSuccess: () => { setInputMemoryMessage(undefined); }, value: memory, }, isRequired: true, tests: [ { onFailure: () => { setInputMemoryMessage({ text: 'Non available.', type: 'warning' }); }, test: testMax, }, { onFailure: ({ displayMax, displayMin }) => { setInputMemoryMessage({ text: `Memory is expected to be between ${displayMin} and ${displayMax}.`, type: 'warning', }); }, test: testRange, }, ], }, installISO: { defaults: { onSuccess: () => { setInputInstallISOMessage(undefined); }, value: inputInstallISOFileUUID, }, isRequired: true, tests: [{ test: testNotBlank }], }, anvil: { defaults: { onSuccess: () => { setInputAnvilMessage(undefined); }, value: inputAnvilValue, }, isRequired: true, tests: [{ test: testNotBlank }], }, optimizeForOS: { defaults: { onSuccess: () => { setInputOptimizeForOSMessage(undefined); }, value: inputOptimizeForOSValue?.key, }, isRequired: true, tests: [{ test: testNotBlank }], }, }; virtualDisks.inputSizeMessages.forEach((message, vdIndex) => { inputTests[`vd${vdIndex}Size`] = { defaults: { displayMax: `${virtualDisks.inputMaxes[vdIndex]} ${virtualDisks.inputUnits[vdIndex]}`, displayMin: displayVirtualDiskSizeMin, max: virtualDisks.maxes[vdIndex], min: VIRTUAL_DISK_SIZE_MIN, onSuccess: () => { virtualDisks.inputSizeMessages[vdIndex] = undefined; }, value: virtualDisks.sizes[vdIndex], }, isRequired: true, onFinishBatch: () => { setVirtualDisks({ ...virtualDisks }); }, tests: [ { onFailure: () => { virtualDisks.inputSizeMessages[vdIndex] = { text: 'Non available.', type: 'warning', }; }, test: testMax, }, { onFailure: ({ displayMax, displayMin }) => { virtualDisks.inputSizeMessages[vdIndex] = { text: `Virtual disk ${vdIndex} size is expected to be between ${displayMin} and ${displayMax}.`, type: 'warning', }; }, test: testRange, }, ], }; inputTests[`vd${vdIndex}StorageGroup`] = { defaults: { onSuccess: () => { virtualDisks.inputStorageGroupUUIDMessages[vdIndex] = undefined; }, value: virtualDisks.inputStorageGroupUUIDs[vdIndex], }, isRequired: true, onFinishBatch: () => { setVirtualDisks({ ...virtualDisks }); }, tests: [{ test: testNotBlank }], }; }); const updateLimits: UpdateLimitsFunction = ({ allAnvils: ulAllAnvils = allAnvils, cpuCores: ulCPUCores = inputCPUCoresValue, fileUUIDs: ulFileUUIDs = [inputInstallISOFileUUID, inputDriverISOFileUUID], includeAnvilUUIDs: ulIncludeAnvilUUIDs = filterBlanks([inputAnvilValue]), includeFileUUIDs: ulIncludeFileUUIDs, includeStorageGroupUUIDs: ulIncludeStorageGroupUUIDs, inputMemoryUnit: ulInputMemoryUnit = inputMemoryUnit, memory: ulMemory = memory, storageGroupUUIDMapToData: ulStorageGroupUUIDMapToData = storageGroupUUIDMapToData, virtualDisks: ulVirtualDisks = virtualDisks, } = {}) => { const { anvilUUIDs, fileUUIDs, maxCPUCores, maxMemory, maxVirtualDiskSizes, storageGroupUUIDs, } = filterAnvils( ulAllAnvils, ulStorageGroupUUIDMapToData, ulCPUCores, ulMemory, ulVirtualDisks.sizes, ulVirtualDisks.inputStorageGroupUUIDs, ulFileUUIDs, { includeAnvilUUIDs: ulIncludeAnvilUUIDs, includeFileUUIDs: ulIncludeFileUUIDs, includeStorageGroupUUIDs: ulIncludeStorageGroupUUIDs, }, ); setInputCPUCoresMax(maxCPUCores); setMemoryMax(maxMemory); const formattedMaxVDSizes: string[] = []; ulVirtualDisks.maxes = maxVirtualDiskSizes; ulVirtualDisks.maxes.forEach((vdMaxSize, vdIndex) => { dsize(vdMaxSize, { fromUnit: 'B', onSuccess: { string: (value, unit) => { ulVirtualDisks.inputMaxes[vdIndex] = value; formattedMaxVDSizes[vdIndex] = `${value} ${unit}`; }, }, toUnit: ulVirtualDisks.inputUnits[vdIndex], }); }); setVirtualDisks({ ...ulVirtualDisks }); setIncludeAnvilUUIDs(anvilUUIDs); setIncludeFileUUIDs(fileUUIDs); setIncludeStorageGroupUUIDs(storageGroupUUIDs); let formattedMaxMemory = ''; dsize(maxMemory, { fromUnit: 'B', onSuccess: { string: (value, unit) => { setInputMemoryMax(value); formattedMaxMemory = `${value} ${unit}`; }, }, toUnit: ulInputMemoryUnit, }); return { formattedMaxMemory, formattedMaxVDSizes, maxCPUCores, maxMemory, maxVirtualDiskSizes, }; }; // The memorized version of updateLimits() should only be called during first render. // eslint-disable-next-line react-hooks/exhaustive-deps const initLimits = useCallback(updateLimits, []); const testInput = ( ...[options, ...restArgs]: Parameters ) => baseTestInput({ tests: inputTests, ...options }, ...restArgs); const changeMemory = ({ cmValue = BIGINT_ZERO, cmUnit = inputMemoryUnit, }: { cmValue?: bigint; cmUnit?: DataSizeUnit } = {}) => { setMemory(cmValue); const { formattedMaxMemory, maxMemory } = updateLimits({ inputMemoryUnit: cmUnit, memory: cmValue, }); testInput({ inputs: { memory: { displayMax: formattedMaxMemory, max: maxMemory, value: cmValue, }, }, }); }; const handleInputMemoryValueChange = ({ value = inputMemoryValue, unit = inputMemoryUnit, }: { value?: string; unit?: DataSizeUnit; } = {}) => { if (value !== inputMemoryValue) { setInputMemoryValue(value); } if (unit !== inputMemoryUnit) { setInputMemoryUnit(unit); } dsizeToByte( value, unit, (convertedMemory) => changeMemory({ cmValue: convertedMemory, cmUnit: unit }), () => changeMemory({ cmUnit: unit }), ); }; const handleInputInstallISOFileUUIDChange = (uuid: string) => { setInputInstallISOFileUUID(uuid); updateLimits({ fileUUIDs: [uuid, inputDriverISOFileUUID], }); }; const handleInputDriverISOFileUUIDChange = (uuid: string) => { setInputDriverISOFileUUID(uuid); updateLimits({ fileUUIDs: [inputInstallISOFileUUID, uuid], }); }; const handleInputAnvilValueChange = (uuid: string) => { const havcIncludeAnvilUUIDs = filterBlanks([uuid]); setInputAnvilValue(uuid); updateLimits({ includeAnvilUUIDs: havcIncludeAnvilUUIDs, }); }; const createConfirmDialogContent = () => { const gridColumns = 10; const c1 = 2; const c2 = 5; const c3 = 3; const c2n3 = c2 + c3; return ( Server will be created on anvil node{' '} {' '} with the following properties: {inputCPUCoresValue}{' '} core(s) {inputCPUCoresMax}{' '} core(s) available {inputMemoryValue} {inputMemoryUnit} {inputMemoryMax} {inputMemoryUnit} {' '} available {virtualDisks.stateIds.map((vdStateId, vdIndex) => { const vdInputMax = virtualDisks.inputMaxes[vdIndex]; const vdInputSize = virtualDisks.inputSizes[vdIndex]; const vdInputUnit = virtualDisks.inputUnits[vdIndex]; const vdStorageGroupName = storageGroupUUIDMapToData[ virtualDisks.inputStorageGroupUUIDs[vdIndex] ].storageGroupName; return ( Disk {vdInputSize} {vdInputUnit} {' '} on {vdStorageGroupName} {vdInputMax} {vdInputUnit} {' '} available ); })} {fileUUIDMapToData[inputInstallISOFileUUID].fileName} {fileUUIDMapToData[inputDriverISOFileUUID] ? ( {fileUUIDMapToData[inputDriverISOFileUUID].fileName} ) : ( 'none' )} {`${inputOptimizeForOSValue?.label}`} ); }; const hasResource = useMemo>( () => ({ 'anvil node': Boolean(Object.keys(anvilUUIDMapToData).length), file: Boolean(Object.keys(fileUUIDMapToData).length), 'storage group': Boolean(Object.keys(storageGroupUUIDMapToData).length), }), [anvilUUIDMapToData, fileUUIDMapToData, storageGroupUUIDMapToData], ); useEffect(() => { api .get('/anvil', { params: { anvilUUIDs: 'all', isForProvisionServer: true, }, }) .then(({ data }) => { const { anvils: ueAllAnvils, anvilSelectItems: ueAnvilSelectItems, anvilUUIDMapToData: ueAnvilUUIDMapToData, fileSelectItems: ueFileSelectItems, fileUUIDMapToData: ueFileUUIDMapToData, serverNameMapToData: ueServerNameMapToData, storageGroupSelectItems: ueStorageGroupSelectItems, storageGroupUUIDMapToData: ueStorageGroupUUIDMapToData, } = organizeAnvils(data.anvils); setAllAnvils(ueAllAnvils); setAnvilUUIDMapToData(ueAnvilUUIDMapToData); setFileUUIDMapToData(ueFileUUIDMapToData); setServerNameMapToData(ueServerNameMapToData); setStorageGroupUUIDMapToData(ueStorageGroupUUIDMapToData); setAnvilSelectItems(ueAnvilSelectItems); setFileSelectItems(ueFileSelectItems); setStorageGroupSelectItems(ueStorageGroupSelectItems); const limits: Parameters[0] = { allAnvils: ueAllAnvils, storageGroupUUIDMapToData: ueStorageGroupUUIDMapToData, }; // Auto-select the only option when there's only 1. // Reminder to update the form limits after changing any value. if (ueAnvilSelectItems.length === 1) { const { 0: { value: uuid }, } = ueAnvilSelectItems; setInputAnvilValue(uuid); limits.includeAnvilUUIDs = [uuid]; } if (ueFileSelectItems.length === 1) { const { 0: { value: uuid }, } = ueFileSelectItems; setInputInstallISOFileUUID(uuid); limits.fileUUIDs = [uuid, '']; } if (ueStorageGroupSelectItems.length === 1) { const { 0: { value: uuid }, } = ueStorageGroupSelectItems; setVirtualDisks((previous) => { const current = { ...previous }; current.inputStorageGroupUUIDs[0] = uuid; limits.virtualDisks = current; return current; }); } initLimits(limits); setOSAutocompleteOptions( Object.entries(data.oses as Record).map( ([key, label]) => ({ key, label, }), ), ); setIsProvisionServerDataReady(true); }); }, [initLimits]); return ( <> {Object.entries(hasResource).map( ([resource, has]) => !has && ( No {resource} available yet. Try refreshing after the resource gets created. ), )} {isProvisionServerDataReady ? ( :not(:first-child)': { marginTop: '1em', }, }} > { setInputServerNameValue(value); testInput({ inputs: { serverName: { value } } }); }, value: inputServerNameValue, }} inputLabelProps={{ isNotifyRequired: inputServerNameValue.length === 0, }} messageBoxProps={inputServerNameMessage} /> { inputLabelProps.isNotifyRequired = inputCPUCoresValue <= 0; }} getOptionLabel={(option) => String(option)} label="CPU cores" messageBoxProps={inputCPUCoresMessage} noOptionsText="No available number of cores." onChange={(event, value) => { if (!value || value === inputCPUCoresValue) return; setInputCPUCoresValue(value); const { maxCPUCores: newCPUCoresMax } = updateLimits({ cpuCores: value, }); testInput({ inputs: { cpuCores: { max: newCPUCoresMax, value, }, }, }); }} openOnFocus options={inputCpuCoresOptions} renderOption={(optionProps, option) => (
  • {option}
  • )} value={inputCPUCoresValue} /> { setInputMemoryValue(inputMemoryMax); changeMemory({ cmValue: memoryMax }); }, }, ), onChange: ({ target: { value } }) => { handleInputMemoryValueChange({ value }); }, type: 'number', value: inputMemoryValue, }, inputLabelProps: { isNotifyRequired: memory === BIGINT_ZERO, }, }} selectItems={DATA_SIZE_UNIT_SELECT_ITEMS} selectWithLabelProps={{ selectProps: { onChange: ({ target: { value } }) => { const selectedUnit = value as DataSizeUnit; handleInputMemoryValueChange({ unit: selectedUnit }); }, value: inputMemoryUnit, }, }} /> {virtualDisks.stateIds.map((vdStateId, vdIndex) => createVirtualDiskForm( virtualDisks, vdIndex, setVirtualDisks, storageGroupSelectItems, includeStorageGroupUUIDs, updateLimits, storageGroupUUIDMapToData, testInput, ), )} value === inputDriverISOFileUUID} hideItem={(value) => !includeFileUUIDs.includes(value)} id="ps-install-image" inputLabelProps={{ isNotifyRequired: inputInstallISOFileUUID.length === 0, }} label="Install ISO" messageBoxProps={inputInstallISOMessage} selectItems={fileSelectItems} selectProps={{ onChange: ({ target: { value } }) => { const newInstallISOFileUUID = value as string; handleInputInstallISOFileUUIDChange(newInstallISOFileUUID); }, onClearIndicatorClick: () => handleInputInstallISOFileUUIDChange(''), value: inputInstallISOFileUUID, }} /> value === inputInstallISOFileUUID} hideItem={(value) => !includeFileUUIDs.includes(value)} id="ps-driver-image" label="Driver ISO" messageBoxProps={inputDriverISOMessage} selectItems={fileSelectItems} selectProps={{ onChange: ({ target: { value } }) => { const newDriverISOFileUUID = value as string; handleInputDriverISOFileUUIDChange(newDriverISOFileUUID); }, onClearIndicatorClick: () => handleInputDriverISOFileUUIDChange(''), value: inputDriverISOFileUUID, }} /> !includeAnvilUUIDs.includes(value)} id="ps-anvil" inputLabelProps={{ isNotifyRequired: inputAnvilValue.length === 0, }} label="Anvil node" messageBoxProps={inputAnvilMessage} selectItems={anvilSelectItems} selectProps={{ onChange: ({ target: { value } }) => { const newAnvilUUID: string = value as string; handleInputAnvilValueChange(newAnvilUUID); }, onClearIndicatorClick: () => handleInputAnvilValueChange(''), renderValue: (value) => { const { anvilName: rvAnvilName = `Unknown ${value}` } = anvilUUIDMapToData[value as string] ?? {}; return rvAnvilName; }, value: inputAnvilValue, }} /> { inputLabelProps.isNotifyRequired = inputOptimizeForOSValue === null; }} isOptionEqualToValue={(option, value) => option.key === value.key} label="Optimize for OS" messageBoxProps={inputOptimizeForOSMessage} noOptionsText="No matching OS" onChange={(event, value) => { setInputOptimizeForOSValue(value); }} openOnFocus options={osAutocompleteOptions} renderOption={(optionProps, option) => (
  • {option.label} ({option.key})
  • )} value={inputOptimizeForOSValue} />
    ) : ( )} :not(:first-child)': { marginTop: '1em', }, }} > {successfulProvisionCount > 0 && ( )} {isProvisionRequestInProgress ? ( ) : ( { setIsOpenProvisionConfirmDialog(true); }} sx={PROVISION_BUTTON_STYLES} > Provision )}
    {isOpenProvisionConfirmDialog && ( { setIsOpenProvisionConfirmDialog(false); }} onProceedAppend={() => { const requestBody = { serverName: inputServerNameValue, cpuCores: inputCPUCoresValue, memory: memory.toString(), virtualDisks: virtualDisks.stateIds.map((vdStateId, vdIndex) => ({ storageSize: virtualDisks.sizes[vdIndex].toString(), storageGroupUUID: virtualDisks.inputStorageGroupUUIDs[vdIndex], })), installISOFileUUID: inputInstallISOFileUUID, driverISOFileUUID: inputDriverISOFileUUID, anvilUUID: inputAnvilValue, optimizeForOS: inputOptimizeForOSValue?.key, }; setIsProvisionRequestInProgress(true); api.post('/server', requestBody).then(() => { setIsProvisionRequestInProgress(false); setSuccessfulProvisionCount(successfulProvisionCount + 1); }); setIsOpenProvisionConfirmDialog(false); }} proceedButtonProps={{ sx: PROVISION_BUTTON_STYLES }} titleText={`Provision ${inputServerNameValue}?`} /> )} ); }; export default ProvisionServerDialog;