Merge pull request #307 from ylei-tsubame/host-network-config
Add prepare network form and connector pagemain
commit
61694a9c04
27 changed files with 844 additions and 214 deletions
@ -1,5 +1,6 @@ |
||||
type HostOverview = { |
||||
hostName: string; |
||||
hostType: string; |
||||
hostUUID: string; |
||||
shortHostName: string; |
||||
}; |
||||
|
@ -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; |
@ -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; |
@ -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; |
@ -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…
Reference in new issue