From a1a00e375be67c65cd178fa9293f0fbe9b13093c Mon Sep 17 00:00:00 2001 From: Tsu-ba-me Date: Wed, 19 Jul 2023 21:08:23 -0400 Subject: [PATCH] fix(striker-ui): restore server VNC to working state --- striker-ui/components/Display/FullSize.tsx | 329 +++++++++---------- striker-ui/components/Display/VncDisplay.tsx | 143 ++++---- striker-ui/types/VncDisplay.d.ts | 33 ++ 3 files changed, 260 insertions(+), 245 deletions(-) create mode 100644 striker-ui/types/VncDisplay.d.ts diff --git a/striker-ui/components/Display/FullSize.tsx b/striker-ui/components/Display/FullSize.tsx index 1b22b089..6d82ef01 100644 --- a/striker-ui/components/Display/FullSize.tsx +++ b/striker-ui/components/Display/FullSize.tsx @@ -4,7 +4,6 @@ import { } from '@mui/icons-material'; import { Box, - IconButton, IconButtonProps, Menu, MenuItem, @@ -12,19 +11,18 @@ import { Typography, } from '@mui/material'; import RFB from '@novnc/novnc/core/rfb'; -import { useState, useRef, useEffect, FC, useCallback } from 'react'; import dynamic from 'next/dynamic'; +import { useState, useEffect, FC, useMemo, useRef, useCallback } from 'react'; -import API_BASE_URL from '../../lib/consts/API_BASE_URL'; -import { BLACK, RED, TEXT } from '../../lib/consts/DEFAULT_THEME'; +import { TEXT } from '../../lib/consts/DEFAULT_THEME'; import ContainedButton from '../ContainedButton'; -import { HeaderText } from '../Text'; +import IconButton from '../IconButton'; import keyCombinations from './keyCombinations'; -import { Panel } from '../Panels'; -import putFetch from '../../lib/fetchers/putFetch'; -import putFetchWithTimeout from '../../lib/fetchers/putFetchWithTimeout'; +import { Panel, PanelHeader } from '../Panels'; import Spinner from '../Spinner'; +import { HeaderText } from '../Text'; +import useIsFirstRender from '../../hooks/useIsFirstRender'; import useProtectedState from '../../hooks/useProtectedState'; const PREFIX = 'FullSize'; @@ -32,9 +30,6 @@ const PREFIX = 'FullSize'; const classes = { displayBox: `${PREFIX}-displayBox`, spinnerBox: `${PREFIX}-spinnerBox`, - closeButton: `${PREFIX}-closeButton`, - keyboardButton: `${PREFIX}-keyboardButton`, - closeBox: `${PREFIX}-closeBox`, buttonsBox: `${PREFIX}-buttonsBox`, keysItem: `${PREFIX}-keysItem`, }; @@ -43,10 +38,6 @@ const StyledDiv = styled('div')(() => ({ [`& .${classes.displayBox}`]: { width: '75vw', height: '75vh', - paddingTop: '1em', - paddingBottom: 0, - paddingLeft: 0, - paddingRight: 0, }, [`& .${classes.spinnerBox}`]: { @@ -57,28 +48,6 @@ const StyledDiv = styled('div')(() => ({ justifyContent: 'center', }, - [`& .${classes.closeButton}`]: { - borderRadius: 8, - backgroundColor: RED, - '&:hover': { - backgroundColor: RED, - }, - }, - - [`& .${classes.keyboardButton}`]: { - borderRadius: 8, - backgroundColor: TEXT, - '&:hover': { - backgroundColor: TEXT, - }, - }, - - [`& .${classes.closeBox}`]: { - paddingBottom: '1em', - paddingLeft: '.7em', - paddingRight: 0, - }, - [`& .${classes.buttonsBox}`]: { paddingTop: 0, }, @@ -92,8 +61,6 @@ const StyledDiv = styled('div')(() => ({ }, })); -const CMD_VNC_PIPE_URL = `${API_BASE_URL}/command/vnc-pipe`; - const VncDisplay = dynamic(() => import('./VncDisplay'), { ssr: false }); type FullSizeOptionalProps = { @@ -105,11 +72,6 @@ type FullSizeProps = FullSizeOptionalProps & { serverName: string | string[] | undefined; }; -type VncConnectionProps = { - protocol: string; - forwardPort: number; -}; - const FULL_SIZE_DEFAULT_PROPS: Required< Omit > & @@ -117,66 +79,33 @@ const FULL_SIZE_DEFAULT_PROPS: Required< onClickCloseButton: undefined, }; +const buildServerVncUrl = (hostname: string, serverUuid: string) => + `ws://${hostname}/ws/server/vnc/${serverUuid}`; + const FullSize: FC = ({ onClickCloseButton, serverUUID, serverName, }): JSX.Element => { - const [anchorEl, setAnchorEl] = useState(null); - const rfb = useRef(); - const hostname = useRef(undefined); - const [vncConnection, setVncConnection] = useProtectedState< - VncConnectionProps | undefined - >(undefined); - const [vncConnecting, setVncConnecting] = useProtectedState(false); - const [isError, setIsError] = useProtectedState(false); + const isFirstRender = useIsFirstRender(); - const connectVnc = useCallback(async () => { - if (vncConnection || vncConnecting) return; - - setVncConnecting(true); + const [anchorEl, setAnchorEl] = useState(null); - try { - const res = await putFetchWithTimeout( - CMD_VNC_PIPE_URL, - { - serverUuid: serverUUID, - open: true, - }, - 120000, - ); - - setVncConnection(await res.json()); - } catch { - setIsError(true); - } finally { - setVncConnecting(false); - } - }, [ - serverUUID, - setIsError, - setVncConnecting, - setVncConnection, - vncConnecting, - vncConnection, - ]); - - useEffect(() => { - if (typeof window !== 'undefined') { - hostname.current = window.location.hostname; - } + const [rfbConnectArgs, setRfbConnectArgs] = useProtectedState< + RfbConnectArgs | undefined + >(undefined); + const [vncConnecting, setVncConnecting] = useProtectedState(false); + const [vncError, setVncError] = useProtectedState(false); - connectVnc(); - }, [connectVnc]); + const rfb = useRef(null); + const rfbScreen = useRef(null); - const handleClick = (event: React.MouseEvent): void => { + const handleClickKeyboard = ( + event: React.MouseEvent, + ): void => { setAnchorEl(event.currentTarget); }; - const handleClickClose = async () => { - await putFetch(CMD_VNC_PIPE_URL, { serverUuid: serverUUID }); - }; - const handleSendKeys = (scans: string[]) => { if (rfb.current) { if (!scans.length) rfb.current.sendCtrlAltDel(); @@ -195,98 +124,156 @@ const FullSize: FC = ({ } }; + // 'connect' event emits when a connection successfully completes. + const rfbConnectEventHandler = useCallback(() => { + setVncConnecting(false); + }, [setVncConnecting]); + + // 'disconnect' event emits when a connection fails, + // OR when a user closes the existing connection. + const rfbDisconnectEventHandler = useCallback( + ({ detail: { clean } }) => { + if (!clean) { + setVncConnecting(false); + setVncError(true); + } + }, + [setVncConnecting, setVncError], + ); + + const connectServerVnc = useCallback(() => { + setVncConnecting(true); + setVncError(false); + + setRfbConnectArgs({ + onConnect: rfbConnectEventHandler, + onDisconnect: rfbDisconnectEventHandler, + rfb, + rfbScreen, + url: buildServerVncUrl(window.location.hostname, serverUUID), + }); + }, [ + rfbConnectEventHandler, + rfbDisconnectEventHandler, + serverUUID, + setRfbConnectArgs, + setVncConnecting, + setVncError, + ]); + + const disconnectServerVnc = useCallback(() => { + setRfbConnectArgs(undefined); + }, [setRfbConnectArgs]); + + const reconnectServerVnc = useCallback(() => { + if (!rfb?.current) return; + + rfb.current.disconnect(); + rfb.current = null; + + connectServerVnc(); + }, [connectServerVnc]); + + const showScreen = useMemo( + () => !vncConnecting && !vncError, + [vncConnecting, vncError], + ); + + const keyboardMenuElement = useMemo( + () => ( + + + + + setAnchorEl(null)} + > + {keyCombinations.map(({ keys, scans }) => ( + handleSendKeys(scans)} + className={classes.keysItem} + key={keys} + > + {keys} + + ))} + + + ), + [anchorEl], + ); + + const vncDisconnectElement = useMemo( + () => ( + { + disconnectServerVnc(); + onClickCloseButton?.call(null, ...args); + }} + variant="redcontained" + > + + + ), + [disconnectServerVnc, onClickCloseButton], + ); + + const vncToolbarElement = useMemo( + () => + showScreen && ( + <> + {keyboardMenuElement} + {vncDisconnectElement} + + ), + [keyboardMenuElement, showScreen, vncDisconnectElement], + ); + + useEffect(() => { + if (isFirstRender) { + connectServerVnc(); + } + }, [connectServerVnc, isFirstRender]); + return ( + + + {vncToolbarElement} + - - + + - {vncConnection ? ( - - { - if (!clean) { - setVncConnection(undefined); - connectVnc(); - } - }} - /> - - - - > - ) => { - handleClickClose(); - onClickCloseButton?.call(null, ...args); - }} - > - - - - - - - - setAnchorEl(null)} - > - {keyCombinations.map(({ keys, scans }) => ( - handleSendKeys(scans)} - className={classes.keysItem} - key={keys} - > - {keys} - - ))} - - - - - ) : ( + {!showScreen && ( - {!isError ? ( + {vncConnecting && ( <> - - + Connecting to {serverName}... - ) : ( + )} + {vncError && ( <> - + + There was a problem connecting to the server, please try + again + { - setIsError(false); + reconnectServerVnc(); }} > Reconnect diff --git a/striker-ui/components/Display/VncDisplay.tsx b/striker-ui/components/Display/VncDisplay.tsx index c9f743fc..c2167abf 100644 --- a/striker-ui/components/Display/VncDisplay.tsx +++ b/striker-ui/components/Display/VncDisplay.tsx @@ -1,83 +1,76 @@ -import { useEffect, useRef, MutableRefObject, memo } from 'react'; import RFB from '@novnc/novnc/core/rfb'; +import { useEffect } from 'react'; -type Props = { - rfb: MutableRefObject; - url: string; - viewOnly: boolean; - focusOnClick: boolean; - clipViewport: boolean; - dragViewport: boolean; - scaleViewport: boolean; - resizeSession: boolean; - showDotCursor: boolean; - background: string; - qualityLevel: number; - compressionLevel: number; - onDisconnect: (event: { detail: { clean: boolean } }) => void; +const rfbConnect: RfbConnectFunction = ({ + background = '', + clipViewport = false, + compressionLevel = 2, + dragViewport = false, + focusOnClick = false, + onConnect, + onDisconnect, + qualityLevel = 6, + resizeSession = true, + rfb, + rfbScreen, + scaleViewport = true, + showDotCursor = false, + url, + viewOnly = false, +}) => { + if (!rfbScreen?.current || rfb?.current) return; + + rfbScreen.current.innerHTML = ''; + + rfb.current = new RFB(rfbScreen.current, url); + + rfb.current.background = background; + rfb.current.clipViewport = clipViewport; + rfb.current.compressionLevel = compressionLevel; + rfb.current.dragViewport = dragViewport; + rfb.current.focusOnClick = focusOnClick; + rfb.current.qualityLevel = qualityLevel; + rfb.current.resizeSession = resizeSession; + rfb.current.scaleViewport = scaleViewport; + rfb.current.showDotCursor = showDotCursor; + rfb.current.viewOnly = viewOnly; + + // RFB extends custom class EventTargetMixin; + // the usual .on or .once doesn't exist. + + if (onConnect) { + rfb.current.addEventListener('connect', onConnect); + } + + if (onDisconnect) { + rfb.current.addEventListener('disconnect', onDisconnect); + } }; -const VncDisplay = (props: Props): JSX.Element => { - const screen = useRef(null); - - const { - rfb, - url, - viewOnly, - focusOnClick, - clipViewport, - dragViewport, - scaleViewport, - resizeSession, - showDotCursor, - background, - qualityLevel, - compressionLevel, - onDisconnect, - } = props; +const rfbDisconnect: RfbDisconnectFunction = (rfb) => { + if (!rfb?.current) return; - useEffect(() => { - if (!screen.current) { - return (): void => { - if (rfb.current) { - rfb?.current.disconnect(); - rfb.current = undefined; - } - }; - } + rfb.current.disconnect(); + rfb.current = null; +}; - if (!rfb.current) { - screen.current.innerHTML = ''; - - rfb.current = new RFB(screen.current, url); - - rfb.current.viewOnly = viewOnly; - rfb.current.focusOnClick = focusOnClick; - rfb.current.clipViewport = clipViewport; - rfb.current.dragViewport = dragViewport; - rfb.current.resizeSession = resizeSession; - rfb.current.scaleViewport = scaleViewport; - rfb.current.showDotCursor = showDotCursor; - rfb.current.background = background; - rfb.current.qualityLevel = qualityLevel; - rfb.current.compressionLevel = compressionLevel; - - // RFB extends custom class EventTargetMixin; - // the usual .on or .once doesn't exist. - rfb.current.addEventListener('disconnect', onDisconnect); - } +const VncDisplay = (props: VncDisplayProps): JSX.Element => { + const { rfb, rfbConnectPartialArgs, rfbScreen } = props; - /* eslint-disable consistent-return */ - if (!rfb.current) return; + useEffect(() => { + if (rfbConnectPartialArgs) { + rfbConnect({ rfb, rfbScreen, ...rfbConnectPartialArgs }); + } else { + rfbDisconnect(rfb); + } + }, [rfb, rfbConnectPartialArgs, rfbScreen]); - return (): void => { - if (rfb.current) { - rfb.current.disconnect(); - rfb.current = undefined; - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rfb]); + useEffect( + () => () => { + rfbDisconnect(rfb); + }, + [rfb], + ); const handleMouseEnter = () => { if ( @@ -93,10 +86,12 @@ const VncDisplay = (props: Props): JSX.Element => { return (
); }; -export default memo(VncDisplay); +VncDisplay.displayName = 'VncDisplay'; + +export default VncDisplay; diff --git a/striker-ui/types/VncDisplay.d.ts b/striker-ui/types/VncDisplay.d.ts new file mode 100644 index 00000000..d28af9e5 --- /dev/null +++ b/striker-ui/types/VncDisplay.d.ts @@ -0,0 +1,33 @@ +type RfbRef = import('react').MutableRefObject< + typeof import('@novnc/novnc/core/rfb') | null +>; + +type RfbScreenRef = import('react').MutableRefObject; + +type RfbConnectArgs = { + background?: string; + clipViewport?: boolean; + compressionLevel?: number; + dragViewport?: boolean; + focusOnClick?: boolean; + onConnect?: () => void; + onDisconnect?: (event: { detail: { clean: boolean } }) => void; + qualityLevel?: number; + resizeSession?: boolean; + rfb: RfbRef; + rfbScreen: RfbScreenRef; + scaleViewport?: boolean; + showDotCursor?: boolean; + url: string; + viewOnly?: boolean; +}; + +type RfbConnectFunction = (args: RfbConnectArgs) => void; + +type RfbDisconnectFunction = (rfb: RfbRef) => void; + +type VncDisplayProps = { + rfb: RfbRef; + rfbConnectPartialArgs?: Omit; + rfbScreen: RfbScreenRef; +};