fix(striker-ui): restore server VNC to working state

main
Tsu-ba-me 1 year ago committed by Yanhao Lei
parent 04390bc416
commit eb907affbc
  1. 329
      striker-ui/components/Display/FullSize.tsx
  2. 143
      striker-ui/components/Display/VncDisplay.tsx
  3. 33
      striker-ui/types/VncDisplay.d.ts

@ -4,7 +4,6 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import { import {
Box, Box,
IconButton,
IconButtonProps, IconButtonProps,
Menu, Menu,
MenuItem, MenuItem,
@ -12,19 +11,18 @@ import {
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import RFB from '@novnc/novnc/core/rfb'; import RFB from '@novnc/novnc/core/rfb';
import { useState, useRef, useEffect, FC, useCallback } from 'react';
import dynamic from 'next/dynamic'; 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 { TEXT } from '../../lib/consts/DEFAULT_THEME';
import { BLACK, RED, TEXT } from '../../lib/consts/DEFAULT_THEME';
import ContainedButton from '../ContainedButton'; import ContainedButton from '../ContainedButton';
import { HeaderText } from '../Text'; import IconButton from '../IconButton';
import keyCombinations from './keyCombinations'; import keyCombinations from './keyCombinations';
import { Panel } from '../Panels'; import { Panel, PanelHeader } from '../Panels';
import putFetch from '../../lib/fetchers/putFetch';
import putFetchWithTimeout from '../../lib/fetchers/putFetchWithTimeout';
import Spinner from '../Spinner'; import Spinner from '../Spinner';
import { HeaderText } from '../Text';
import useIsFirstRender from '../../hooks/useIsFirstRender';
import useProtectedState from '../../hooks/useProtectedState'; import useProtectedState from '../../hooks/useProtectedState';
const PREFIX = 'FullSize'; const PREFIX = 'FullSize';
@ -32,9 +30,6 @@ const PREFIX = 'FullSize';
const classes = { const classes = {
displayBox: `${PREFIX}-displayBox`, displayBox: `${PREFIX}-displayBox`,
spinnerBox: `${PREFIX}-spinnerBox`, spinnerBox: `${PREFIX}-spinnerBox`,
closeButton: `${PREFIX}-closeButton`,
keyboardButton: `${PREFIX}-keyboardButton`,
closeBox: `${PREFIX}-closeBox`,
buttonsBox: `${PREFIX}-buttonsBox`, buttonsBox: `${PREFIX}-buttonsBox`,
keysItem: `${PREFIX}-keysItem`, keysItem: `${PREFIX}-keysItem`,
}; };
@ -43,10 +38,6 @@ const StyledDiv = styled('div')(() => ({
[`& .${classes.displayBox}`]: { [`& .${classes.displayBox}`]: {
width: '75vw', width: '75vw',
height: '75vh', height: '75vh',
paddingTop: '1em',
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
}, },
[`& .${classes.spinnerBox}`]: { [`& .${classes.spinnerBox}`]: {
@ -57,28 +48,6 @@ const StyledDiv = styled('div')(() => ({
justifyContent: 'center', 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}`]: { [`& .${classes.buttonsBox}`]: {
paddingTop: 0, 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 }); const VncDisplay = dynamic(() => import('./VncDisplay'), { ssr: false });
type FullSizeOptionalProps = { type FullSizeOptionalProps = {
@ -105,11 +72,6 @@ type FullSizeProps = FullSizeOptionalProps & {
serverName: string | string[] | undefined; serverName: string | string[] | undefined;
}; };
type VncConnectionProps = {
protocol: string;
forwardPort: number;
};
const FULL_SIZE_DEFAULT_PROPS: Required< const FULL_SIZE_DEFAULT_PROPS: Required<
Omit<FullSizeOptionalProps, 'onClickCloseButton'> Omit<FullSizeOptionalProps, 'onClickCloseButton'>
> & > &
@ -117,66 +79,33 @@ const FULL_SIZE_DEFAULT_PROPS: Required<
onClickCloseButton: undefined, onClickCloseButton: undefined,
}; };
const buildServerVncUrl = (hostname: string, serverUuid: string) =>
`ws://${hostname}/ws/server/vnc/${serverUuid}`;
const FullSize: FC<FullSizeProps> = ({ const FullSize: FC<FullSizeProps> = ({
onClickCloseButton, onClickCloseButton,
serverUUID, serverUUID,
serverName, serverName,
}): JSX.Element => { }): JSX.Element => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const isFirstRender = useIsFirstRender();
const rfb = useRef<typeof RFB>();
const hostname = useRef<string | undefined>(undefined);
const [vncConnection, setVncConnection] = useProtectedState<
VncConnectionProps | undefined
>(undefined);
const [vncConnecting, setVncConnecting] = useProtectedState<boolean>(false);
const [isError, setIsError] = useProtectedState<boolean>(false);
const connectVnc = useCallback(async () => { const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
if (vncConnection || vncConnecting) return;
setVncConnecting(true);
try { const [rfbConnectArgs, setRfbConnectArgs] = useProtectedState<
const res = await putFetchWithTimeout( RfbConnectArgs | undefined
CMD_VNC_PIPE_URL, >(undefined);
{ const [vncConnecting, setVncConnecting] = useProtectedState<boolean>(false);
serverUuid: serverUUID, const [vncError, setVncError] = useProtectedState<boolean>(false);
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;
}
connectVnc(); const rfb = useRef<typeof RFB | null>(null);
}, [connectVnc]); const rfbScreen = useRef<HTMLDivElement | null>(null);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>): void => { const handleClickKeyboard = (
event: React.MouseEvent<HTMLButtonElement>,
): void => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
}; };
const handleClickClose = async () => {
await putFetch(CMD_VNC_PIPE_URL, { serverUuid: serverUUID });
};
const handleSendKeys = (scans: string[]) => { const handleSendKeys = (scans: string[]) => {
if (rfb.current) { if (rfb.current) {
if (!scans.length) rfb.current.sendCtrlAltDel(); if (!scans.length) rfb.current.sendCtrlAltDel();
@ -195,98 +124,156 @@ const FullSize: FC<FullSizeProps> = ({
} }
}; };
// '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(
() => (
<Box>
<IconButton onClick={handleClickKeyboard}>
<KeyboardIcon />
</IconButton>
<Menu
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
{keyCombinations.map(({ keys, scans }) => (
<MenuItem
onClick={() => handleSendKeys(scans)}
className={classes.keysItem}
key={keys}
>
<Typography variant="subtitle1">{keys}</Typography>
</MenuItem>
))}
</Menu>
</Box>
),
[anchorEl],
);
const vncDisconnectElement = useMemo(
() => (
<IconButton
onClick={(...args) => {
disconnectServerVnc();
onClickCloseButton?.call(null, ...args);
}}
variant="redcontained"
>
<CloseIcon />
</IconButton>
),
[disconnectServerVnc, onClickCloseButton],
);
const vncToolbarElement = useMemo(
() =>
showScreen && (
<>
{keyboardMenuElement}
{vncDisconnectElement}
</>
),
[keyboardMenuElement, showScreen, vncDisconnectElement],
);
useEffect(() => {
if (isFirstRender) {
connectServerVnc();
}
}, [connectServerVnc, isFirstRender]);
return ( return (
<Panel> <Panel>
<PanelHeader>
<HeaderText text={`Server: ${serverName}`} />
{vncToolbarElement}
</PanelHeader>
<StyledDiv> <StyledDiv>
<Box flexGrow={1}> <Box
<HeaderText text={`Server: ${serverName}`} /> display={showScreen ? 'flex' : 'none'}
className={classes.displayBox}
>
<VncDisplay
rfb={rfb}
rfbConnectPartialArgs={rfbConnectArgs}
rfbScreen={rfbScreen}
/>
</Box> </Box>
{vncConnection ? ( {!showScreen && (
<Box display="flex" className={classes.displayBox}>
<VncDisplay
rfb={rfb}
url={`${vncConnection.protocol}://${hostname.current}:${vncConnection.forwardPort}`}
viewOnly={false}
focusOnClick={false}
clipViewport={false}
dragViewport={false}
scaleViewport
resizeSession
showDotCursor={false}
background=""
qualityLevel={6}
compressionLevel={2}
onDisconnect={({ detail: { clean } }) => {
if (!clean) {
setVncConnection(undefined);
connectVnc();
}
}}
/>
<Box>
<Box className={classes.closeBox}>
<IconButton
className={classes.closeButton}
style={{ color: TEXT }}
component="span"
onClick={(
...args: Parameters<
Exclude<IconButtonProps['onClick'], undefined>
>
) => {
handleClickClose();
onClickCloseButton?.call(null, ...args);
}}
>
<CloseIcon />
</IconButton>
</Box>
<Box className={classes.closeBox}>
<IconButton
className={classes.keyboardButton}
style={{ color: BLACK }}
component="span"
onClick={handleClick}
>
<KeyboardIcon />
</IconButton>
<Menu
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
{keyCombinations.map(({ keys, scans }) => (
<MenuItem
onClick={() => handleSendKeys(scans)}
className={classes.keysItem}
key={keys}
>
<Typography variant="subtitle1">{keys}</Typography>
</MenuItem>
))}
</Menu>
</Box>
</Box>
</Box>
) : (
<Box display="flex" className={classes.spinnerBox}> <Box display="flex" className={classes.spinnerBox}>
{!isError ? ( {vncConnecting && (
<> <>
<HeaderText <HeaderText>Connecting to {serverName}...</HeaderText>
text={`Establishing connection with ${serverName}`}
/>
<HeaderText text="This may take a few minutes" />
<Spinner /> <Spinner />
</> </>
) : ( )}
{vncError && (
<> <>
<Box style={{ paddingBottom: '2em' }}> <Box style={{ paddingBottom: '2em' }}>
<HeaderText text="There was a problem connecting to the server, please try again" /> <HeaderText textAlign="center">
There was a problem connecting to the server, please try
again
</HeaderText>
</Box> </Box>
<ContainedButton <ContainedButton
onClick={() => { onClick={() => {
setIsError(false); reconnectServerVnc();
}} }}
> >
Reconnect Reconnect

@ -1,83 +1,76 @@
import { useEffect, useRef, MutableRefObject, memo } from 'react';
import RFB from '@novnc/novnc/core/rfb'; import RFB from '@novnc/novnc/core/rfb';
import { useEffect } from 'react';
type Props = { const rfbConnect: RfbConnectFunction = ({
rfb: MutableRefObject<typeof RFB | undefined>; background = '',
url: string; clipViewport = false,
viewOnly: boolean; compressionLevel = 2,
focusOnClick: boolean; dragViewport = false,
clipViewport: boolean; focusOnClick = false,
dragViewport: boolean; onConnect,
scaleViewport: boolean; onDisconnect,
resizeSession: boolean; qualityLevel = 6,
showDotCursor: boolean; resizeSession = true,
background: string; rfb,
qualityLevel: number; rfbScreen,
compressionLevel: number; scaleViewport = true,
onDisconnect: (event: { detail: { clean: boolean } }) => void; 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 rfbDisconnect: RfbDisconnectFunction = (rfb) => {
const screen = useRef<HTMLDivElement>(null); if (!rfb?.current) return;
const {
rfb,
url,
viewOnly,
focusOnClick,
clipViewport,
dragViewport,
scaleViewport,
resizeSession,
showDotCursor,
background,
qualityLevel,
compressionLevel,
onDisconnect,
} = props;
useEffect(() => { rfb.current.disconnect();
if (!screen.current) { rfb.current = null;
return (): void => { };
if (rfb.current) {
rfb?.current.disconnect();
rfb.current = undefined;
}
};
}
if (!rfb.current) { const VncDisplay = (props: VncDisplayProps): JSX.Element => {
screen.current.innerHTML = ''; const { rfb, rfbConnectPartialArgs, rfbScreen } = props;
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);
}
/* eslint-disable consistent-return */ useEffect(() => {
if (!rfb.current) return; if (rfbConnectPartialArgs) {
rfbConnect({ rfb, rfbScreen, ...rfbConnectPartialArgs });
} else {
rfbDisconnect(rfb);
}
}, [rfb, rfbConnectPartialArgs, rfbScreen]);
return (): void => { useEffect(
if (rfb.current) { () => () => {
rfb.current.disconnect(); rfbDisconnect(rfb);
rfb.current = undefined; },
} [rfb],
}; );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rfb]);
const handleMouseEnter = () => { const handleMouseEnter = () => {
if ( if (
@ -93,10 +86,12 @@ const VncDisplay = (props: Props): JSX.Element => {
return ( return (
<div <div
style={{ width: '100%', height: '75vh' }} style={{ width: '100%', height: '75vh' }}
ref={screen} ref={rfbScreen}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
/> />
); );
}; };
export default memo(VncDisplay); VncDisplay.displayName = 'VncDisplay';
export default VncDisplay;

@ -0,0 +1,33 @@
type RfbRef = import('react').MutableRefObject<
typeof import('@novnc/novnc/core/rfb') | null
>;
type RfbScreenRef = import('react').MutableRefObject<HTMLDivElement | null>;
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<RfbConnectArgs, 'rfb' | 'rfbScreen'>;
rfbScreen: RfbScreenRef;
};
Loading…
Cancel
Save