import {browserHistory} from 'react-router'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import type {Location} from 'history'; import Avatar from 'sentry/components/avatar'; import {Button} from 'sentry/components/button'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; import UserBadge from 'sentry/components/idBadge/userBadge'; import Link from 'sentry/components/links/link'; import ContextIcon from 'sentry/components/replays/contextIcon'; import ReplayPlayPauseButton from 'sentry/components/replays/replayPlayPauseButton'; import {formatTime} from 'sentry/components/replays/utils'; import ScoreBar from 'sentry/components/scoreBar'; import TimeSince from 'sentry/components/timeSince'; import {Tooltip} from 'sentry/components/tooltip'; import {CHART_PALETTE} from 'sentry/constants/chartPalette'; import { IconCalendar, IconCursorArrow, IconDelete, IconEllipsis, IconFire, IconPlay, } from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import type {ValidSize} from 'sentry/styles/space'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types'; import {trackAnalytics} from 'sentry/utils/analytics'; import type EventView from 'sentry/utils/discover/eventView'; import {spanOperationRelativeBreakdownRenderer} from 'sentry/utils/discover/fieldRenderers'; import {getShortEventId} from 'sentry/utils/events'; import {decodeScalar} from 'sentry/utils/queryString'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {useLocation} from 'sentry/utils/useLocation'; import useMedia from 'sentry/utils/useMedia'; import useProjects from 'sentry/utils/useProjects'; import {normalizeUrl} from 'sentry/utils/withDomainRequired'; import type {ReplayListRecordWithTx} from 'sentry/views/performance/transactionSummary/transactionReplays/useReplaysWithTxData'; import type {ReplayListLocationQuery, ReplayListRecord} from 'sentry/views/replays/types'; type Props = { replay: ReplayListRecord | ReplayListRecordWithTx; showDropdownFilters?: boolean; }; export type ReferrerTableType = 'main' | 'selector-widget'; type EditType = 'set' | 'remove'; function generateAction({ key, value, edit, location, }: { edit: EditType; key: string; location: Location; value: string; }) { const search = new MutableSearch(decodeScalar(location.query.query) || ''); const modifiedQuery = edit === 'set' ? search.setFilterValues(key, [value]) : search.removeFilter(key); const onAction = () => { browserHistory.push({ pathname: location.pathname, query: { ...location.query, query: modifiedQuery.formatString(), }, }); }; return onAction; } function OSBrowserDropdownFilter({ type, name, version, }: { name: string | null; type: string; version: string | null; }) { const location = useLocation(); return ( {type}, name: {name}, }), children: [ { key: 'name_add', label: t('Add to filter'), onAction: generateAction({ key: `${type}.name`, value: name ?? '', edit: 'set', location, }), }, { key: 'name_exclude', label: t('Exclude from filter'), onAction: generateAction({ key: `${type}.name`, value: name ?? '', edit: 'remove', location, }), }, ], }, ] : []), ...(version ? [ { key: 'version', label: tct('[type] version: [version]', { type: {type}, version: {version}, }), children: [ { key: 'version_add', label: t('Add to filter'), onAction: generateAction({ key: `${type}.version`, value: version ?? '', edit: 'set', location, }), }, { key: 'version_exclude', label: t('Exclude from filter'), onAction: generateAction({ key: `${type}.version`, value: version ?? '', edit: 'remove', location, }), }, ], }, ] : []), ]} usePortal size="xs" offset={4} position="bottom" preventOverflowOptions={{padding: 4}} flipOptions={{ fallbackPlacements: ['top', 'right-start', 'right-end', 'left-start', 'left-end'], }} trigger={triggerProps => ( } size="zero" /> )} /> ); } function NumericDropdownFilter({ type, val, triggerOverlay, }: { type: string; val: number; triggerOverlay?: boolean; }) { const location = useLocation(); return ( ' + val.toString(), edit: 'set', location, }), }, { key: 'less', label: 'Show values less than', onAction: generateAction({ key: type, value: '<' + val.toString(), edit: 'set', location, }), }, { key: 'exclude', label: t('Exclude from filter'), onAction: generateAction({ key: type, value: val.toString(), edit: 'remove', location, }), }, ]} usePortal size="xs" offset={4} position="bottom" preventOverflowOptions={{padding: 4}} flipOptions={{ fallbackPlacements: ['top', 'right-start', 'right-end', 'left-start', 'left-end'], }} trigger={triggerProps => triggerOverlay ? ( } size="zero" /> ) : ( } size="zero" /> ) } /> ); } function getUserBadgeUser(replay: Props['replay']) { return replay.is_archived ? { username: '', email: '', id: '', ip_address: '', name: '', } : { username: replay.user?.display_name || '', email: replay.user?.email || '', id: replay.user?.id || '', ip_address: replay.user?.ip || '', name: replay.user?.username || '', }; } export function ReplayCell({ eventView, organization, referrer, replay, referrer_table, isWidget, className, }: Props & { eventView: EventView; organization: Organization; referrer: string; className?: string; isWidget?: boolean; referrer_table?: ReferrerTableType; }) { const {projects} = useProjects(); const project = projects.find(p => p.id === replay.project_id); const replayDetails = { pathname: normalizeUrl(`/organizations/${organization.slug}/replays/${replay.id}/`), query: { referrer, ...eventView.generateQueryStringObject(), }, }; const replayDetailsDeadRage = { pathname: normalizeUrl(`/organizations/${organization.slug}/replays/${replay.id}/`), query: { referrer, ...eventView.generateQueryStringObject(), f_b_type: 'rageOrDead', }, }; const detailsTab = () => { switch (referrer_table) { case 'selector-widget': return replayDetailsDeadRage; default: return replayDetails; } }; const trackNavigationEvent = () => trackAnalytics('replay.list-navigate-to-details', { project_id: project?.id, platform: project?.platform, organization, referrer, referrer_table, }); if (replay.is_archived) { return (
{t('Deleted Replay')} {project ? : null} {getShortEventId(replay.id)}
); } const subText = ( {/* Avatar is used instead of ProjectBadge because using ProjectBadge increases spacing, which doesn't look as good */} {project ? : null} {project ? project.slug : null} {getShortEventId(replay.id)} ); return ( {replay.user.display_name || t('Anonymous User')} ) } user={getUserBadgeUser(replay)} // this is the subheading for the avatar, so displayEmail in this case is a misnomer displayEmail={subText} /> ); } const ArchivedId = styled('div')` font-size: ${p => p.theme.fontSizeSmall}; `; const StyledIconDelete = styled(IconDelete)` margin: ${space(0.25)}; `; const Cols = styled('div')` display: flex; flex-direction: column; gap: ${space(0.5)}; width: 100%; `; const Row = styled('div')<{gap: ValidSize; minWidth?: number}>` display: flex; gap: ${p => space(p.gap)}; align-items: center; ${p => (p.minWidth ? `min-width: ${p.minWidth}px;` : '')} `; const MainLink = styled(Link)` font-size: ${p => p.theme.fontSizeLarge}; `; export function TransactionCell({ organization, replay, }: Props & {organization: Organization}) { const location = useLocation(); if (replay.is_archived) { return ; } const hasTxEvent = 'txEvent' in replay; const txDuration = hasTxEvent ? replay.txEvent?.['transaction.duration'] : undefined; return hasTxEvent ? ( {txDuration ?
{txDuration}ms
: null} {spanOperationRelativeBreakdownRenderer( replay.txEvent, {organization, location}, {enableOnClick: false} )}
) : null; } export function OSCell({replay, showDropdownFilters}: Props) { const {name, version} = replay.os ?? {}; const theme = useTheme(); const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.large})`); if (replay.is_archived) { return ; } return ( {showDropdownFilters ? ( ) : null} ); } export function BrowserCell({replay, showDropdownFilters}: Props) { const {name, version} = replay.browser ?? {}; const theme = useTheme(); const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.large})`); if (replay.is_archived) { return ; } return ( {showDropdownFilters ? ( ) : null} ); } export function DurationCell({replay, showDropdownFilters}: Props) { if (replay.is_archived) { return ; } return ( {showDropdownFilters ? ( ) : null} ); } export function RageClickCountCell({replay, showDropdownFilters}: Props) { if (replay.is_archived) { return ; } return ( {replay.count_rage_clicks ? ( {replay.count_rage_clicks} ) : ( 0 )} {showDropdownFilters ? ( ) : null} ); } export function DeadClickCountCell({replay, showDropdownFilters}: Props) { if (replay.is_archived) { return ; } return ( {replay.count_dead_clicks ? ( {replay.count_dead_clicks} ) : ( 0 )} {showDropdownFilters ? ( ) : null} ); } export function ErrorCountCell({replay, showDropdownFilters}: Props) { if (replay.is_archived) { return ; } return ( {replay.count_errors ? ( {replay.count_errors} ) : ( 0 )} {showDropdownFilters ? ( ) : null} ); } export function ActivityCell({replay, showDropdownFilters}: Props) { if (replay.is_archived) { return ; } const scoreBarPalette = new Array(10).fill([CHART_PALETTE[0][0]]); return ( {showDropdownFilters ? ( ) : null} ); } export function PlayPauseCell({ isSelected, handleClick, }: { handleClick: () => void; isSelected: boolean; }) { const inner = isSelected ? ( ) : (