import {Component, createRef, Fragment} from 'react'; import styled from '@emotion/styled'; import {Location} from 'history'; import GuideAnchor from 'sentry/components/assistant/guideAnchor'; import Count from 'sentry/components/count'; import * as DividerHandlerManager from 'sentry/components/events/interfaces/spans/dividerHandlerManager'; import * as ScrollbarManager from 'sentry/components/events/interfaces/spans/scrollbarManager'; import {MeasurementMarker} from 'sentry/components/events/interfaces/spans/styles'; import { getMeasurementBounds, SpanBoundsType, SpanGeneratedBoundsType, transactionTargetHash, VerticalMark, } from 'sentry/components/events/interfaces/spans/utils'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import Link from 'sentry/components/links/link'; import {ROW_HEIGHT, SpanBarType} from 'sentry/components/performance/waterfall/constants'; import { Row, RowCell, RowCellContainer, RowReplayTimeIndicators, } from 'sentry/components/performance/waterfall/row'; import {DurationPill, RowRectangle} from 'sentry/components/performance/waterfall/rowBar'; import { DividerContainer, DividerLine, DividerLineGhostContainer, ErrorBadge, } from 'sentry/components/performance/waterfall/rowDivider'; import { RowTitle, RowTitleContainer, RowTitleContent, } from 'sentry/components/performance/waterfall/rowTitle'; import { ConnectorBar, TOGGLE_BORDER_BOX, TreeConnector, TreeToggle, TreeToggleContainer, TreeToggleIcon, } from 'sentry/components/performance/waterfall/treeConnector'; import { getDurationDisplay, getHumanDuration, } from 'sentry/components/performance/waterfall/utils'; import {generateIssueEventTarget} from 'sentry/components/quickTrace/utils'; import {Tooltip} from 'sentry/components/tooltip'; import {Organization} from 'sentry/types'; import {defined} from 'sentry/utils'; import toPercent from 'sentry/utils/number/toPercent'; import {TraceError, TraceFullDetailed} from 'sentry/utils/performance/quickTrace/types'; import { isTraceError, isTraceRoot, isTraceTransaction, } from 'sentry/utils/performance/quickTrace/utils'; import Projects from 'sentry/utils/projects'; import {ProjectBadgeContainer} from './styles'; import TransactionDetail from './transactionDetail'; import {TraceInfo, TraceRoot, TreeDepth} from './types'; import {shortenErrorTitle} from './utils'; const MARGIN_LEFT = 0; type Props = { addContentSpanBarRef: (instance: HTMLDivElement | null) => void; continuingDepths: TreeDepth[]; generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType; hasGuideAnchor: boolean; index: number; isExpanded: boolean; isLast: boolean; isOrphan: boolean; isVisible: boolean; location: Location; onWheel: (deltaX: number) => void; organization: Organization; removeContentSpanBarRef: (instance: HTMLDivElement | null) => void; toggleExpandedState: () => void; traceInfo: TraceInfo; transaction: TraceRoot | TraceFullDetailed | TraceError; barColor?: string; isOrphanError?: boolean; measurements?: Map; numOfOrphanErrors?: number; }; type State = { showDetail: boolean; }; class TransactionBar extends Component { state: State = { showDetail: false, }; componentDidMount() { const {location, transaction} = this.props; if ( 'event_id' in transaction && transactionTargetHash(transaction.event_id) === location.hash ) { this.scrollIntoView(); } if (this.transactionTitleRef.current) { this.transactionTitleRef.current.addEventListener('wheel', this.handleWheel, { passive: false, }); } } componentWillUnmount() { if (this.transactionTitleRef.current) { this.transactionTitleRef.current.removeEventListener('wheel', this.handleWheel); } } transactionRowDOMRef = createRef(); transactionTitleRef = createRef(); spanContentRef: HTMLDivElement | null = null; toggleDisplayDetail = () => { const {transaction} = this.props; if (isTraceError(transaction)) { return; } if (isTraceTransaction(transaction)) { this.setState(state => ({ showDetail: !state.showDetail, })); } }; getCurrentOffset() { const {transaction} = this.props; const {generation} = transaction; return getOffset(generation); } handleWheel = (event: WheelEvent) => { // https://stackoverflow.com/q/57358640 // https://github.com/facebook/react/issues/14856 if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) { return; } event.preventDefault(); event.stopPropagation(); if (Math.abs(event.deltaY) === Math.abs(event.deltaX)) { return; } const {onWheel} = this.props; onWheel(event.deltaX); }; renderMeasurements() { const {measurements, generateBounds} = this.props; if (!measurements) { return null; } return ( {Array.from(measurements.values()).map(verticalMark => { const mark = Object.values(verticalMark.marks)[0]; const {timestamp} = mark; const bounds = getMeasurementBounds(timestamp, generateBounds); const shouldDisplay = defined(bounds.left) && defined(bounds.width); if (!shouldDisplay || !bounds.isSpanVisibleInView) { return null; } return ( ); })} ); } renderConnector(hasToggle: boolean) { const {continuingDepths, isExpanded, isOrphan, isLast, transaction} = this.props; const {generation = 0} = transaction; const eventId = isTraceTransaction(transaction) || isTraceError(transaction) ? transaction.event_id : transaction.traceSlug; if (generation === 0) { if (hasToggle) { return ( ); } return null; } const connectorBars: Array = continuingDepths.map( ({depth, isOrphanDepth}) => { if (generation - depth <= 1) { // If the difference is less than or equal to 1, then it means that the continued // bar is from its direct parent. In this case, do not render a connector bar // because the tree connector below will suffice. return null; } const left = -1 * getOffset(generation - depth - 1) - 2; return ( ); } ); if (hasToggle && isExpanded) { connectorBars.push( ); } return ( {connectorBars} ); } renderToggle(errored: boolean) { const {isExpanded, transaction, toggleExpandedState, numOfOrphanErrors} = this.props; const left = this.getCurrentOffset(); const hasOrphanErrors = numOfOrphanErrors && numOfOrphanErrors > 0; const childrenLength = (!isTraceError(transaction) && transaction.children?.length) || 0; const generation = transaction.generation || 0; if (childrenLength <= 0 && !hasOrphanErrors) { return ( {this.renderConnector(false)} ); } const isRoot = generation === 0; return ( {this.renderConnector(true)} { event.stopPropagation(); if (isRoot) { return; } toggleExpandedState(); }} > {!isRoot && (
)}
); } // TODO: Use ScrollbarManager to bring autoscrolling here renderTitle(_: ScrollbarManager.ScrollbarManagerChildrenProps) { const {organization, transaction, addContentSpanBarRef, removeContentSpanBarRef} = this.props; const left = this.getCurrentOffset(); const errored = isTraceTransaction(transaction) ? transaction.errors && transaction.errors.length + transaction.performance_issues.length > 0 : false; const projectBadge = (isTraceTransaction(transaction) || isTraceError(transaction)) && ( {({projects}) => { const project = projects.find(p => p.slug === transaction.project_slug); return ( ); }} ); const content = isTraceError(transaction) ? ( {projectBadge} {'Unknown \u2014 '} {shortenErrorTitle(transaction.title)} ) : isTraceTransaction(transaction) ? ( {projectBadge} {transaction['transaction.op']} {' \u2014 '} {transaction.transaction} ) : ( {'Trace \u2014 '} {transaction.traceSlug} ); return ( { if (!ref) { removeContentSpanBarRef(this.spanContentRef); return; } addContentSpanBarRef(ref); this.spanContentRef = ref; }} > {this.renderToggle(errored)} {content} ); } renderDivider( dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps ) { if (this.state.showDetail) { // Mock component to preserve layout spacing return ( ); } const {addDividerLineRef} = dividerHandlerChildrenProps; return ( { dividerHandlerChildrenProps.setHover(true); }} onMouseLeave={() => { dividerHandlerChildrenProps.setHover(false); }} onMouseOver={() => { dividerHandlerChildrenProps.setHover(true); }} onMouseDown={dividerHandlerChildrenProps.onDragStart} onClick={event => { // we prevent the propagation of the clicks from this component to prevent // the span detail from being opened. event.stopPropagation(); }} /> ); } renderGhostDivider( dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps ) { const {dividerPosition, addGhostDividerLineRef} = dividerHandlerChildrenProps; return ( { // the ghost divider line should not be interactive. // we prevent the propagation of the clicks from this component to prevent // the span detail from being opened. event.stopPropagation(); }} /> ); } renderErrorBadge() { const {transaction} = this.props; if ( isTraceRoot(transaction) || isTraceError(transaction) || !(transaction.errors.length + transaction.performance_issues.length) ) { return null; } return ; } renderRectangle() { const {transaction, traceInfo, barColor} = this.props; const {showDetail} = this.state; // Use 1 as the difference in the case that startTimestamp === endTimestamp const delta = Math.abs(traceInfo.endTimestamp - traceInfo.startTimestamp) || 1; const start_timestamp = isTraceError(transaction) ? transaction.timestamp : transaction.start_timestamp; if (!(start_timestamp && transaction.timestamp)) { return null; } const startPosition = Math.abs(start_timestamp - traceInfo.startTimestamp); const startPercentage = startPosition / delta; const duration = Math.abs(transaction.timestamp - start_timestamp); const widthPercentage = duration / delta; return ( {this.renderPerformanceIssues()} {isTraceError(transaction) ? ( ) : ( {this.renderErrorBadge()} {getHumanDuration(duration)} )} ); } renderPerformanceIssues() { const {transaction, barColor} = this.props; if (isTraceError(transaction) || isTraceRoot(transaction)) { return null; } const rows: React.ReactElement[] = []; // Use 1 as the difference in the case that startTimestamp === endTimestamp const delta = Math.abs(transaction.timestamp - transaction.start_timestamp) || 1; for (let i = 0; i < transaction.performance_issues.length; i++) { const issue = transaction.performance_issues[i]; const startPosition = Math.abs(issue.start - transaction.start_timestamp); const startPercentage = startPosition / delta; const duration = Math.abs(issue.end - issue.start); const widthPercentage = duration / delta; rows.push( ); } return rows; } renderHeader({ dividerHandlerChildrenProps, scrollbarManagerChildrenProps, }: { dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps; scrollbarManagerChildrenProps: ScrollbarManager.ScrollbarManagerChildrenProps; }) { const {hasGuideAnchor, index, transaction, organization} = this.props; const {showDetail} = this.state; const {dividerPosition} = dividerHandlerChildrenProps; const rowcellContainer = ( {this.renderTitle(scrollbarManagerChildrenProps)} {this.renderDivider(dividerHandlerChildrenProps)} {this.renderRectangle()} {this.renderMeasurements()} {!showDetail && this.renderGhostDivider(dividerHandlerChildrenProps)} ); return isTraceError(transaction) ? ( {rowcellContainer} ) : ( rowcellContainer ); } scrollIntoView = () => { const element = this.transactionRowDOMRef.current; if (!element) { return; } const boundingRect = element.getBoundingClientRect(); const offset = boundingRect.top + window.scrollY; this.setState({showDetail: true}, () => window.scrollTo(0, offset)); }; renderDetail() { const {location, organization, isVisible, transaction} = this.props; const {showDetail} = this.state; if (isTraceError(transaction) || isTraceRoot(transaction)) { return null; } if (!isVisible || !showDetail) { return null; } return ( ); } render() { const {isVisible, transaction} = this.props; const {showDetail} = this.state; return ( (transaction) ? 'pointer' : 'default' } > {scrollbarManagerChildrenProps => ( {dividerHandlerChildrenProps => this.renderHeader({ dividerHandlerChildrenProps, scrollbarManagerChildrenProps, }) } )} {this.renderDetail()} ); } } function getOffset(generation) { return generation * (TOGGLE_BORDER_BOX / 2) + MARGIN_LEFT; } export default TransactionBar; const StyledRow = styled(Row)` &, ${RowCellContainer} { overflow: visible; } `; const ErrorLink = styled(Link)` color: ${p => p.theme.error}; `; const StyledRowRectangle = styled(RowRectangle)` display: flex; align-items: center; `;