Merge pull request #550 from ylei-tsubame/issues/471-force-off-server
Web UI: patch 454, and 471main
commit
971ef9560c
62 changed files with 560 additions and 93 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,103 @@ |
||||
import { MoreVert as MoreVertIcon } from '@mui/icons-material'; |
||||
import { Box } from '@mui/material'; |
||||
import { FC, MouseEventHandler, useCallback, useMemo, useState } from 'react'; |
||||
|
||||
import ContainedButton from './ContainedButton'; |
||||
import IconButton from './IconButton/IconButton'; |
||||
import Menu from './Menu'; |
||||
|
||||
const ButtonWithMenu: FC<ButtonWithMenuProps> = (props) => { |
||||
const { |
||||
children, |
||||
containedButtonProps, |
||||
iconButtonProps, |
||||
muiMenuProps, |
||||
onButtonClick, |
||||
onItemClick, |
||||
variant = 'icon', |
||||
...menuProps |
||||
} = props; |
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); |
||||
|
||||
const open = useMemo(() => Boolean(anchorEl), [anchorEl]); |
||||
|
||||
const buttonContent = useMemo(() => { |
||||
if (children) { |
||||
return children; |
||||
} |
||||
|
||||
if (variant === 'icon') { |
||||
return <MoreVertIcon fontSize={iconButtonProps?.size} />; |
||||
} |
||||
|
||||
return 'Options'; |
||||
}, [children, iconButtonProps?.size, variant]); |
||||
|
||||
const buttonClickHandler = useCallback<MouseEventHandler<HTMLButtonElement>>( |
||||
(...args) => { |
||||
const { |
||||
0: { currentTarget }, |
||||
} = args; |
||||
|
||||
setAnchorEl(currentTarget); |
||||
|
||||
return onButtonClick?.call(null, ...args); |
||||
}, |
||||
[onButtonClick], |
||||
); |
||||
|
||||
const buttonElement = useMemo(() => { |
||||
if (variant === 'contained') { |
||||
return ( |
||||
<ContainedButton onClick={buttonClickHandler} {...containedButtonProps}> |
||||
{buttonContent} |
||||
</ContainedButton> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<IconButton onClick={buttonClickHandler} {...iconButtonProps}> |
||||
{buttonContent} |
||||
</IconButton> |
||||
); |
||||
}, [ |
||||
buttonClickHandler, |
||||
buttonContent, |
||||
containedButtonProps, |
||||
iconButtonProps, |
||||
variant, |
||||
]); |
||||
|
||||
const itemClickHandler = useCallback< |
||||
Exclude<MenuProps['onItemClick'], undefined> |
||||
>( |
||||
(key, value, ...rest) => { |
||||
setAnchorEl(null); |
||||
|
||||
return onItemClick?.call(null, key, value, ...rest); |
||||
}, |
||||
[onItemClick], |
||||
); |
||||
|
||||
return ( |
||||
<Box> |
||||
{buttonElement} |
||||
<Menu |
||||
muiMenuProps={{ |
||||
anchorEl, |
||||
keepMounted: true, |
||||
onClose: () => setAnchorEl(null), |
||||
...muiMenuProps, |
||||
}} |
||||
onItemClick={itemClickHandler} |
||||
open={open} |
||||
{...menuProps} |
||||
/> |
||||
</Box> |
||||
); |
||||
}; |
||||
|
||||
export default ButtonWithMenu as <T>( |
||||
...args: Parameters<FC<ButtonWithMenuProps<T>>> |
||||
) => ReturnType<FC<ButtonWithMenuProps<T>>>; |
@ -0,0 +1,43 @@ |
||||
import { Menu as MuiMenu } from '@mui/material'; |
||||
import { FC, useMemo } from 'react'; |
||||
|
||||
import MenuItem from './MenuItem'; |
||||
|
||||
const Menu: FC<MenuProps> = (props) => { |
||||
const { |
||||
getItemDisabled, |
||||
items = {}, |
||||
muiMenuProps: menuProps, |
||||
onItemClick, |
||||
open, |
||||
renderItem, |
||||
} = props; |
||||
|
||||
const pairs = useMemo(() => Object.entries(items), [items]); |
||||
|
||||
const itemElements = useMemo( |
||||
() => |
||||
pairs.map(([key, value]) => ( |
||||
<MenuItem |
||||
disabled={getItemDisabled?.call(null, key, value)} |
||||
onClick={(...parent) => |
||||
onItemClick?.call(null, key, value, ...parent) |
||||
} |
||||
// The key is only relevant within the same branch; i.e., instance of
|
||||
// the same key under a different parent is OK.
|
||||
key={key} |
||||
> |
||||
{renderItem?.call(null, key, value)} |
||||
</MenuItem> |
||||
)), |
||||
[getItemDisabled, onItemClick, pairs, renderItem], |
||||
); |
||||
|
||||
return ( |
||||
<MuiMenu open={open} {...menuProps}> |
||||
{itemElements} |
||||
</MuiMenu> |
||||
); |
||||
}; |
||||
|
||||
export default Menu as <T>(props: MenuProps<T>) => ReturnType<FC<MenuProps<T>>>; |
@ -0,0 +1,145 @@ |
||||
import { PowerSettingsNew as PowerSettingsNewIcon } from '@mui/icons-material'; |
||||
import { Box } from '@mui/material'; |
||||
import { FC, useMemo } from 'react'; |
||||
|
||||
import api from '../lib/api'; |
||||
import ButtonWithMenu from './ButtonWithMenu'; |
||||
import { MAP_TO_COLOUR } from './ContainedButton'; |
||||
import handleAPIError from '../lib/handleAPIError'; |
||||
import { BodyText } from './Text'; |
||||
import useConfirmDialog from '../hooks/useConfirmDialog'; |
||||
|
||||
const ServerMenu: FC<ServerMenuProps> = (props) => { |
||||
const { |
||||
// Props to ignore, for now:
|
||||
getItemDisabled, |
||||
items, |
||||
onItemClick, |
||||
renderItem, |
||||
// ----------
|
||||
serverName, |
||||
serverState, |
||||
serverUuid, |
||||
...buttonWithMenuProps |
||||
} = props; |
||||
|
||||
const { |
||||
confirmDialog, |
||||
setConfirmDialogOpen, |
||||
setConfirmDialogProps, |
||||
finishConfirm, |
||||
} = useConfirmDialog(); |
||||
|
||||
const powerOptions = useMemo<MapToServerPowerOption>( |
||||
() => ({ |
||||
'force-off': { |
||||
colour: 'red', |
||||
description: ( |
||||
<> |
||||
This is equal to pulling the power cord, which may cause data loss |
||||
or system corruption. |
||||
</> |
||||
), |
||||
label: 'Force off', |
||||
path: `/command/stop-server/${serverUuid}?force=1`, |
||||
}, |
||||
'power-off': { |
||||
description: ( |
||||
<> |
||||
This is equal to pushing the power button. If the server |
||||
doesn't respond to the corresponding signals, you may have to |
||||
manually shut it down. |
||||
</> |
||||
), |
||||
label: 'Power off', |
||||
path: `/command/stop-server/${serverUuid}`, |
||||
}, |
||||
'power-on': { |
||||
description: <>This is equal to pushing the power button.</>, |
||||
label: 'Power on', |
||||
path: `/command/start-server/${serverUuid}`, |
||||
}, |
||||
}), |
||||
[serverUuid], |
||||
); |
||||
|
||||
return ( |
||||
<Box> |
||||
<ButtonWithMenu |
||||
getItemDisabled={(key) => { |
||||
const optionOn = key.includes('on'); |
||||
const serverRunning = serverState === 'running'; |
||||
|
||||
return serverRunning === optionOn; |
||||
}} |
||||
items={powerOptions} |
||||
onItemClick={(key, value) => { |
||||
const { colour, description, label, path } = value; |
||||
|
||||
const op = label.toLocaleLowerCase(); |
||||
|
||||
setConfirmDialogProps({ |
||||
actionProceedText: label, |
||||
content: <BodyText>{description}</BodyText>, |
||||
onProceedAppend: () => { |
||||
setConfirmDialogProps((previous) => ({ |
||||
...previous, |
||||
loading: true, |
||||
})); |
||||
|
||||
api |
||||
.put(path) |
||||
.then(() => { |
||||
finishConfirm('Success', { |
||||
children: ( |
||||
<> |
||||
Successfully registered {op} job on {serverName}. |
||||
</> |
||||
), |
||||
}); |
||||
}) |
||||
.catch((error) => { |
||||
const emsg = handleAPIError(error); |
||||
|
||||
emsg.children = ( |
||||
<> |
||||
Failed to register {op} job on {serverName}; CAUSE:{' '} |
||||
{emsg.children}. |
||||
</> |
||||
); |
||||
|
||||
finishConfirm('Error', emsg); |
||||
}); |
||||
}, |
||||
proceedColour: colour, |
||||
titleText: `${label} server ${serverName}?`, |
||||
}); |
||||
setConfirmDialogOpen(true); |
||||
}} |
||||
renderItem={(key, value) => { |
||||
const { colour, label } = value; |
||||
|
||||
let ccode: string | undefined; |
||||
|
||||
if (colour) { |
||||
ccode = MAP_TO_COLOUR[colour]; |
||||
} |
||||
|
||||
return ( |
||||
<BodyText inheritColour color={ccode}> |
||||
{label} |
||||
</BodyText> |
||||
); |
||||
}} |
||||
{...buttonWithMenuProps} |
||||
> |
||||
<PowerSettingsNewIcon |
||||
fontSize={buttonWithMenuProps?.iconButtonProps?.size} |
||||
/> |
||||
</ButtonWithMenu> |
||||
{confirmDialog} |
||||
</Box> |
||||
); |
||||
}; |
||||
|
||||
export default ServerMenu; |
@ -0,0 +1,73 @@ |
||||
import { |
||||
Dispatch, |
||||
MutableRefObject, |
||||
ReactElement, |
||||
ReactNode, |
||||
SetStateAction, |
||||
useCallback, |
||||
useMemo, |
||||
useRef, |
||||
useState, |
||||
} from 'react'; |
||||
|
||||
import ConfirmDialog from '../components/ConfirmDialog'; |
||||
import MessageBox from '../components/MessageBox'; |
||||
|
||||
const useConfirmDialog = ( |
||||
args: { |
||||
initial?: Partial<ConfirmDialogProps>; |
||||
} = {}, |
||||
): { |
||||
confirmDialog: ReactElement; |
||||
confirmDialogRef: MutableRefObject<ConfirmDialogForwardedRefContent | null>; |
||||
setConfirmDialogOpen: (value: boolean) => void; |
||||
setConfirmDialogProps: Dispatch<SetStateAction<ConfirmDialogProps>>; |
||||
finishConfirm: (title: ReactNode, message: Message) => void; |
||||
} => { |
||||
const { |
||||
initial: { actionProceedText = '', content = '', titleText = '' } = {}, |
||||
} = args; |
||||
|
||||
const confirmDialogRef = useRef<ConfirmDialogForwardedRefContent | null>( |
||||
null, |
||||
); |
||||
|
||||
const [confirmDialogProps, setConfirmDialogProps] = |
||||
useState<ConfirmDialogProps>({ |
||||
actionProceedText, |
||||
content, |
||||
titleText, |
||||
}); |
||||
|
||||
const setConfirmDialogOpen = useCallback( |
||||
(value: boolean) => confirmDialogRef?.current?.setOpen?.call(null, value), |
||||
[], |
||||
); |
||||
|
||||
const finishConfirm = useCallback( |
||||
(title: ReactNode, message: Message) => |
||||
setConfirmDialogProps({ |
||||
actionProceedText: '', |
||||
content: <MessageBox {...message} />, |
||||
showActionArea: false, |
||||
showClose: true, |
||||
titleText: title, |
||||
}), |
||||
[], |
||||
); |
||||
|
||||
const confirmDialog = useMemo<ReactElement>( |
||||
() => <ConfirmDialog {...confirmDialogProps} ref={confirmDialogRef} />, |
||||
[confirmDialogProps], |
||||
); |
||||
|
||||
return { |
||||
confirmDialog, |
||||
confirmDialogRef, |
||||
setConfirmDialogOpen, |
||||
setConfirmDialogProps, |
||||
finishConfirm, |
||||
}; |
||||
}; |
||||
|
||||
export default useConfirmDialog; |
@ -1 +0,0 @@ |
||||
self.__BUILD_MANIFEST=function(s,c,a,e,t,n,i,f,d,b,u,k,h,j,r,g,l){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,a,e,f,d,"static/chunks/82-b2661d1af04f38ff.js",c,t,n,i,h,j,"static/chunks/pages/index-0bfb652a4f03dc4d.js"],"/_error":["static/chunks/pages/_error-2280fa386d040b66.js"],"/anvil":[s,a,e,f,d,"static/chunks/638-13a283c3a7da370b.js",c,t,n,i,h,"static/chunks/pages/anvil-3bce568d47e8eaba.js"],"/config":[s,a,e,u,"static/chunks/519-4b7761e884c88eb9.js",c,t,n,i,b,k,r,"static/chunks/pages/config-511a465cb55668af.js"],"/file-manager":["static/chunks/29107295-fbcfe2172188e46f.js",s,a,e,f,"static/chunks/176-7308c25ba374961e.js",c,t,i,b,"static/chunks/pages/file-manager-a085c3bead0f489f.js"],"/init":[s,a,f,d,u,g,c,t,n,i,l,"static/chunks/pages/init-59fe9f29b6489800.js"],"/login":[s,a,e,c,t,n,b,k,"static/chunks/pages/login-183a6e481fd67cca.js"],"/manage-element":[s,a,e,f,d,u,g,"static/chunks/111-2605129c170ed35d.js",c,t,n,i,b,k,l,r,"static/chunks/pages/manage-element-d8b8ab9027cbbb98.js"],"/server":[s,e,"static/chunks/528-72edc50189f30fa9.js",c,j,"static/chunks/pages/server-8faafa80170f67f2.js"],sortedPages:["/","/_app","/_error","/anvil","/config","/file-manager","/init","/login","/manage-element","/server"]}}("static/chunks/412-d77d0985f9905450.js","static/chunks/62-09a1812bcc63d819.js","static/chunks/438-0147a63d98e89439.js","static/chunks/894-e57948de523bcf96.js","static/chunks/195-fa06e61dd4339031.js","static/chunks/27-7790e406eb2ea28d.js","static/chunks/157-d1418743accab385.js","static/chunks/182-08683bbe95fbb010.js","static/chunks/209-4e2794319babfeec.js","static/chunks/48-d4400834d0a31c6e.js","static/chunks/644-4eec2b397fdacb0c.js","static/chunks/336-33ece0c8120f3bd4.js","static/chunks/707-705fb5e735d81042.js","static/chunks/94-f83c1e7821f76736.js","static/chunks/560-a9c9ecda0eca25a9.js","static/chunks/404-b8e9ff2043a0d30c.js","static/chunks/86-d7025c9609028f44.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); |
@ -0,0 +1 @@ |
||||
self.__BUILD_MANIFEST=function(s,c,e,a,t,n,i,f,u,k,h,j,b,d,r,g,l,_,o){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,e,a,i,u,k,d,"static/chunks/936-f64829e0e2013921.js",c,t,n,f,r,g,"static/chunks/pages/index-8766524a2b0384fc.js"],"/_error":["static/chunks/pages/_error-2280fa386d040b66.js"],"/anvil":[s,e,a,i,u,k,"static/chunks/638-13a283c3a7da370b.js",c,t,n,f,r,"static/chunks/pages/anvil-7fb5cba6fcb66e8c.js"],"/config":[s,e,a,j,"static/chunks/519-4b7761e884c88eb9.js",c,t,n,f,h,b,l,"static/chunks/pages/config-e3aa9a84a8baacc1.js"],"/file-manager":["static/chunks/29107295-fbcfe2172188e46f.js",s,e,a,i,u,"static/chunks/176-7308c25ba374961e.js",c,t,n,h,"static/chunks/pages/file-manager-ef725a93a3e227aa.js"],"/init":[s,e,i,u,k,j,_,c,t,n,f,o,"static/chunks/pages/init-b774a276c8a4ad79.js"],"/login":[s,e,a,c,t,f,h,b,"static/chunks/pages/login-270fe7adf9f44c67.js"],"/manage-element":[s,e,a,i,u,k,j,_,"static/chunks/195-d5fd184cc249f755.js",c,t,n,f,h,b,o,l,"static/chunks/pages/manage-element-e577aadd99900dcb.js"],"/server":[s,a,i,d,c,n,g,"static/chunks/pages/server-97d4cafd19cb2e9d.js"],sortedPages:["/","/_app","/_error","/anvil","/config","/file-manager","/init","/login","/manage-element","/server"]}}("static/chunks/498-e1933a5461cd8607.js","static/chunks/668-b264bf73f0c1b5eb.js","static/chunks/910-2a0e86a170f6eb77.js","static/chunks/894-e57948de523bcf96.js","static/chunks/284-03dc30df5d459e72.js","static/chunks/157-0528651bf3cd10a7.js","static/chunks/839-dabd319a60c8df83.js","static/chunks/27-7790e406eb2ea28d.js","static/chunks/213-a0488f84cc98f172.js","static/chunks/209-4e2794319babfeec.js","static/chunks/48-d4400834d0a31c6e.js","static/chunks/644-4eec2b397fdacb0c.js","static/chunks/336-33ece0c8120f3bd4.js","static/chunks/570-6bad4610969fc14b.js","static/chunks/707-ee38ab2abcd0aa3f.js","static/chunks/170-357f4683929223df.js","static/chunks/560-a9c9ecda0eca25a9.js","static/chunks/404-b8e9ff2043a0d30c.js","static/chunks/86-af7e2d6c5444a983.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,8 @@ |
||||
type ButtonWithMenuOptionalProps<T = unknown> = Omit<MenuProps<T>, 'open'> & { |
||||
containedButtonProps?: Partial<ContainedButtonProps>; |
||||
iconButtonProps?: Partial<import('../components/IconButton').IconButtonProps>; |
||||
onButtonClick?: import('react').MouseEventHandler<HTMLButtonElement>; |
||||
variant?: 'contained' | 'icon'; |
||||
}; |
||||
|
||||
type ButtonWithMenuProps<T = unknown> = ButtonWithMenuOptionalProps<T>; |
@ -0,0 +1,15 @@ |
||||
type MuiMenuProps = import('@mui/material').MenuProps; |
||||
|
||||
type MenuOptionalProps<T = unknown> = Pick<MuiMenuProps, 'open'> & { |
||||
getItemDisabled?: (key: string, value: T) => boolean; |
||||
items?: Record<string, T>; |
||||
muiMenuProps?: Partial<MuiMenuProps>; |
||||
onItemClick?: ( |
||||
key: string, |
||||
value: T, |
||||
...parent: Parameters<MuiMenuItemClickEventHandler> |
||||
) => ReturnType<MuiMenuItemClickEventHandler>; |
||||
renderItem?: (key: string, value: T) => import('react').ReactNode; |
||||
}; |
||||
|
||||
type MenuProps<T = unknown> = MenuOptionalProps<T>; |
@ -0,0 +1,6 @@ |
||||
type MuiMenuItemProps = import('@mui/material').MenuItemProps; |
||||
|
||||
type MuiMenuItemClickEventHandler = Exclude< |
||||
MuiMenuItemProps['onClick'], |
||||
undefined |
||||
>; |
@ -0,0 +1,14 @@ |
||||
type ServerPowerOption = { |
||||
description: import('react').ReactNode; |
||||
label: string; |
||||
path: string; |
||||
colour?: Exclude<ContainedButtonBackground, 'normal'>; |
||||
}; |
||||
|
||||
type MapToServerPowerOption = Record<string, ServerPowerOption>; |
||||
|
||||
type ServerMenuProps = ButtonWithMenuProps & { |
||||
serverName: string; |
||||
serverState: string; |
||||
serverUuid: string; |
||||
}; |
Loading…
Reference in new issue