Merge pull request #307 from ylei-tsubame/host-network-config

Add prepare network form and connector page
main
Yanhao Lei 2 years ago committed by GitHub
commit 61694a9c04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      striker-ui-api/src/lib/buildCondition.ts
  2. 17
      striker-ui-api/src/lib/request_handlers/host/buildQueryHostDetail.ts
  3. 21
      striker-ui-api/src/lib/request_handlers/host/getHost.ts
  4. 10
      striker-ui-api/src/lib/request_handlers/network-interface/getNetworkInterface.ts
  5. 2
      striker-ui-api/src/routes/network-interface.ts
  6. 1
      striker-ui-api/src/types/HostOverview.d.ts
  7. 84
      striker-ui/components/Display/Preview.tsx
  8. 160
      striker-ui/components/NetworkInitForm.tsx
  9. 5
      striker-ui/components/OutlinedLabeledInputWithSelect.tsx
  10. 161
      striker-ui/components/PrepareNetworkForm.tsx
  11. 2
      striker-ui/components/ProvisionServerDialog.tsx
  12. 12
      striker-ui/components/SelectWithLabel.tsx
  13. 17
      striker-ui/components/StrikerConfig/AddPeerDialog.tsx
  14. 41
      striker-ui/components/Tab.tsx
  15. 21
      striker-ui/components/TabContent.tsx
  16. 98
      striker-ui/components/Tabs.tsx
  17. 44
      striker-ui/components/Text/BodyText.tsx
  18. 15
      striker-ui/hooks/useProtectedState.ts
  19. 9
      striker-ui/lib/buildObjectStateSetterCallback.ts
  20. 13
      striker-ui/lib/getQueryParam.ts
  21. 181
      striker-ui/pages/manage-element/index.tsx
  22. 29
      striker-ui/pages/prepare-network/index.tsx
  23. 14
      striker-ui/types/APIHost.d.ts
  24. 6
      striker-ui/types/PrepareNetworkForm.d.ts
  25. 7
      striker-ui/types/SelectWithLabel.d.ts
  26. 4
      striker-ui/types/TabContent.d.ts
  27. 10
      striker-ui/types/Tabs.d.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',

@ -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<string, string>
>(
(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 },
);
});

@ -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),
};

@ -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}';`;
},
);

@ -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;

@ -1,5 +1,6 @@
type HostOverview = {
hostName: string;
hostType: string;
hostUUID: string;
shortHostName: string;
};

@ -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<PreviewProps> = ({
serverUUID,
onClickConnectButton: connectButtonClickHandle = previewClickHandler,
}) => {
const [isPreviewLoading, setIsPreviewLoading] = useState<boolean>(true);
const [isPreviewStale, setIsPreviewStale] = useState<boolean>(false);
const [preview, setPreview] = useState<string>('');
const previewButtonContent = useMemo(
() =>
preview ? (
<Box
alt=""
component="img"
src={`data:image/png;base64,${preview}`}
sx={{
height: '100%',
opacity: isPreviewStale ? '0.4' : '1',
padding: isUseInnerPanel ? '.2em' : 0,
width: '100%',
}}
/>
) : (
<PowerOffOutlinedIcon
sx={{
height: '100%',
width: '100%',
}}
/>
),
[isPreviewStale, isUseInnerPanel, preview],
);
useEffect(() => {
if (isFetchPreview) {
(async () => {
@ -107,11 +135,14 @@ const Preview: FC<PreviewProps> = ({
setIsPreviewStale(false);
} catch {
setIsPreviewStale(true);
} finally {
setIsPreviewLoading(false);
}
})();
} else if (externalPreview) {
setPreview(externalPreview);
setIsPreviewStale(isExternalPreviewStale);
setIsPreviewLoading(false);
}
}, [externalPreview, isExternalPreviewStale, isFetchPreview, serverUUID]);
@ -120,17 +151,12 @@ const Preview: FC<PreviewProps> = ({
<PreviewPanelHeader isUseInnerPanel={isUseInnerPanel} text={serverName}>
{headerEndAdornment}
</PreviewPanelHeader>
<Box
sx={{
display: 'flex',
width: '100%',
'& > :not(:last-child)': {
marginRight: '1em',
},
}}
>
<FlexBox row sx={{ '& > :first-child': { flexGrow: 1 } }}>
{/* Box wrapper below is required to keep external preview size sane. */}
<Box>
{isPreviewLoading ? (
<Spinner mt="1em" mb="1em" />
) : (
<MUIIconButton
component="span"
onClick={previewClickHandler}
@ -140,36 +166,18 @@ const Preview: FC<PreviewProps> = ({
padding: 0,
}}
>
{preview ? (
<Box
alt=""
component="img"
src={`data:image/png;base64,${preview}`}
sx={{
height: '100%',
opacity: isPreviewStale ? '0.4' : '1',
padding: isUseInnerPanel ? '.2em' : 0,
width: '100%',
}}
/>
) : (
<PowerOffOutlinedIcon
sx={{
height: '100%',
width: '100%',
}}
/>
)}
{previewButtonContent}
</MUIIconButton>
)}
</Box>
{isShowControls && (
<Box>
<FlexBox>
<IconButton onClick={connectButtonClickHandle}>
<DesktopWindowsIcon />
</IconButton>
</Box>
</FlexBox>
)}
</Box>
</FlexBox>
</PreviewPanel>
);
};

@ -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<string, string> = {
bcn: 'Back-Channel Network',
ifn: 'Internet-Facing Network',
sn: 'Storage Network',
};
const NODE_NETWORK_TYPES: Record<string, string> = {
...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<NetworkInterfaceInputMap>({});
const [networkInputs, setNetworkInputs] =
useState<NetworkInput[]>(REQUIRED_NETWORKS);
const [networkInputs, setNetworkInputs] = useState<NetworkInput[]>(
hostType === 'node' ? NODE_REQUIRED_NETWORKS : STRIKER_REQUIRED_NETWORKS,
);
const [networkInterfaceHeld, setNetworkInterfaceHeld] = useState<
NetworkInterfaceOverviewMetadata | undefined
>();
@ -599,9 +611,9 @@ const NetworkInitForm = forwardRef<
const dnsCSVInputRef = useRef<InputForwardedRefContent<'string'>>({});
const messageGroupRef = useRef<MessageGroupForwardedRefContent>({});
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<NetworkInterfaceInputMap>((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<NetworkInterfaceInputMap>(
(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<
},
}}
>
<IconButton
disabled={isDisableAddNetworkButton}
onClick={createNetwork}
>
<MUIAddIcon />
</IconButton>
<MUIBox
sx={{
alignItems: 'strech',
@ -1301,8 +1293,10 @@ const NetworkInitForm = forwardRef<
{...{
createDropMouseUpHandler,
getNetworkTypeCount,
hostDetail,
networkIndex,
networkInput,
networkInterfaceCount: networkInterfaces.length,
networkInterfaceInputMap,
removeNetwork,
setMessageRe,
@ -1319,15 +1313,20 @@ const NetworkInitForm = forwardRef<
<FlexBox
sm="row"
sx={{
marginLeft: { sm: '3.8em' },
marginTop: '.2em',
'& > *': {
'& > :not(button)': {
minWidth: networkInputMinWidth,
width: { sm: networkInputWidth },
},
}}
>
<IconButton
disabled={isDisableAddNetworkButton}
onClick={createNetwork}
>
<MUIAddIcon />
</IconButton>
<InputWithRef
input={
<OutlinedInputWithLabel
@ -1345,6 +1344,7 @@ const NetworkInitForm = forwardRef<
setGatewayInputMessage();
}}
label="Gateway"
value={xGateway}
/>
}
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 {

@ -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<OutlinedInputWithLabelProps>;

@ -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<PrepareNetworkFormProps> = ({
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<boolean>(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(
() => (
<PanelHeader>
<HeaderText>
Prepare network on {dataHostDetail?.shortHostName}
</HeaderText>
</PanelHeader>
),
[dataHostDetail],
);
const contentElement = useMemo(() => {
let result;
if (isLoading) {
result = <Spinner mt={0} />;
} else if (fatalErrorMessage) {
result = <MessageBox {...fatalErrorMessage} />;
} else {
result = (
<>
{panelHeaderElement}
<FlexBox>
<InputWithRef
input={
<OutlinedInputWithLabel
formControlProps={{ sx: { maxWidth: '20em' } }}
id="prepare-network-host-name"
label="Host name"
value={dataHostDetail?.hostName}
/>
}
required
/>
<NetworkInitForm hostDetail={dataHostDetail} />
<FlexBox row justifyContent="flex-end">
<ContainedButton>Prepare network</ContainedButton>
</FlexBox>
</FlexBox>
</>
);
}
return result;
}, [dataHostDetail, fatalErrorMessage, isLoading, panelHeaderElement]);
const getHostDetail = useCallback(
(uuid: string) => {
setIsLoading(true);
if (isLoading) {
api
.get<APIHostDetail>(`/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 <Panel>{contentElement}</Panel>;
};
export default PrepareNetworkForm;

@ -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 {

@ -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<SelectWithLabelProps> = ({
SelectWithLabel.defaultProps = SELECT_WITH_LABEL_DEFAULT_PROPS;
export type { SelectItem, SelectWithLabelProps };
export type { SelectWithLabelProps };
export default SelectWithLabel;

@ -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<boolean>(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);

@ -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<MUITabProps> = ({ label: originalLabel, ...restTabProps }) => {
const label = useMemo(
() =>
typeof originalLabel === 'string' ? (
<BodyText inheritColour>{originalLabel}</BodyText>
) : (
originalLabel
),
[originalLabel],
);
return (
<MUITab
{...restTabProps}
label={label}
sx={{
borderRadius: BORDER_RADIUS,
color: GREY,
padding: '.4em .8em',
textTransform: 'none',
[`&.${muiTabClasses.selected}`]: {
color: BLUE,
},
}}
/>
);
};
export default Tab;

@ -0,0 +1,21 @@
import { Box } from '@mui/material';
import { ReactElement, useMemo } from 'react';
const TabContent = <T,>({
changingTabId,
children,
tabId,
}: TabContentProps<T>): ReactElement => {
const isTabIdMatch = useMemo(
() => changingTabId === tabId,
[changingTabId, tabId],
);
const displayValue = useMemo(
() => (isTabIdMatch ? 'initial' : 'none'),
[isTabIdMatch],
);
return <Box sx={{ display: displayValue }}>{children}</Box>;
};
export default TabContent;

@ -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<TabsProps> = ({
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 (
<MUITabs
{...restTabsProps}
orientation={orientation}
variant={variant}
sx={{
minHeight: TABS_MIN_HEIGHT,
[`&.${muiTabsClasses.vertical}`]: {
minHeight: TABS_VERTICAL_MIN_HEIGHT,
[`& .${muiTabClasses.root}`]: {
alignItems: 'flex-start',
minHeight: TABS_VERTICAL_MIN_HEIGHT,
paddingLeft: '2em',
},
[`& .${muiTabsClasses.indicator}`]: {
right: 'initial',
},
},
[`& .${muiTabClasses.root}`]: {
minHeight: TABS_MIN_HEIGHT,
},
[`& .${muiTabsClasses.indicator}`]: {
backgroundColor: BLUE,
borderRadius: BORDER_RADIUS,
},
}}
/>
);
};
export default Tabs;

@ -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<BodyTextOptionalProps> = {
inheritColour: false,
inverted: false,
monospaced: false,
selected: true,
@ -25,6 +27,7 @@ const BODY_TEXT_DEFAULT_PROPS: Required<BodyTextOptionalProps> = {
};
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,20 +67,30 @@ const buildBodyTextClasses = ({
const BodyText: FC<BodyTextProps> = ({
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 (
<MUITypography
{...{
className: `${buildBodyTextClasses({
isInvert: inverted,
isMonospace: monospaced,
isSelect: selected,
})} ${className}`,
className: `${baseClassName} ${className}`,
variant: 'subtitle1',
...muiTypographyRestProps,
sx: {
@ -98,9 +115,10 @@ const BodyText: FC<BodyTextProps> = ({
},
}}
>
{text ?? children}
{content}
</MUITypography>
);
);
};
BodyText.defaultProps = BODY_TEXT_DEFAULT_PROPS;

@ -1,4 +1,6 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { Dispatch, SetStateAction, useMemo, useState } from 'react';
import useProtect from './useProtect';
type SetStateFunction<S> = Dispatch<SetStateAction<S>>;
@ -8,17 +10,24 @@ type SetStateReturnType<S> = ReturnType<SetStateFunction<S>>;
const useProtectedState = <S>(
initialState: S | (() => S),
protect: (
protect?: (
fn: SetStateFunction<S>,
...args: SetStateParameters<S>
) => SetStateReturnType<S>,
): [S, SetStateFunction<S>] => {
const { protect: defaultProtect } = useProtect();
const [state, setState] = useState<S>(initialState);
const pfn = useMemo(
() => protect ?? defaultProtect,
[defaultProtect, protect],
);
return [
state,
(...args: SetStateParameters<S>): SetStateReturnType<S> =>
protect(setState, ...args),
pfn(setState, ...args),
];
};

@ -0,0 +1,9 @@
const buildObjectStateSetterCallback =
<S extends Record<string, unknown>>(key: keyof S, value: S[keyof S]) =>
({ [key]: toReplace, ...restPrevious }: S): S =>
({
...restPrevious,
[key]: value,
} as S);
export default buildObjectStateSetterCallback;

@ -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;

@ -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<string, string> = {
'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 = () => (
<Grid
columns={STEP_CONTENT_GRID_COLUMNS}
layout={{
'preparehost-left-column': {},
'preparehost-center-column': {
children: <PrepareHostForm />,
...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<string | false>(false);
const hostSubTabs = useMemo(() => {
let result: ReactElement | undefined;
if (hostOverviewList) {
const hostOverviewPairs = Object.entries(hostOverviewList);
result = (
<Tabs
onChange={(event, newSubTabId) => {
setHostSubTabId(newSubTabId);
}}
orientation="vertical"
value={hostSubTabId}
>
{hostOverviewPairs.map(([hostUUID, { shortHostName }]) => (
<Tab
key={`prepare-network-${hostUUID}`}
label={shortHostName}
value={hostUUID}
/>
))}
</Tabs>
);
} else {
result = <Spinner mt={0} />;
}
return result;
}, [hostOverviewList, hostSubTabId]);
if (isFirstRender) {
api
.get<APIHostOverviewList>('/host', { params: { types: 'node,dr' } })
.then(({ data }) => {
setHostOverviewList(data);
setHostSubTabId(Object.keys(data)[0]);
})
.catch((error) => {
handleAPIError(error);
});
}
return (
<Grid
columns={STEP_CONTENT_GRID_COLUMNS}
layout={{
'preparenetwork-left-column': {
children: <Panel>{hostSubTabs}</Panel>,
sm: 2,
},
'preparenetwork-center-column': {
children: (
<PrepareNetworkForm
expectUUID
hostUUID={hostSubTabId || undefined}
/>
),
...STEP_CONTENT_GRID_CENTER_COLUMN,
},
}}
/>
);
};
const ManageElement: FC = () => {
const {
isReady,
query: { step: rawStep },
} = useRouter();
const [pageTabId, setPageTabId] = useState<string | false>(false);
const [pageTitle, setPageTitle] = useState<string>(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 (
<>
<Head>
<title>{pageTitle}</title>
</Head>
<Header />
<Panel>
<Tabs
onChange={(event, newTabId) => {
setPageTabId(newTabId);
setPageTitle(MAP_TO_PAGE_TITLE[newTabId]);
}}
orientation={{ xs: 'vertical', sm: 'horizontal' }}
value={pageTabId}
>
<Tab label="Prepare host" value="prepare-host" />
<Tab label="Prepare network" value="prepare-network" />
<Tab label="Manage fence devices" value="manage-fence-devices" />
</Tabs>
</Panel>
<TabContent changingTabId={pageTabId} tabId="prepare-host">
<PrepareHostTabContent />
</TabContent>
<TabContent changingTabId={pageTabId} tabId="prepare-network">
<PrepareNetworkTabContent />
</TabContent>
<TabContent changingTabId={pageTabId} tabId="manage-fence-devices">
{}
</TabContent>
</>
);
};
export default ManageElement;

@ -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 = () => (
<>
<Head>
<title>Prepare Network</title>
</Head>
<Header />
<Grid
columns={{ xs: 1, sm: 6, md: 4 }}
layout={{
'preparehost-left-column': { sm: 1, xs: 0 },
'preparehost-center-column': {
children: <PrepareNetworkForm />,
md: 2,
sm: 4,
xs: 1,
},
}}
/>
</>
);
export default PrepareNetwork;

@ -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[] };

@ -0,0 +1,6 @@
type PrepareNetworkFormOptionalProps = {
expectUUID?: boolean;
hostUUID?: string;
};
type PrepareNetworkFormProps = PrepareNetworkFormOptionalProps;

@ -0,0 +1,7 @@
type SelectItem<
ValueType = string,
DisplayValueType = ValueType | import('react').ReactNode,
> = {
displayValue?: DisplayValueType;
value: ValueType;
};

@ -0,0 +1,4 @@
type TabContentProps<T> = import('react').PropsWithChildren<{
changingTabId: T;
tabId: T;
}>;

@ -0,0 +1,10 @@
type TabsOrientation = Exclude<
import('@mui/material').TabsProps['orientation'],
undefined
>;
type TabsProps = Omit<import('@mui/material').TabsProps, 'orientation'> & {
orientation?:
| TabsOrientation
| Partial<Record<import('@mui/material').Breakpoint, TabsOrientation>>;
};
Loading…
Cancel
Save