diff --git a/web/packages/design/src/Icon/Icons.story.tsx b/web/packages/design/src/Icon/Icons.story.tsx index 310247742514b..75643602fd429 100644 --- a/web/packages/design/src/Icon/Icons.story.tsx +++ b/web/packages/design/src/Icon/Icons.story.tsx @@ -124,6 +124,7 @@ export const Icons = () => ( + diff --git a/web/packages/design/src/Icon/Icons/FastForward.tsx b/web/packages/design/src/Icon/Icons/FastForward.tsx new file mode 100644 index 0000000000000..38ec3b7bd1b8b --- /dev/null +++ b/web/packages/design/src/Icon/Icons/FastForward.tsx @@ -0,0 +1,78 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import { forwardRef } from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export const FastForward = forwardRef( + ({ size = 24, color, ...otherProps }, ref) => ( + + + + + ) +); diff --git a/web/packages/design/src/Icon/assets/FastForward.svg b/web/packages/design/src/Icon/assets/FastForward.svg new file mode 100644 index 0000000000000..79e5ef336c4a8 --- /dev/null +++ b/web/packages/design/src/Icon/assets/FastForward.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/packages/design/src/Icon/index.ts b/web/packages/design/src/Icon/index.ts index dd52f898b9500..4682e40eff00d 100644 --- a/web/packages/design/src/Icon/index.ts +++ b/web/packages/design/src/Icon/index.ts @@ -113,6 +113,7 @@ export { EnvelopeOpen } from './Icons/EnvelopeOpen'; export { EqualizersVertical } from './Icons/EqualizersVertical'; export { Expand } from './Icons/Expand'; export { Facebook } from './Icons/Facebook'; +export { FastForward } from './Icons/FastForward'; export { FilmStrip } from './Icons/FilmStrip'; export { FingerprintSimple } from './Icons/FingerprintSimple'; export { Floppy } from './Icons/Floppy'; diff --git a/web/packages/teleport/src/SessionRecordings/view/CurrentEventInfo.tsx b/web/packages/teleport/src/SessionRecordings/view/CurrentEventInfo.tsx new file mode 100644 index 0000000000000..d5267bf1510aa --- /dev/null +++ b/web/packages/teleport/src/SessionRecordings/view/CurrentEventInfo.tsx @@ -0,0 +1,134 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, + type Ref, +} from 'react'; +import styled from 'styled-components'; + +import { ButtonPrimary } from 'design/Button'; +import { FastForward } from 'design/Icon'; + +import { + SessionRecordingEventType, + type SessionRecordingEvent, +} from 'teleport/services/recordings'; +import { formatSessionRecordingDuration } from 'teleport/SessionRecordings/list/RecordingItem'; + +export interface CurrentEventInfoHandle { + setTime: (time: number) => void; +} + +interface CurrentEventInfoProps { + events: SessionRecordingEvent[]; + onSeek: (time: number) => void; + ref?: Ref; +} + +const EventsList = styled.div` + display: flex; + flex-direction: column; + gap: ${props => props.theme.space[2]}px; + position: absolute; + top: ${props => props.theme.space[4]}px; + right: ${props => props.theme.space[4]}px; + z-index: 2; +`; + +export function CurrentEventInfo({ + events, + onSeek, + ref, +}: CurrentEventInfoProps) { + const [currentEvents, setCurrentEvents] = useState( + [] + ); + const currentEventsRef = useRef([]); + + useImperativeHandle(ref, () => ({ + setTime(time: number) { + const eventsInTimePeriod = events.filter( + e => e.startTime <= time && e.endTime >= time + ); + + const hasChanged = + eventsInTimePeriod.length !== currentEventsRef.current.length || + eventsInTimePeriod.some( + (event, index) => event !== currentEventsRef.current[index] + ); + + if (hasChanged) { + currentEventsRef.current = eventsInTimePeriod; + setCurrentEvents(eventsInTimePeriod); + } + }, + })); + + const handleSkipToEnd = useCallback( + (time: number) => { + onSeek(time + 1); + }, + [onSeek] + ); + + const items = useMemo(() => { + if (currentEvents.length === 0) { + return null; + } + + const items: ReactNode[] = []; + + for (const [index, event] of currentEvents.entries()) { + if (event.type !== SessionRecordingEventType.Inactivity) { + continue; + } + + items.push( + { + handleSkipToEnd(event.endTime); + }} + px={2} + > + Skip {formatSessionRecordingDuration(event.endTime - event.startTime)}{' '} + of inactivity + + + ); + } + + if (items.length === 0) { + return null; + } + + return items; + }, [currentEvents, handleSkipToEnd]); + + if (currentEvents.length === 0) { + return null; + } + + return {items}; +} diff --git a/web/packages/teleport/src/SessionRecordings/view/RecordingPlayer.tsx b/web/packages/teleport/src/SessionRecordings/view/RecordingPlayer.tsx index 9d25beb0a9931..4939127872dc3 100644 --- a/web/packages/teleport/src/SessionRecordings/view/RecordingPlayer.tsx +++ b/web/packages/teleport/src/SessionRecordings/view/RecordingPlayer.tsx @@ -23,7 +23,10 @@ import styled from 'styled-components'; import Flex from 'design/Flex'; import Indicator from 'design/Indicator'; -import type { RecordingType } from 'teleport/services/recordings'; +import type { + RecordingType, + SessionRecordingEvent, +} from 'teleport/services/recordings'; import { RECORDING_TYPES_WITH_METADATA } from 'teleport/services/recordings/recordings'; import { DesktopPlayer } from 'teleport/SessionRecordings/view/DesktopPlayer'; import { TtyRecordingPlayer } from 'teleport/SessionRecordings/view/player/tty/TtyRecordingPlayer'; @@ -43,6 +46,7 @@ interface RecordingPlayerProps { fullscreen?: boolean; initialCols?: number; initialRows?: number; + events?: SessionRecordingEvent[]; ref?: RefObject; } @@ -75,6 +79,7 @@ export function RecordingPlayer({ recordingType, initialCols, initialRows, + events, ref, }: RecordingPlayerProps) { if (recordingType === 'desktop') { @@ -121,6 +126,7 @@ export function RecordingPlayer({ onToggleTimeline={onToggleTimeline} initialCols={initialCols} initialRows={initialRows} + events={events} ref={ref} /> diff --git a/web/packages/teleport/src/SessionRecordings/view/RecordingWithMetadata.tsx b/web/packages/teleport/src/SessionRecordings/view/RecordingWithMetadata.tsx index 883ffce0c6b09..3a0052c6212e2 100644 --- a/web/packages/teleport/src/SessionRecordings/view/RecordingWithMetadata.tsx +++ b/web/packages/teleport/src/SessionRecordings/view/RecordingWithMetadata.tsx @@ -147,6 +147,7 @@ export function RecordingWithMetadata({ onTimeChange={handleTimeChange} initialCols={data.metadata.startCols} initialRows={data.metadata.startRows} + events={data.metadata.events} ref={playerRef} /> diff --git a/web/packages/teleport/src/SessionRecordings/view/player/RecordingPlayer.tsx b/web/packages/teleport/src/SessionRecordings/view/player/RecordingPlayer.tsx index 37ee62a78948c..c1f9d61f7361b 100644 --- a/web/packages/teleport/src/SessionRecordings/view/player/RecordingPlayer.tsx +++ b/web/packages/teleport/src/SessionRecordings/view/player/RecordingPlayer.tsx @@ -31,6 +31,11 @@ import Box from 'web/packages/design/src/Box'; import Flex from 'web/packages/design/src/Flex'; import { Pause, Play } from 'web/packages/design/src/Icon'; +import type { SessionRecordingEvent } from 'teleport/services/recordings'; +import { + CurrentEventInfo, + type CurrentEventInfoHandle, +} from 'teleport/SessionRecordings/view/CurrentEventInfo'; import type { Player } from 'teleport/SessionRecordings/view/player/Player'; import { PlayerControls, @@ -58,6 +63,7 @@ export interface RecordingPlayerProps< endEventType: TEndEventType; decodeEvent: (buffer: ArrayBuffer) => TEvent; ref: RefObject; + events?: SessionRecordingEvent[]; ws: WebSocket; } @@ -74,6 +80,7 @@ export function RecordingPlayer< onToggleFullscreen, onToggleSidebar, onToggleTimeline, + events, ref, ws, }: RecordingPlayerProps) { @@ -81,6 +88,7 @@ export function RecordingPlayer< const [showPlayButton, setShowPlayButton] = useState(true); + const eventInfoRef = useRef(null); const controlsRef = useRef(null); const playerRef = useRef(null); @@ -95,12 +103,13 @@ export function RecordingPlayer< }); stream.on('time', time => { - if (!controlsRef.current) { + if (!controlsRef.current || !eventInfoRef.current) { return; } controlsRef.current.setTime(time); onTimeChange(time); + eventInfoRef.current.setTime(time); }); stream.loadInitial(); @@ -161,7 +170,14 @@ export function RecordingPlayer< borderColor="spotBackground.1" borderRadius={4} overflow="hidden" + position="relative" > + + {showPlayButton && (