diff --git a/striker-ui-api/src/lib/buildCondition.ts b/striker-ui-api/src/lib/buildCondition.ts index 3226a80d..b128322e 100644 --- a/striker-ui-api/src/lib/buildCondition.ts +++ b/striker-ui-api/src/lib/buildCondition.ts @@ -22,7 +22,7 @@ const buildIDCondition = ( export const buildUnknownIDCondition = ( keys: unknown, conditionPrefix: string, - { onFallback }: { onFallback?: () => string }, + { onFallback }: { onFallback?: () => string } = {}, ): { after: string; before: string[] } => { const before = sanitize(keys, 'string[]', { modifierType: 'sql', diff --git a/striker-ui-api/src/lib/request_handlers/host/buildQueryHostDetail.ts b/striker-ui-api/src/lib/request_handlers/host/buildQueryHostDetail.ts index c7458264..05da6d53 100644 --- a/striker-ui-api/src/lib/request_handlers/host/buildQueryHostDetail.ts +++ b/striker-ui-api/src/lib/request_handlers/host/buildQueryHostDetail.ts @@ -28,6 +28,7 @@ export const buildQueryHostDetail: BuildQueryDetailFunction = ({ const query = ` SELECT hos.host_name, + hos.host_type, hos.host_uuid, var.variable_name, var.variable_value @@ -42,16 +43,18 @@ export const buildQueryHostDetail: BuildQueryDetailFunction = ({ const afterQueryReturn: QueryResultModifierFunction = buildQueryResultModifier((output) => { - const [hostName, hostUUID] = output[0]; + const [hostName, hostType, hostUUID] = output[0]; const shortHostName = getShortHostName(hostName); return output.reduce< - { hostName: string; hostUUID: string; shortHostName: string } & Record< - string, - string - > + { + hostName: string; + hostType: string; + hostUUID: string; + shortHostName: string; + } & Record >( - (previous, [, , variableName, variableValue]) => { + (previous, [, , , variableName, variableValue]) => { const [variablePrefix, ...restVariableParts] = variableName.split('::'); const key = MAP_TO_EXTRACTOR[variablePrefix](restVariableParts); @@ -60,7 +63,7 @@ export const buildQueryHostDetail: BuildQueryDetailFunction = ({ return previous; }, - { hostName, hostUUID, shortHostName }, + { hostName, hostType, hostUUID, shortHostName }, ); }); diff --git a/striker-ui-api/src/lib/request_handlers/host/getHost.ts b/striker-ui-api/src/lib/request_handlers/host/getHost.ts index f1173610..8e5b2000 100644 --- a/striker-ui-api/src/lib/request_handlers/host/getHost.ts +++ b/striker-ui-api/src/lib/request_handlers/host/getHost.ts @@ -1,4 +1,5 @@ import { getLocalHostUUID } from '../../accessModule'; +import { buildUnknownIDCondition } from '../../buildCondition'; import buildGetRequestHandler from '../buildGetRequestHandler'; import { buildQueryHostDetail } from './buildQueryHostDetail'; import { buildQueryResultReducer } from '../../buildQueryResultModifier'; @@ -7,22 +8,36 @@ import { getShortHostName } from '../../getShortHostName'; import { sanitize } from '../../sanitize'; export const getHost = buildGetRequestHandler((request, buildQueryOptions) => { - const { hostUUIDs } = request.query; + const { hostUUIDs, types: hostTypes } = request.query; const localHostUUID: string = getLocalHostUUID(); + const { after: typeCondition } = buildUnknownIDCondition( + hostTypes, + 'hos.host_type', + ); + + let condition = ''; + + if (typeCondition.length > 0) { + condition += `WHERE ${typeCondition}`; + } + let query = ` SELECT hos.host_name, + hos.host_type, hos.host_uuid - FROM hosts AS hos;`; + FROM hosts AS hos + ${condition};`; let afterQueryReturn: QueryResultModifierFunction | undefined = buildQueryResultReducer<{ [hostUUID: string]: HostOverview }>( - (previous, [hostName, hostUUID]) => { + (previous, [hostName, hostType, hostUUID]) => { const key = toLocal(hostUUID, localHostUUID); previous[key] = { hostName, + hostType, hostUUID, shortHostName: getShortHostName(hostName), }; diff --git a/striker-ui-api/src/lib/request_handlers/network-interface/getNetworkInterface.ts b/striker-ui-api/src/lib/request_handlers/network-interface/getNetworkInterface.ts index df4475ea..6c70a3ba 100644 --- a/striker-ui-api/src/lib/request_handlers/network-interface/getNetworkInterface.ts +++ b/striker-ui-api/src/lib/request_handlers/network-interface/getNetworkInterface.ts @@ -1,10 +1,10 @@ -import { getLocalHostUUID } from '../../accessModule'; +import { toHostUUID } from '../../convertHostUUID'; import buildGetRequestHandler from '../buildGetRequestHandler'; export const getNetworkInterface = buildGetRequestHandler( - (request, buildQueryOptions) => { - const localHostUUID: string = getLocalHostUUID(); + ({ params: { hostUUID: rawHostUUID } }, buildQueryOptions) => { + const hostUUID = toHostUUID(rawHostUUID ?? 'local'); if (buildQueryOptions) { buildQueryOptions.afterQueryReturn = (queryStdout) => { @@ -48,7 +48,7 @@ export const getNetworkInterface = buildGetRequestHandler( network_interface_speed, ROW_NUMBER() OVER(ORDER BY modified_date ASC) AS network_interface_order FROM network_interfaces - WHERE network_interface_operational != 'DELETE' - AND network_interface_host_uuid = '${localHostUUID}';`; + WHERE network_interface_operational != 'DELETED' + AND network_interface_host_uuid = '${hostUUID}';`; }, ); diff --git a/striker-ui-api/src/routes/network-interface.ts b/striker-ui-api/src/routes/network-interface.ts index 7e23b005..18d17704 100644 --- a/striker-ui-api/src/routes/network-interface.ts +++ b/striker-ui-api/src/routes/network-interface.ts @@ -4,6 +4,6 @@ import { getNetworkInterface } from '../lib/request_handlers/network-interface'; const router = express.Router(); -router.get('/', getNetworkInterface); +router.get('/', getNetworkInterface).get('/:hostUUID', getNetworkInterface); export default router; diff --git a/striker-ui-api/src/types/HostOverview.d.ts b/striker-ui-api/src/types/HostOverview.d.ts index 660798bd..8e991dd3 100644 --- a/striker-ui-api/src/types/HostOverview.d.ts +++ b/striker-ui-api/src/types/HostOverview.d.ts @@ -1,5 +1,6 @@ type HostOverview = { hostName: string; + hostType: string; hostUUID: string; shortHostName: string; }; diff --git a/striker-ui/components/Display/Preview.tsx b/striker-ui/components/Display/Preview.tsx index a56a9ca1..486706b9 100644 --- a/striker-ui/components/Display/Preview.tsx +++ b/striker-ui/components/Display/Preview.tsx @@ -1,19 +1,21 @@ -import { FC, ReactNode, useEffect, useState } from 'react'; +import { + DesktopWindows as DesktopWindowsIcon, + PowerOffOutlined as PowerOffOutlinedIcon, +} from '@mui/icons-material'; import { Box, IconButton as MUIIconButton, IconButtonProps as MUIIconButtonProps, } from '@mui/material'; -import { - DesktopWindows as DesktopWindowsIcon, - PowerOffOutlined as PowerOffOutlinedIcon, -} from '@mui/icons-material'; +import { FC, ReactNode, useEffect, useMemo, useState } from 'react'; import API_BASE_URL from '../../lib/consts/API_BASE_URL'; import { BORDER_RADIUS, GREY } from '../../lib/consts/DEFAULT_THEME'; +import FlexBox from '../FlexBox'; import IconButton, { IconButtonProps } from '../IconButton'; import { InnerPanel, InnerPanelHeader, Panel, PanelHeader } from '../Panels'; +import Spinner from '../Spinner'; import { BodyText, HeaderText } from '../Text'; type PreviewOptionalProps = { @@ -85,9 +87,35 @@ const Preview: FC = ({ serverUUID, onClickConnectButton: connectButtonClickHandle = previewClickHandler, }) => { + const [isPreviewLoading, setIsPreviewLoading] = useState(true); const [isPreviewStale, setIsPreviewStale] = useState(false); const [preview, setPreview] = useState(''); + const previewButtonContent = useMemo( + () => + preview ? ( + + ) : ( + + ), + [isPreviewStale, isUseInnerPanel, preview], + ); + useEffect(() => { if (isFetchPreview) { (async () => { @@ -107,11 +135,14 @@ const Preview: FC = ({ setIsPreviewStale(false); } catch { setIsPreviewStale(true); + } finally { + setIsPreviewLoading(false); } })(); } else if (externalPreview) { setPreview(externalPreview); setIsPreviewStale(isExternalPreviewStale); + setIsPreviewLoading(false); } }, [externalPreview, isExternalPreviewStale, isFetchPreview, serverUUID]); @@ -120,56 +151,33 @@ const Preview: FC = ({ {headerEndAdornment} - :not(:last-child)': { - marginRight: '1em', - }, - }} - > + :first-child': { flexGrow: 1 } }}> + {/* Box wrapper below is required to keep external preview size sane. */} - - {preview ? ( - - ) : ( - - )} - + {isPreviewLoading ? ( + + ) : ( + + {previewButtonContent} + + )} {isShowControls && ( - + - + )} - + ); }; diff --git a/striker-ui/components/NetworkInitForm.tsx b/striker-ui/components/NetworkInitForm.tsx index d9f55206..507454e1 100644 --- a/striker-ui/components/NetworkInitForm.tsx +++ b/striker-ui/components/NetworkInitForm.tsx @@ -100,41 +100,6 @@ type TestInputToToggleSubmitDisabled = ( >, ) => void; -const MOCK_NICS: NetworkInterfaceOverviewMetadata[] = [ - { - networkInterfaceUUID: 'fe299134-c8fe-47bd-ab7a-3aa95eada1f6', - networkInterfaceMACAddress: '52:54:00:d2:31:36', - networkInterfaceName: 'ens10', - networkInterfaceState: 'up', - networkInterfaceSpeed: 10000, - networkInterfaceOrder: 1, - }, - { - networkInterfaceUUID: 'a652bfd5-61ac-4495-9881-185be8a2ac74', - networkInterfaceMACAddress: '52:54:00:d4:4d:b5', - networkInterfaceName: 'ens11', - networkInterfaceState: 'up', - networkInterfaceSpeed: 10000, - networkInterfaceOrder: 2, - }, - { - networkInterfaceUUID: 'b8089b40-0969-49c3-ad65-2470ddb420ef', - networkInterfaceMACAddress: '52:54:00:ba:f5:a3', - networkInterfaceName: 'ens3', - networkInterfaceState: 'up', - networkInterfaceSpeed: 10000, - networkInterfaceOrder: 3, - }, - { - networkInterfaceUUID: '42a17465-31b1-4e47-9a91-f803f22ffcc1', - networkInterfaceMACAddress: '52:54:00:ae:31:70', - networkInterfaceName: 'ens9', - networkInterfaceState: 'up', - networkInterfaceSpeed: 10000, - networkInterfaceOrder: 4, - }, -]; - const CLASS_PREFIX = 'NetworkInitForm'; const CLASSES = { ifaceNotApplied: `${CLASS_PREFIX}-network-interface-not-applied`, @@ -144,9 +109,15 @@ const INITIAL_IFACES = [undefined, undefined]; const NETWORK_TYPES: Record = { bcn: 'Back-Channel Network', ifn: 'Internet-Facing Network', + sn: 'Storage Network', +}; + +const NODE_NETWORK_TYPES: Record = { + ...NETWORK_TYPES, + mn: 'Migration Network', }; -const REQUIRED_NETWORKS: NetworkInput[] = [ +const STRIKER_REQUIRED_NETWORKS: NetworkInput[] = [ { inputUUID: '30dd2ac5-8024-4a7e-83a1-6a3df7218972', interfaces: [...INITIAL_IFACES], @@ -168,6 +139,19 @@ const REQUIRED_NETWORKS: NetworkInput[] = [ typeCount: 1, }, ]; +const NODE_REQUIRED_NETWORKS: NetworkInput[] = [ + ...STRIKER_REQUIRED_NETWORKS, + { + inputUUID: '525e4847-f929-44a7-83b2-28eb289ffb57', + interfaces: [...INITIAL_IFACES], + ipAddress: '10.202.1.1', + isRequired: true, + name: `${NETWORK_TYPES.sn} 1`, + subnetMask: '255.255.0.0', + type: 'sn', + typeCount: 1, + }, +]; const MAX_INTERFACES_PER_NETWORK = 2; const IT_IDS = { @@ -293,8 +277,10 @@ const NetworkForm: FC<{ interfaceIndex: number, ) => MUIBoxProps['onMouseUp']; getNetworkTypeCount: GetNetworkTypeCountFunction; + hostDetail?: APIHostDetail; networkIndex: number; networkInput: NetworkInput; + networkInterfaceCount: number; networkInterfaceInputMap: NetworkInterfaceInputMap; removeNetwork: (index: number) => void; setMessageRe: (re: RegExp, message?: Message) => void; @@ -307,8 +293,10 @@ const NetworkForm: FC<{ }> = ({ createDropMouseUpHandler, getNetworkTypeCount, + hostDetail: { hostType } = {}, networkIndex, networkInput, + networkInterfaceCount, networkInterfaceInputMap, removeNetwork, setMessageRe, @@ -355,6 +343,18 @@ const NetworkForm: FC<{ [inputTestPrefix], ); + const isNode = useMemo(() => hostType === 'node', [hostType]); + const netIfTemplate = useMemo( + () => + !isNode && networkInterfaceCount <= 2 ? [1] : NETWORK_INTERFACE_TEMPLATE, + [isNode, networkInterfaceCount], + ); + const netTypeList = useMemo( + () => + isNode && networkInterfaceCount >= 8 ? NODE_NETWORK_TYPES : NETWORK_TYPES, + [isNode, networkInterfaceCount], + ); + useEffect(() => { const { ipAddressInputRef: ipRef, subnetMaskInputRef: maskRef } = networkInput; @@ -375,7 +375,7 @@ const NetworkForm: FC<{ isReadOnly={isRequired} inputLabelProps={{ isNotifyRequired: true }} label="Network name" - selectItems={Object.entries(NETWORK_TYPES).map( + selectItems={Object.entries(netTypeList).map( ([networkType, networkTypeName]) => { let count = getNetworkTypeCount(networkType, { lastIndex: networkIndex, @@ -438,7 +438,7 @@ const NetworkForm: FC<{ }, }} > - {NETWORK_INTERFACE_TEMPLATE.map((linkNumber) => { + {netIfTemplate.map((linkNumber) => { const linkName = `Link ${linkNumber}`; const networkInterfaceIndex = linkNumber - 1; const networkInterface = interfaces[networkInterfaceIndex]; @@ -576,20 +576,32 @@ const NetworkForm: FC<{ NetworkForm.defaultProps = { createDropMouseUpHandler: undefined, + hostDetail: undefined, }; const NetworkInitForm = forwardRef< NetworkInitFormForwardedRefContent, - { toggleSubmitDisabled?: (testResult: boolean) => void } ->(({ toggleSubmitDisabled }, ref) => { + { + hostDetail?: APIHostDetail; + toggleSubmitDisabled?: (testResult: boolean) => void; + } +>(({ hostDetail, toggleSubmitDisabled }, ref) => { + const { + dns: xDns, + gateway: xGateway, + hostType, + hostUUID = 'local', + }: APIHostDetail = hostDetail ?? ({} as APIHostDetail); + const [dragMousePosition, setDragMousePosition] = useState<{ x: number; y: number; }>({ x: 0, y: 0 }); const [networkInterfaceInputMap, setNetworkInterfaceInputMap] = useState({}); - const [networkInputs, setNetworkInputs] = - useState(REQUIRED_NETWORKS); + const [networkInputs, setNetworkInputs] = useState( + hostType === 'node' ? NODE_REQUIRED_NETWORKS : STRIKER_REQUIRED_NETWORKS, + ); const [networkInterfaceHeld, setNetworkInterfaceHeld] = useState< NetworkInterfaceOverviewMetadata | undefined >(); @@ -599,9 +611,9 @@ const NetworkInitForm = forwardRef< const dnsCSVInputRef = useRef>({}); const messageGroupRef = useRef({}); - const { data: networkInterfaces = MOCK_NICS, isLoading } = periodicFetch< + const { data: networkInterfaces = [], isLoading } = periodicFetch< NetworkInterfaceOverviewMetadata[] - >(`${API_BASE_URL}/network-interface`, { + >(`${API_BASE_URL}/network-interface/${hostUUID}`, { refreshInterval: 2000, onSuccess: (data) => { const map = data.reduce((result, metadata) => { @@ -623,8 +635,9 @@ const NetworkInitForm = forwardRef< networkInputs.length >= networkInterfaces.length || Object.values(networkInterfaceInputMap).every( ({ isApplied }) => isApplied, - ), - [networkInputs, networkInterfaces, networkInterfaceInputMap], + ) || + (hostType === 'node' && networkInterfaces.length <= 6), + [hostType, networkInputs, networkInterfaces, networkInterfaceInputMap], ); const setMessage = useCallback( @@ -673,6 +686,7 @@ const NetworkInitForm = forwardRef< try { subnet = new Netmask(`${ip}/${mask}`); + // TODO: find a way to express the netmask creation error // eslint-disable-next-line no-empty } catch (netmaskError) {} @@ -694,6 +708,7 @@ const NetworkInitForm = forwardRef< 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) {} @@ -888,7 +903,9 @@ const NetworkInitForm = forwardRef< }, { test: ({ value }) => - testNetworkSubnetConflictWithDefaults({ ip: value as string }), + testNetworkSubnetConflictWithDefaults({ + ip: value as string, + }), }, { test: testNotBlank }, ], @@ -984,7 +1001,9 @@ const NetworkInitForm = forwardRef< networkInterfaceInputMap[networkInterfaceUUID].isApplied = false; }); - testInputToToggleSubmitDisabled({ excludeTestIdsRe: RegExp(inputUUID) }); + testInputToToggleSubmitDisabled({ + excludeTestIdsRe: RegExp(inputUUID), + }); setNetworkInputs([...networkInputs]); setNetworkInterfaceInputMap((previous) => ({ ...previous, @@ -1106,27 +1125,6 @@ const NetworkInitForm = forwardRef< [clearNetworkInterfaceHeld, networkInterfaceHeld], ); - useEffect(() => { - const map = networkInterfaces.reduce( - (result, metadata) => { - const { networkInterfaceUUID } = metadata; - - result[networkInterfaceUUID] = networkInterfaceInputMap[ - networkInterfaceUUID - ] ?? { metadata }; - - return result; - }, - {}, - ); - - setNetworkInterfaceInputMap(map); - - // This block inits the input map for the MOCK_NICS. - // TODO: remove after testing. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - useImperativeHandle( ref, () => ({ @@ -1266,12 +1264,6 @@ const NetworkInitForm = forwardRef< }, }} > - - - *': { + '& > :not(button)': { minWidth: networkInputMinWidth, width: { sm: networkInputWidth }, }, }} > + + + } ref={gatewayInputRef} @@ -1366,6 +1366,7 @@ const NetworkInitForm = forwardRef< setDomainNameServerCSVInputMessage(); }} label="Domain name server(s)" + value={xDns} /> } ref={dnsCSVInputRef} @@ -1381,7 +1382,10 @@ const NetworkInitForm = forwardRef< ); }); -NetworkInitForm.defaultProps = { toggleSubmitDisabled: undefined }; +NetworkInitForm.defaultProps = { + hostDetail: undefined, + toggleSubmitDisabled: undefined, +}; NetworkInitForm.displayName = 'NetworkInitForm'; export type { diff --git a/striker-ui/components/OutlinedLabeledInputWithSelect.tsx b/striker-ui/components/OutlinedLabeledInputWithSelect.tsx index 56cf9fca..c473810c 100644 --- a/striker-ui/components/OutlinedLabeledInputWithSelect.tsx +++ b/striker-ui/components/OutlinedLabeledInputWithSelect.tsx @@ -12,10 +12,7 @@ import { MessageBoxProps } from './MessageBox'; import OutlinedInputWithLabel, { OutlinedInputWithLabelProps, } from './OutlinedInputWithLabel'; -import SelectWithLabel, { - SelectItem, - SelectWithLabelProps, -} from './SelectWithLabel'; +import SelectWithLabel, { SelectWithLabelProps } from './SelectWithLabel'; type OutlinedLabeledInputWithSelectOptionalProps = { inputWithLabelProps?: Partial; diff --git a/striker-ui/components/PrepareNetworkForm.tsx b/striker-ui/components/PrepareNetworkForm.tsx new file mode 100644 index 00000000..7bfdb64a --- /dev/null +++ b/striker-ui/components/PrepareNetworkForm.tsx @@ -0,0 +1,161 @@ +import { useRouter } from 'next/router'; +import { FC, useCallback, useEffect, useMemo } from 'react'; + +import api from '../lib/api'; +import ContainedButton from './ContainedButton'; +import handleAPIError from '../lib/handleAPIError'; +import FlexBox from './FlexBox'; +import getQueryParam from '../lib/getQueryParam'; +import InputWithRef from './InputWithRef'; +import MessageBox, { Message } from './MessageBox'; +import NetworkInitForm from './NetworkInitForm'; +import OutlinedInputWithLabel from './OutlinedInputWithLabel'; +import { Panel, PanelHeader } from './Panels'; +import Spinner from './Spinner'; +import { HeaderText } from './Text'; +import useProtect from '../hooks/useProtect'; +import useProtectedState from '../hooks/useProtectedState'; + +const PrepareNetworkForm: FC = ({ + expectUUID: isExpectExternalHostUUID = false, + hostUUID, +}) => { + const { protect } = useProtect(); + + const { + isReady, + query: { host_uuid: queryHostUUID }, + } = useRouter(); + + const [dataHostDetail, setDataHostDetail] = useProtectedState< + APIHostDetail | undefined + >(undefined, protect); + const [fatalErrorMessage, setFatalErrorMessage] = useProtectedState< + Message | undefined + >(undefined, protect); + const [isLoading, setIsLoading] = useProtectedState(true, protect); + const [previousHostUUID, setPreviousHostUUID] = useProtectedState< + PrepareNetworkFormProps['hostUUID'] + >(undefined, protect); + + const isDifferentHostUUID = useMemo( + () => hostUUID !== previousHostUUID, + [hostUUID, previousHostUUID], + ); + const isReloadHostDetail = useMemo( + () => Boolean(hostUUID) && isDifferentHostUUID, + [hostUUID, isDifferentHostUUID], + ); + + const panelHeaderElement = useMemo( + () => ( + + + Prepare network on {dataHostDetail?.shortHostName} + + + ), + [dataHostDetail], + ); + const contentElement = useMemo(() => { + let result; + + if (isLoading) { + result = ; + } else if (fatalErrorMessage) { + result = ; + } else { + result = ( + <> + {panelHeaderElement} + + + } + required + /> + + + Prepare network + + + + ); + } + + return result; + }, [dataHostDetail, fatalErrorMessage, isLoading, panelHeaderElement]); + + const getHostDetail = useCallback( + (uuid: string) => { + setIsLoading(true); + + if (isLoading) { + api + .get(`/host/${uuid}`) + .then(({ data }) => { + setPreviousHostUUID(data.hostUUID); + setDataHostDetail(data); + }) + .catch((error) => { + const { children } = handleAPIError(error); + + setFatalErrorMessage({ + children: `Failed to get target host information; cannot continue. ${children}`, + type: 'error', + }); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, + [ + setIsLoading, + isLoading, + setPreviousHostUUID, + setDataHostDetail, + setFatalErrorMessage, + ], + ); + + useEffect(() => { + if (isExpectExternalHostUUID) { + if (isReloadHostDetail) { + getHostDetail(hostUUID as string); + } + } else if (isReady && !fatalErrorMessage) { + if (queryHostUUID) { + getHostDetail(getQueryParam(queryHostUUID)); + } else { + setFatalErrorMessage({ + children: `No host UUID provided; cannot continue.`, + type: 'error', + }); + + setIsLoading(false); + } + } + }, [ + fatalErrorMessage, + getHostDetail, + hostUUID, + isExpectExternalHostUUID, + isReady, + queryHostUUID, + setFatalErrorMessage, + setDataHostDetail, + setIsLoading, + isReloadHostDetail, + ]); + + return {contentElement}; +}; + +export default PrepareNetworkForm; diff --git a/striker-ui/components/ProvisionServerDialog.tsx b/striker-ui/components/ProvisionServerDialog.tsx index bb95ad77..ba711e1f 100644 --- a/striker-ui/components/ProvisionServerDialog.tsx +++ b/striker-ui/components/ProvisionServerDialog.tsx @@ -23,7 +23,7 @@ import MessageBox, { MessageBoxProps } from './MessageBox'; import OutlinedInputWithLabel from './OutlinedInputWithLabel'; import OutlinedLabeledInputWithSelect from './OutlinedLabeledInputWithSelect'; import { Panel, PanelHeader } from './Panels'; -import SelectWithLabel, { SelectItem } from './SelectWithLabel'; +import SelectWithLabel from './SelectWithLabel'; import Slider, { SliderProps } from './Slider'; import Spinner from './Spinner'; import { diff --git a/striker-ui/components/SelectWithLabel.tsx b/striker-ui/components/SelectWithLabel.tsx index c9aa356a..b062403a 100644 --- a/striker-ui/components/SelectWithLabel.tsx +++ b/striker-ui/components/SelectWithLabel.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode } from 'react'; +import { FC } from 'react'; import { Checkbox as MUICheckbox, FormControl as MUIFormControl, @@ -14,14 +14,6 @@ import OutlinedInputLabel, { } from './OutlinedInputLabel'; import Select, { SelectProps } from './Select'; -type SelectItem< - ValueType = string, - DisplayValueType = ValueType | ReactNode, -> = { - displayValue?: DisplayValueType; - value: ValueType; -}; - type SelectWithLabelOptionalProps = { checkItem?: ((value: string) => boolean) | null; disableItem?: ((value: string) => boolean) | null; @@ -110,6 +102,6 @@ const SelectWithLabel: FC = ({ SelectWithLabel.defaultProps = SELECT_WITH_LABEL_DEFAULT_PROPS; -export type { SelectItem, SelectWithLabelProps }; +export type { SelectWithLabelProps }; export default SelectWithLabel; diff --git a/striker-ui/components/StrikerConfig/AddPeerDialog.tsx b/striker-ui/components/StrikerConfig/AddPeerDialog.tsx index dfab1d65..e8cb8dc3 100644 --- a/striker-ui/components/StrikerConfig/AddPeerDialog.tsx +++ b/striker-ui/components/StrikerConfig/AddPeerDialog.tsx @@ -5,6 +5,7 @@ import INPUT_TYPES from '../../lib/consts/INPUT_TYPES'; import api from '../../lib/api'; import buildMapToMessageSetter from '../../lib/buildMapToMessageSetter'; import buildNumberTestBatch from '../../lib/test_input/buildNumberTestBatch'; +import buildObjectStateSetterCallback from '../../lib/buildObjectStateSetterCallback'; import CheckboxWithLabel from '../CheckboxWithLabel'; import ConfirmDialog from '../ConfirmDialog'; import FlexBox from '../FlexBox'; @@ -58,26 +59,18 @@ const AddPeerDialog = forwardRef< const [isSubmittingAddPeer, setIsSubmittingAddPeer] = useProtectedState(false, protect); - const buildFormValiditySetterCallback = useCallback( - (key: string, value: boolean) => - ({ [key]: toReplace, ...restPrevious }) => ({ - ...restPrevious, - [key]: value, - }), - [], - ); const buildInputFirstRenderFunction = useCallback( (key: string) => ({ isRequired }: { isRequired: boolean }) => { - setFormValidity(buildFormValiditySetterCallback(key, !isRequired)); + setFormValidity(buildObjectStateSetterCallback(key, !isRequired)); }, - [buildFormValiditySetterCallback], + [], ); const buildFinishInputTestBatchFunction = useCallback( (key: string) => (result: boolean) => { - setFormValidity(buildFormValiditySetterCallback(key, result)); + setFormValidity(buildObjectStateSetterCallback(key, result)); }, - [buildFormValiditySetterCallback], + [], ); const setAPIMessage = useCallback((message?: Message) => { messageGroupRef.current.setMessage?.call(null, 'api', message); diff --git a/striker-ui/components/Tab.tsx b/striker-ui/components/Tab.tsx new file mode 100644 index 00000000..4fe61bee --- /dev/null +++ b/striker-ui/components/Tab.tsx @@ -0,0 +1,41 @@ +import { + Tab as MUITab, + tabClasses as muiTabClasses, + TabProps as MUITabProps, +} from '@mui/material'; +import { FC, useMemo } from 'react'; + +import { BLUE, BORDER_RADIUS, GREY } from '../lib/consts/DEFAULT_THEME'; + +import { BodyText } from './Text'; + +const Tab: FC = ({ label: originalLabel, ...restTabProps }) => { + const label = useMemo( + () => + typeof originalLabel === 'string' ? ( + {originalLabel} + ) : ( + originalLabel + ), + [originalLabel], + ); + + return ( + + ); +}; + +export default Tab; diff --git a/striker-ui/components/TabContent.tsx b/striker-ui/components/TabContent.tsx new file mode 100644 index 00000000..eb5ce0ce --- /dev/null +++ b/striker-ui/components/TabContent.tsx @@ -0,0 +1,21 @@ +import { Box } from '@mui/material'; +import { ReactElement, useMemo } from 'react'; + +const TabContent = ({ + changingTabId, + children, + tabId, +}: TabContentProps): ReactElement => { + const isTabIdMatch = useMemo( + () => changingTabId === tabId, + [changingTabId, tabId], + ); + const displayValue = useMemo( + () => (isTabIdMatch ? 'initial' : 'none'), + [isTabIdMatch], + ); + + return {children}; +}; + +export default TabContent; diff --git a/striker-ui/components/Tabs.tsx b/striker-ui/components/Tabs.tsx new file mode 100644 index 00000000..97a78105 --- /dev/null +++ b/striker-ui/components/Tabs.tsx @@ -0,0 +1,98 @@ +import { + Breakpoint, + tabClasses as muiTabClasses, + Tabs as MUITabs, + tabsClasses as muiTabsClasses, + useMediaQuery, + useTheme, +} from '@mui/material'; +import { FC, useCallback, useMemo } from 'react'; + +import { BLUE, BORDER_RADIUS } from '../lib/consts/DEFAULT_THEME'; + +const TABS_MIN_HEIGHT = '1em'; +const TABS_VERTICAL_MIN_HEIGHT = '1.8em'; + +const Tabs: FC = ({ + orientation: rawOrientation, + variant = 'fullWidth', + ...restTabsProps +}) => { + const theme = useTheme(); + + const bp = useCallback( + (breakpoint: Breakpoint) => theme.breakpoints.up(breakpoint), + [theme], + ); + + const bpxs = useMediaQuery(bp('xs')); + const bpsm = useMediaQuery(bp('sm')); + const bpmd = useMediaQuery(bp('md')); + const bplg = useMediaQuery(bp('lg')); + const bpxl = useMediaQuery(bp('xl')); + + const mapToBreakpointUp: [Breakpoint, boolean][] = useMemo( + () => [ + ['xs', bpxs], + ['sm', bpsm], + ['md', bpmd], + ['lg', bplg], + ['xl', bpxl], + ], + [bplg, bpmd, bpsm, bpxl, bpxs], + ); + + const orientation = useMemo(() => { + let result: TabsOrientation | undefined; + + if (typeof rawOrientation === 'object') { + mapToBreakpointUp.some(([breakpoint, isUp]) => { + if (isUp && rawOrientation[breakpoint]) { + result = rawOrientation[breakpoint]; + } + + return !isUp; + }); + } else { + result = rawOrientation; + } + + return result; + }, [mapToBreakpointUp, rawOrientation]); + + return ( + + ); +}; + +export default Tabs; diff --git a/striker-ui/components/Text/BodyText.tsx b/striker-ui/components/Text/BodyText.tsx index bef61dcb..8fef39e9 100644 --- a/striker-ui/components/Text/BodyText.tsx +++ b/striker-ui/components/Text/BodyText.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode } from 'react'; +import { FC, ReactNode, useMemo } from 'react'; import { Typography as MUITypography, TypographyProps as MUITypographyProps, @@ -7,6 +7,7 @@ import { import { BLACK, TEXT, UNSELECTED } from '../../lib/consts/DEFAULT_THEME'; type BodyTextOptionalProps = { + inheritColour?: boolean; inverted?: boolean; monospaced?: boolean; selected?: boolean; @@ -18,6 +19,7 @@ type BodyTextProps = MUITypographyProps & BodyTextOptionalProps; const BODY_TEXT_CLASS_PREFIX = 'BodyText'; const BODY_TEXT_DEFAULT_PROPS: Required = { + inheritColour: false, inverted: false, monospaced: false, selected: true, @@ -25,6 +27,7 @@ const BODY_TEXT_DEFAULT_PROPS: Required = { }; const BODY_TEXT_CLASSES = { + inheritColour: `${BODY_TEXT_CLASS_PREFIX}-inherit-colour`, inverted: `${BODY_TEXT_CLASS_PREFIX}-inverted`, monospaced: `${BODY_TEXT_CLASS_PREFIX}-monospaced`, selected: `${BODY_TEXT_CLASS_PREFIX}-selected`, @@ -32,17 +35,21 @@ const BODY_TEXT_CLASSES = { }; const buildBodyTextClasses = ({ + isInheritColour, isInvert, isMonospace, isSelect, }: { + isInheritColour?: boolean; isInvert?: boolean; isMonospace?: boolean; isSelect?: boolean; }) => { const bodyTextClasses: string[] = []; - if (isInvert) { + if (isInheritColour) { + bodyTextClasses.push(BODY_TEXT_CLASSES.inheritColour); + } else if (isInvert) { bodyTextClasses.push(BODY_TEXT_CLASSES.inverted); } else if (isSelect) { bodyTextClasses.push(BODY_TEXT_CLASSES.selected); @@ -60,47 +67,58 @@ const buildBodyTextClasses = ({ const BodyText: FC = ({ children, className, - inverted = BODY_TEXT_DEFAULT_PROPS.inverted, - monospaced = BODY_TEXT_DEFAULT_PROPS.monospaced, - selected = BODY_TEXT_DEFAULT_PROPS.selected, + inheritColour: isInheritColour = BODY_TEXT_DEFAULT_PROPS.inheritColour, + inverted: isInvert = BODY_TEXT_DEFAULT_PROPS.inverted, + monospaced: isMonospace = BODY_TEXT_DEFAULT_PROPS.monospaced, + selected: isSelect = BODY_TEXT_DEFAULT_PROPS.selected, sx, text = BODY_TEXT_DEFAULT_PROPS.text, ...muiTypographyRestProps -}) => ( - { + const baseClassName = useMemo( + () => + buildBodyTextClasses({ + isInheritColour, + isInvert, + isMonospace, + isSelect, + }), + [isInheritColour, isInvert, isMonospace, isSelect], + ); + const content = useMemo(() => text ?? children, [children, text]); + + return ( + - {text ?? children} - -); + }} + > + {content} + + ); +}; BodyText.defaultProps = BODY_TEXT_DEFAULT_PROPS; diff --git a/striker-ui/hooks/useProtectedState.ts b/striker-ui/hooks/useProtectedState.ts index 019fc9c0..f486c201 100644 --- a/striker-ui/hooks/useProtectedState.ts +++ b/striker-ui/hooks/useProtectedState.ts @@ -1,4 +1,6 @@ -import { Dispatch, SetStateAction, useState } from 'react'; +import { Dispatch, SetStateAction, useMemo, useState } from 'react'; + +import useProtect from './useProtect'; type SetStateFunction = Dispatch>; @@ -8,17 +10,24 @@ type SetStateReturnType = ReturnType>; const useProtectedState = ( initialState: S | (() => S), - protect: ( + protect?: ( fn: SetStateFunction, ...args: SetStateParameters ) => SetStateReturnType, ): [S, SetStateFunction] => { + const { protect: defaultProtect } = useProtect(); + const [state, setState] = useState(initialState); + const pfn = useMemo( + () => protect ?? defaultProtect, + [defaultProtect, protect], + ); + return [ state, (...args: SetStateParameters): SetStateReturnType => - protect(setState, ...args), + pfn(setState, ...args), ]; }; diff --git a/striker-ui/lib/buildObjectStateSetterCallback.ts b/striker-ui/lib/buildObjectStateSetterCallback.ts new file mode 100644 index 00000000..15170e85 --- /dev/null +++ b/striker-ui/lib/buildObjectStateSetterCallback.ts @@ -0,0 +1,9 @@ +const buildObjectStateSetterCallback = + >(key: keyof S, value: S[keyof S]) => + ({ [key]: toReplace, ...restPrevious }: S): S => + ({ + ...restPrevious, + [key]: value, + } as S); + +export default buildObjectStateSetterCallback; diff --git a/striker-ui/lib/getQueryParam.ts b/striker-ui/lib/getQueryParam.ts new file mode 100644 index 00000000..7b343984 --- /dev/null +++ b/striker-ui/lib/getQueryParam.ts @@ -0,0 +1,13 @@ +const getQueryParam = ( + queryParam?: string | string[], + { + fallbackValue = '', + joinSeparator = '', + limit = 1, + }: { fallbackValue?: string; joinSeparator?: string; limit?: number } = {}, +): string => + queryParam instanceof Array + ? queryParam.slice(0, limit).join(joinSeparator) + : queryParam ?? fallbackValue; + +export default getQueryParam; diff --git a/striker-ui/pages/manage-element/index.tsx b/striker-ui/pages/manage-element/index.tsx new file mode 100644 index 00000000..5add77d6 --- /dev/null +++ b/striker-ui/pages/manage-element/index.tsx @@ -0,0 +1,181 @@ +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { FC, ReactElement, useEffect, useMemo, useState } from 'react'; + +import api from '../../lib/api'; +import getQueryParam from '../../lib/getQueryParam'; +import Grid from '../../components/Grid'; +import handleAPIError from '../../lib/handleAPIError'; +import Header from '../../components/Header'; +import { Panel } from '../../components/Panels'; +import PrepareHostForm from '../../components/PrepareHostForm'; +import PrepareNetworkForm from '../../components/PrepareNetworkForm'; +import Spinner from '../../components/Spinner'; +import Tab from '../../components/Tab'; +import TabContent from '../../components/TabContent'; +import Tabs from '../../components/Tabs'; +import useIsFirstRender from '../../hooks/useIsFirstRender'; +import useProtect from '../../hooks/useProtect'; +import useProtectedState from '../../hooks/useProtectedState'; + +const MAP_TO_PAGE_TITLE: Record = { + 'prepare-host': 'Prepare Host', + 'prepare-network': 'Prepare Network', + 'manage-fence-devices': 'Manage Fence Devices', + 'manage-upses': 'Manage UPSes', + 'manage-manifests': 'Manage Manifests', +}; +const PAGE_TITLE_LOADING = 'Loading'; +const STEP_CONTENT_GRID_COLUMNS = { md: 8, sm: 6, xs: 1 }; +const STEP_CONTENT_GRID_CENTER_COLUMN = { md: 6, sm: 4, xs: 1 }; + +const PrepareHostTabContent: FC = () => ( + , + ...STEP_CONTENT_GRID_CENTER_COLUMN, + }, + }} + /> +); + +const PrepareNetworkTabContent: FC = () => { + const isFirstRender = useIsFirstRender(); + + const { protect } = useProtect(); + + const [hostOverviewList, setHostOverviewList] = useProtectedState< + APIHostOverviewList | undefined + >(undefined, protect); + const [hostSubTabId, setHostSubTabId] = useState(false); + + const hostSubTabs = useMemo(() => { + let result: ReactElement | undefined; + + if (hostOverviewList) { + const hostOverviewPairs = Object.entries(hostOverviewList); + + result = ( + { + setHostSubTabId(newSubTabId); + }} + orientation="vertical" + value={hostSubTabId} + > + {hostOverviewPairs.map(([hostUUID, { shortHostName }]) => ( + + ))} + + ); + } else { + result = ; + } + + return result; + }, [hostOverviewList, hostSubTabId]); + + if (isFirstRender) { + api + .get('/host', { params: { types: 'node,dr' } }) + .then(({ data }) => { + setHostOverviewList(data); + setHostSubTabId(Object.keys(data)[0]); + }) + .catch((error) => { + handleAPIError(error); + }); + } + + return ( + {hostSubTabs}, + sm: 2, + }, + 'preparenetwork-center-column': { + children: ( + + ), + ...STEP_CONTENT_GRID_CENTER_COLUMN, + }, + }} + /> + ); +}; + +const ManageElement: FC = () => { + const { + isReady, + query: { step: rawStep }, + } = useRouter(); + + const [pageTabId, setPageTabId] = useState(false); + const [pageTitle, setPageTitle] = useState(PAGE_TITLE_LOADING); + + useEffect(() => { + if (isReady) { + let step = getQueryParam(rawStep, { + fallbackValue: 'prepare-host', + }); + + if (!MAP_TO_PAGE_TITLE[step]) { + step = 'prepare-host'; + } + + if (pageTitle === PAGE_TITLE_LOADING) { + setPageTitle(MAP_TO_PAGE_TITLE[step]); + } + + if (!pageTabId) { + setPageTabId(step); + } + } + }, [isReady, pageTabId, pageTitle, rawStep]); + + return ( + <> + + {pageTitle} + +
+ + { + setPageTabId(newTabId); + setPageTitle(MAP_TO_PAGE_TITLE[newTabId]); + }} + orientation={{ xs: 'vertical', sm: 'horizontal' }} + value={pageTabId} + > + + + + + + + + + + + + + {} + + + ); +}; + +export default ManageElement; diff --git a/striker-ui/pages/prepare-network/index.tsx b/striker-ui/pages/prepare-network/index.tsx new file mode 100644 index 00000000..853d67b4 --- /dev/null +++ b/striker-ui/pages/prepare-network/index.tsx @@ -0,0 +1,29 @@ +import Head from 'next/head'; +import { FC } from 'react'; + +import Grid from '../../components/Grid'; +import Header from '../../components/Header'; +import PrepareNetworkForm from '../../components/PrepareNetworkForm'; + +const PrepareNetwork: FC = () => ( + <> + + Prepare Network + +
+ , + md: 2, + sm: 4, + xs: 1, + }, + }} + /> + +); + +export default PrepareNetwork; diff --git a/striker-ui/types/APIHost.d.ts b/striker-ui/types/APIHost.d.ts index b06bf9cd..6dcfd14d 100644 --- a/striker-ui/types/APIHost.d.ts +++ b/striker-ui/types/APIHost.d.ts @@ -28,11 +28,21 @@ type APIHostConnectionOverviewList = { type APIHostInstallTarget = 'enabled' | 'disabled'; -type APIHostDetail = { +type APIHostOverview = { hostName: string; + hostType: string; hostUUID: string; - installTarget: APIHostInstallTarget; shortHostName: string; }; +type APIHostOverviewList = { + [hostUUID: string]: APIHostOverview; +}; + +type APIHostDetail = APIHostOverview & { + dns: string; + gateway: string; + installTarget: APIHostInstallTarget; +}; + type APIDeleteHostConnectionRequestBody = { [key: 'local' | string]: string[] }; diff --git a/striker-ui/types/PrepareNetworkForm.d.ts b/striker-ui/types/PrepareNetworkForm.d.ts new file mode 100644 index 00000000..7638a17e --- /dev/null +++ b/striker-ui/types/PrepareNetworkForm.d.ts @@ -0,0 +1,6 @@ +type PrepareNetworkFormOptionalProps = { + expectUUID?: boolean; + hostUUID?: string; +}; + +type PrepareNetworkFormProps = PrepareNetworkFormOptionalProps; diff --git a/striker-ui/types/SelectWithLabel.d.ts b/striker-ui/types/SelectWithLabel.d.ts new file mode 100644 index 00000000..47957ce9 --- /dev/null +++ b/striker-ui/types/SelectWithLabel.d.ts @@ -0,0 +1,7 @@ +type SelectItem< + ValueType = string, + DisplayValueType = ValueType | import('react').ReactNode, +> = { + displayValue?: DisplayValueType; + value: ValueType; +}; diff --git a/striker-ui/types/TabContent.d.ts b/striker-ui/types/TabContent.d.ts new file mode 100644 index 00000000..ed031541 --- /dev/null +++ b/striker-ui/types/TabContent.d.ts @@ -0,0 +1,4 @@ +type TabContentProps = import('react').PropsWithChildren<{ + changingTabId: T; + tabId: T; +}>; diff --git a/striker-ui/types/Tabs.d.ts b/striker-ui/types/Tabs.d.ts new file mode 100644 index 00000000..bf956f62 --- /dev/null +++ b/striker-ui/types/Tabs.d.ts @@ -0,0 +1,10 @@ +type TabsOrientation = Exclude< + import('@mui/material').TabsProps['orientation'], + undefined +>; + +type TabsProps = Omit & { + orientation?: + | TabsOrientation + | Partial>; +};