diff --git a/packages/teleport/src/Player/DesktopPlayer.tsx b/packages/teleport/src/Player/DesktopPlayer.tsx index 3249d3e49..5f55aae31 100644 --- a/packages/teleport/src/Player/DesktopPlayer.tsx +++ b/packages/teleport/src/Player/DesktopPlayer.tsx @@ -1,11 +1,13 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import styled from 'styled-components'; -import { Indicator, Box } from 'design'; +import { Indicator, Box, Alert } from 'design'; + +import useAttempt from 'shared/hooks/useAttemptNext'; import cfg from 'teleport/config'; -import { PlayerClient } from 'teleport/lib/tdp'; +import { PlayerClient, PlayerClientEvent } from 'teleport/lib/tdp'; import { PngFrame, ClientScreenSpec } from 'teleport/lib/tdp/codec'; import { getAccessToken, getHostName } from 'teleport/services/api'; import TdpClientCanvas from 'teleport/components/TdpClientCanvas'; @@ -25,44 +27,53 @@ export const DesktopPlayer = ({ playerClient, tdpCliOnPngFrame, tdpCliOnClientScreenSpec, - showCanvas, + tdpCliOnWsClose, + tdpCliOnTdpError, + attempt, } = useDesktopPlayer({ sid, clusterId, }); + const displayCanvas = attempt.status === 'success' || attempt.status === ''; + const displayProgressBar = attempt.status !== 'processing'; + return ( - <> - - {!showCanvas && ( - - - - )} - - true} - // overflow: 'hidden' is needed to prevent the canvas from outgrowing the container due to some weird css flex idiosyncracy. - // See https://gaurav5430.medium.com/css-flex-positioning-gotchas-child-expands-to-more-than-the-width-allowed-by-the-parent-799c37428dd6. - style={{ - alignSelf: 'center', - overflow: 'hidden', - display: showCanvas ? 'flex' : 'none', - }} - /> - - - + + {attempt.status === 'processing' && ( + + + + )} + + {attempt.status === 'failed' && ( + + )} + + true} + // overflow: 'hidden' is needed to prevent the canvas from outgrowing the container due to some weird css flex idiosyncracy. + // See https://gaurav5430.medium.com/css-flex-positioning-gotchas-child-expands-to-more-than-the-width-allowed-by-the-parent-799c37428dd6. + style={{ + alignSelf: 'center', + overflow: 'hidden', + display: displayCanvas ? 'flex' : 'none', + }} + /> + + ); }; @@ -73,15 +84,21 @@ const useDesktopPlayer = ({ sid: string; clusterId: string; }) => { - const playerClient = new PlayerClient( - cfg.api.desktopPlaybackWsAddr - .replace(':fqdn', getHostName()) - .replace(':clusterId', clusterId) - .replace(':sid', sid) - .replace(':token', getAccessToken()) - ); - - const [showCanvas, setShowCanvas] = useState(false); + const [playerClient, setPlayerClient] = useState(null); + // attempt.status === '' means the playback ended gracefully + const { attempt, setAttempt } = useAttempt('processing'); + + useEffect(() => { + setPlayerClient( + new PlayerClient( + cfg.api.desktopPlaybackWsAddr + .replace(':fqdn', getHostName()) + .replace(':clusterId', clusterId) + .replace(':sid', sid) + .replace(':token', getAccessToken()) + ) + ); + }, [clusterId, sid]); const tdpCliOnPngFrame = ( ctx: CanvasRenderingContext2D, @@ -113,14 +130,61 @@ const useDesktopPlayer = ({ canvas.width = spec.width; canvas.height = spec.height; - setShowCanvas(true); + setAttempt({ status: 'success' }); + }; + + useEffect(() => { + if (playerClient) { + playerClient.addListener(PlayerClientEvent.SESSION_END, () => { + setAttempt({ status: '' }); + }); + + playerClient.addListener( + PlayerClientEvent.PLAYBACK_ERROR, + (err: Error) => { + setAttempt({ + status: 'failed', + statusText: `There was an error while playing this session: ${err.message}`, + }); + } + ); + + return () => { + playerClient.nuke(); + }; + } + }, [playerClient]); + + // If the websocket closed for some reason other than the session playback ending, + // as signaled by the server (which sets prevAttempt.status = '' in + // the PlayerClientEvent.SESSION_END event handler), or a TDP message from the server + // signalling an error, assume some sort of network or playback error and alert the user. + const tdpCliOnWsClose = () => { + setAttempt(prevAttempt => { + if (prevAttempt.status !== '' && prevAttempt.status !== 'failed') { + return { + status: 'failed', + statusText: 'connection to the server failed for an unknown reason', + }; + } + return prevAttempt; + }); + }; + + const tdpCliOnTdpError = (err: Error) => { + setAttempt({ + status: 'failed', + statusText: err.message, + }); }; return { playerClient, tdpCliOnPngFrame, tdpCliOnClientScreenSpec, - showCanvas, + tdpCliOnWsClose, + tdpCliOnTdpError, + attempt, }; }; @@ -131,3 +195,12 @@ const StyledPlayer = styled.div` width: 100%; height: 100%; `; + +const DesktopPlayerAlert = styled(Alert)` + align-self: center; + min-width: 450px; + + // Overrides StyledPlayer container's justify-content + // https://stackoverflow.com/a/34063808/6277051 + margin-bottom: auto; +`; diff --git a/packages/teleport/src/Player/ProgressBar/ProgressBarDesktop.tsx b/packages/teleport/src/Player/ProgressBar/ProgressBarDesktop.tsx index 4fb2f1661..b892fa888 100644 --- a/packages/teleport/src/Player/ProgressBar/ProgressBarDesktop.tsx +++ b/packages/teleport/src/Player/ProgressBar/ProgressBarDesktop.tsx @@ -4,7 +4,11 @@ import { throttle } from 'lodash'; import { dateToUtc } from 'shared/services/loc'; import { format } from 'date-fns'; -import { PlayerClient, PlayerClientEvent } from 'teleport/lib/tdp'; +import { + PlayerClient, + PlayerClientEvent, + TdpClientEvent, +} from 'teleport/lib/tdp'; import ProgressBar from './ProgressBar'; @@ -29,47 +33,57 @@ export const ProgressBarDesktop = (props: { }); useEffect(() => { - playerClient.addListener(PlayerClientEvent.TOGGLE_PLAY_PAUSE, () => { - // setState({...state, isPlaying: !state.isPlaying}) doesn't work because - // the listener is added when state == initialState, and that initialState - // value is effectively hardcoded into its logic. - setState(prevState => { - return { ...prevState, isPlaying: !prevState.isPlaying }; + if (playerClient) { + playerClient.addListener(PlayerClientEvent.TOGGLE_PLAY_PAUSE, () => { + // setState({...state, isPlaying: !state.isPlaying}) doesn't work because + // the listener is added when state == initialState, and that initialState + // value is effectively hardcoded into its logic. + setState(prevState => { + return { ...prevState, isPlaying: !prevState.isPlaying }; + }); }); - }); - const throttledUpdateCurrentTime = throttle( - currentTimeMs => { + const throttledUpdateCurrentTime = throttle( + currentTimeMs => { + setState(prevState => { + return { + ...prevState, + current: currentTimeMs, + time: toHuman(currentTimeMs), + }; + }); + }, + // Magic number to throttle progress bar updates so that the playback is smoother. + 50 + ); + + playerClient.addListener( + PlayerClientEvent.UPDATE_CURRENT_TIME, + currentTimeMs => throttledUpdateCurrentTime(currentTimeMs) + ); + + const progressToEnd = () => { + throttledUpdateCurrentTime.cancel(); + // TODO(isaiah): Make this smoother + // https://github.com/gravitational/webapps/issues/579 setState(prevState => { - return { - ...prevState, - current: currentTimeMs, - time: toHuman(currentTimeMs), - }; + return { ...prevState, current: durationMs }; }); - }, - // Magic number to throttle progress bar updates so that the playback is smoother. - 50 - ); + }; - playerClient.addListener( - PlayerClientEvent.UPDATE_CURRENT_TIME, - currentTimeMs => throttledUpdateCurrentTime(currentTimeMs) - ); + playerClient.addListener(PlayerClientEvent.SESSION_END, () => { + progressToEnd(); + }); - playerClient.addListener(PlayerClientEvent.SESSION_END, () => { - throttledUpdateCurrentTime.cancel(); - // TODO(isaiah): Make this smoother - // https://github.com/gravitational/webapps/issues/579 - setState(prevState => { - return { ...prevState, current: durationMs }; + playerClient.addListener(TdpClientEvent.TDP_ERROR, () => { + progressToEnd(); }); - }); - return () => { - throttledUpdateCurrentTime.cancel(); - playerClient.nuke(); - }; + return () => { + throttledUpdateCurrentTime.cancel(); + playerClient.nuke(); + }; + } }, [playerClient]); return ( diff --git a/packages/teleport/src/lib/tdp/client.ts b/packages/teleport/src/lib/tdp/client.ts index 484889f59..0f211e4fb 100644 --- a/packages/teleport/src/lib/tdp/client.ts +++ b/packages/teleport/src/lib/tdp/client.ts @@ -228,6 +228,8 @@ export default class Client extends EventEmitterWebAuthnSender { // Ensures full cleanup of this object. // Note that it removes all listeners first and then cleans up the socket, // so don't call this if your calling object is relying on listeners. + // It's safe to call this multiple times, calls subsequent to the first call + // will simply do nothing. nuke() { this.removeAllListeners(); this.socket?.close(); diff --git a/packages/teleport/src/lib/tdp/playerClient.ts b/packages/teleport/src/lib/tdp/playerClient.ts index e0c190dc5..2cd1c344d 100644 --- a/packages/teleport/src/lib/tdp/playerClient.ts +++ b/packages/teleport/src/lib/tdp/playerClient.ts @@ -24,6 +24,7 @@ export enum PlayerClientEvent { TOGGLE_PLAY_PAUSE = 'play/pause', UPDATE_CURRENT_TIME = 'time', SESSION_END = 'end', + PLAYBACK_ERROR = 'playback error', } export class PlayerClient extends Client { @@ -42,13 +43,16 @@ export class PlayerClient extends Client { // Overrides Client implementation. processMessage(buffer: ArrayBuffer) { const json = JSON.parse(this.textDecoder.decode(buffer)); + if (json.message === 'end') { this.emit(PlayerClientEvent.SESSION_END); - return; + } else if (json.message === 'error') { + this.emit(PlayerClientEvent.PLAYBACK_ERROR, new Error(json.errorText)); + } else { + const ms = json.ms; + this.emit(PlayerClientEvent.UPDATE_CURRENT_TIME, ms); + super.processMessage(base64ToArrayBuffer(json.message)); } - const ms = json.ms; - super.processMessage(base64ToArrayBuffer(json.message)); - this.emit(PlayerClientEvent.UPDATE_CURRENT_TIME, ms); } // Overrides Client implementation.