diff --git a/package.json b/package.json index 3f140f2ff2ab65..eeb94452a369ee 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "fuse.js": "^3.2.0", "gettext-parser": "1.3.1", "grid-emotion": "^2.1.0", + "intersection-observer": "^0.7.0", "ios-device-list": "^1.1.30", "jed": "^1.1.0", "jquery": "2.2.2", diff --git a/src/sentry/static/sentry/app/components/events/eventEntries.jsx b/src/sentry/static/sentry/app/components/events/eventEntries.jsx index ff4bba212c73bc..c96b6c6a95647a 100644 --- a/src/sentry/static/sentry/app/components/events/eventEntries.jsx +++ b/src/sentry/static/sentry/app/components/events/eventEntries.jsx @@ -32,7 +32,6 @@ import SentryTypes from 'app/sentryTypes'; import StacktraceInterface from 'app/components/events/interfaces/stacktrace'; import TemplateInterface from 'app/components/events/interfaces/template'; import ThreadsInterface from 'app/components/events/interfaces/threads'; -import SpansInterface from 'app/components/events/interfaces/spans'; import withApi from 'app/utils/withApi'; import withOrganization from 'app/utils/withOrganization'; @@ -49,7 +48,6 @@ export const INTERFACES = { breadcrumbs: BreadcrumbsInterface, threads: ThreadsInterface, debugmeta: DebugMetaInterface, - spans: SpansInterface, }; class EventEntries extends React.Component { diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/minimap.tsx b/src/sentry/static/sentry/app/components/events/interfaces/spans/minimap.tsx deleted file mode 100644 index bf62f7a6799f90..00000000000000 --- a/src/sentry/static/sentry/app/components/events/interfaces/spans/minimap.tsx +++ /dev/null @@ -1,613 +0,0 @@ -import React from 'react'; -import styled from 'react-emotion'; - -import space from 'app/styles/space'; - -import { - rectOfContent, - clamp, - rectRelativeTo, - rectOfElement, - toPercent, - getHumanDuration, -} from './utils'; -import {DragManagerChildrenProps} from './dragManager'; -import {ParsedTraceType, TickAlignment} from './types'; - -const MINIMAP_HEIGHT = 75; -const TIME_AXIS_HEIGHT = 30; - -type MinimapProps = { - traceViewRef: React.RefObject; - minimapInteractiveRef: React.RefObject; - dragProps: DragManagerChildrenProps; - trace: ParsedTraceType; -}; - -type MinimapState = { - showCursorGuide: boolean; - mousePageX: number | undefined; - startViewHandleX: number; -}; - -class Minimap extends React.Component { - state: MinimapState = { - showCursorGuide: false, - mousePageX: void 0, - startViewHandleX: 100, - }; - - minimapRef = React.createRef(); - - componentDidMount() { - this.drawMinimap(); - } - - drawMinimap = () => { - const canvas = this.minimapRef.current; - const traceViewDOM = this.props.traceViewRef.current; - - if (!canvas || !traceViewDOM) { - return; - } - - const canvasContext = canvas.getContext('2d'); - - if (!canvasContext) { - return; - } - - const rootRect = rectOfContent(traceViewDOM); - - const scaleX = canvas.clientWidth / rootRect.width; - const scaleY = canvas.clientHeight / rootRect.height; - - // https://www.html5rocks.com/en/tutorials/canvas/hidpi/ - // we consider the devicePixelRatio (dpr) factor so that the canvas looks decent on hidpi screens - // such as retina macbooks - const devicePixelRatio = window.devicePixelRatio || 1; - - const resizeCanvas = (width: number, height: number) => { - // scale the canvas up by the dpr factor - canvas.width = width * devicePixelRatio; - canvas.height = height * devicePixelRatio; - - // scale the canvas down by the dpr factor thru CSS - canvas.style.width = '100%'; - canvas.style.height = `${height}px`; - }; - - resizeCanvas(rootRect.width * scaleX, rootRect.height * scaleY); - - canvasContext.setTransform(1, 0, 0, 1, 0, 0); - canvasContext.clearRect(0, 0, canvas.width, canvas.height); - canvasContext.scale(scaleX, scaleY); - - // scale canvas operations by the dpr factor - canvasContext.scale(devicePixelRatio, devicePixelRatio); - - const black = (pc: number) => `rgba(0,0,0,${pc / 100})`; - const back = black(0); - - const drawRect = ( - rect: {x: number; y: number; width: number; height: number}, - colour: string - ) => { - if (colour) { - canvasContext.beginPath(); - canvasContext.rect(rect.x, rect.y, rect.width, rect.height); - canvasContext.fillStyle = colour; - canvasContext.fill(); - } - }; - - // draw background - - drawRect(rectRelativeTo(rootRect, rootRect), back); - - // draw the spans - - Array.from(traceViewDOM.querySelectorAll('[data-span="true"]')).forEach( - el => { - const backgroundColor = window.getComputedStyle(el).backgroundColor || black(10); - drawRect(rectRelativeTo(rectOfElement(el), rootRect), backgroundColor); - } - ); - }; - - renderMinimapCursorGuide = () => { - if (!this.state.showCursorGuide || !this.state.mousePageX) { - return null; - } - - const minimapCanvas = this.props.minimapInteractiveRef.current; - - if (!minimapCanvas) { - return null; - } - - const rect = rectOfContent(minimapCanvas); - - // clamp mouseLeft to be within [0, 100] - const mouseLeft = clamp( - ((this.state.mousePageX - rect.x) / rect.width) * 100, - 0, - 100 - ); - - return ( - - ); - }; - - renderViewHandles = ({ - isDragging, - onLeftHandleDragStart, - leftHandlePosition, - viewWindowStart, - onRightHandleDragStart, - rightHandlePosition, - viewWindowEnd, - }: DragManagerChildrenProps) => { - const leftHandleGhost = isDragging ? ( - - - - - ) : null; - - const leftHandle = ( - - - - - ); - - const rightHandle = ( - - - - - ); - - const rightHandleGhost = isDragging ? ( - - - - - ) : null; - - return ( - - {leftHandleGhost} - {rightHandleGhost} - {leftHandle} - {rightHandle} - - ); - }; - - renderFog = (dragProps: DragManagerChildrenProps) => { - return ( - - - - - ); - }; - - renderDurationGuide = () => { - if (!this.state.showCursorGuide || !this.state.mousePageX) { - return null; - } - - const minimapCanvas = this.props.minimapInteractiveRef.current; - - if (!minimapCanvas) { - return null; - } - - const rect = rectOfContent(minimapCanvas); - - // clamp mouseLeft to be within [0, 1] - const mouseLeft = clamp((this.state.mousePageX - rect.x) / rect.width, 0, 1); - - const {trace} = this.props; - - const duration = - mouseLeft * Math.abs(trace.traceEndTimestamp - trace.traceStartTimestamp); - - const style = {top: 0, left: `calc(${mouseLeft * 100}% + 4px)`}; - - const alignLeft = (1 - mouseLeft) * rect.width <= 100; - - return ( - - {getHumanDuration(duration)} - - ); - }; - - renderTimeAxis = () => { - const {trace} = this.props; - - const duration = Math.abs(trace.traceEndTimestamp - trace.traceStartTimestamp); - - const firstTick = ( - - ); - - const secondTick = ( - - ); - - const thirdTick = ( - - ); - - const fourthTick = ( - - ); - - const lastTick = ( - - ); - - return ( - - {firstTick} - {secondTick} - {thirdTick} - {fourthTick} - {lastTick} - - {this.renderTimeAxisCursorGuide()} - - {this.renderDurationGuide()} - - ); - }; - - renderTimeAxisCursorGuide = () => { - if (!this.state.showCursorGuide || !this.state.mousePageX) { - return null; - } - - const minimapCanvas = this.props.minimapInteractiveRef.current; - - if (!minimapCanvas) { - return null; - } - - const rect = rectOfContent(minimapCanvas); - - // clamp mouseLeft to be within [0, 100] - const mouseLeft = clamp( - ((this.state.mousePageX - rect.x) / rect.width) * 100, - 0, - 100 - ); - - return ( - - ); - }; - - render() { - return ( - - - -
{ - this.setState({ - showCursorGuide: true, - mousePageX: event.pageX, - }); - }} - onMouseLeave={() => { - this.setState({showCursorGuide: false, mousePageX: void 0}); - }} - onMouseMove={event => { - this.setState({ - showCursorGuide: true, - mousePageX: event.pageX, - }); - }} - > - - {this.renderFog(this.props.dragProps)} - {this.renderMinimapCursorGuide()} - {this.renderViewHandles(this.props.dragProps)} - - {this.renderTimeAxis()} -
-
-
- ); - } -} - -const TimeAxis = styled('div')` - width: 100%; - position: absolute; - left: 0; - top: ${MINIMAP_HEIGHT}px; - - border-top: 1px solid #d1cad8; - - height: ${TIME_AXIS_HEIGHT}px; - background-color: #faf9fb; - - color: #9585a3; - font-size: 10px; - font-weight: 500; -`; - -const TickLabelContainer = styled('div')` - height: ${TIME_AXIS_HEIGHT}px; - - position: absolute; - top: 0; - - user-select: none; -`; - -const TickText = styled('span')` - line-height: 1; - - position: absolute; - bottom: 8px; - white-space: nowrap; - - ${({align}: {align: TickAlignment}) => { - switch (align) { - case TickAlignment.Center: { - return 'transform: translateX(-50%)'; - } - case TickAlignment.Left: { - return null; - } - - case TickAlignment.Right: { - return 'transform: translateX(-100%)'; - } - - default: { - throw Error(`Invalid tick alignment: ${align}`); - } - } - }}; -`; - -const TickMarker = styled('div')` - width: 1px; - height: 5px; - - background-color: #d1cad8; - - position: absolute; - top: 0; - left: 0; - - transform: translateX(-50%); -`; - -const TickLabel = (props: { - style: React.CSSProperties; - hideTickMarker?: boolean; - align?: TickAlignment; - duration: number; -}) => { - const {style, duration, hideTickMarker = false, align = TickAlignment.Center} = props; - - return ( - - {hideTickMarker ? null : } - {getHumanDuration(duration)} - - ); -}; - -const DurationGuideBox = styled('div')` - position: absolute; - - background-color: rgba(255, 255, 255, 1); - padding: 4px; - - border-radius: 3px; - border: 1px solid rgba(0, 0, 0, 0.1); - - height: 16px; - - line-height: 1; - vertical-align: middle; - - transform: translateY(50%); - - white-space: nowrap; - - ${({alignLeft}: {alignLeft: boolean}) => { - if (!alignLeft) { - return null; - } - - return 'transform: translateY(50%) translateX(-100%) translateX(-8px);'; - }}; -`; - -const MinimapContainer = styled('div')` - width: 100%; - position: relative; - left: 0; - border-bottom: 1px solid #d1cad8; - - height: ${MINIMAP_HEIGHT + TIME_AXIS_HEIGHT + 1}px; -`; - -const MinimapBackground = styled('canvas')` - height: ${MINIMAP_HEIGHT}px; - width: 100%; - position: absolute; - top: 0; - left: 0; -`; - -const InteractiveLayer = styled('svg')` - height: ${MINIMAP_HEIGHT}px; - width: 100%; - position: relative; - left: 0; -`; - -const ViewHandle = styled('rect')` - fill: #6c5fc7; - - cursor: col-resize; - - height: 20px; - - ${({isDragging}: {isDragging: boolean}) => { - if (isDragging) { - return ` - width: 5px; - transform: translate(-2.5px, ${MINIMAP_HEIGHT - 20}px); - `; - } - - return ` - width: 3px; - transform: translate(-1.5px, ${MINIMAP_HEIGHT - 20}px); - `; - }}; - - &:hover { - width: 5px; - transform: translate(-2.5px, ${MINIMAP_HEIGHT - 20}px); - } -`; - -const Fog = styled('rect')` - fill: rgba(241, 245, 251, 0.5); -`; - -export default Minimap; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/spanTree.tsx b/src/sentry/static/sentry/app/components/events/interfaces/spans/spanTree.tsx deleted file mode 100644 index 25a1b51391d23a..00000000000000 --- a/src/sentry/static/sentry/app/components/events/interfaces/spans/spanTree.tsx +++ /dev/null @@ -1,547 +0,0 @@ -import React from 'react'; -import styled from 'react-emotion'; -import {get} from 'lodash'; - -import space from 'app/styles/space'; -import Count from 'app/components/count'; - -import {SpanType, SpanChildrenLookupType, ParsedTraceType} from './types'; -import { - toPercent, - boundsGenerator, - SpanBoundsType, - SpanGeneratedBoundsType, - getHumanDuration, -} from './utils'; -import {DragManagerChildrenProps} from './dragManager'; -import SpanDetail from './spanDetail'; - -type RenderedSpanTree = { - spanTree: JSX.Element | null; - numOfHiddenSpansAbove: number; -}; - -type SpanTreeProps = { - traceViewRef: React.RefObject; - trace: ParsedTraceType; - dragProps: DragManagerChildrenProps; -}; - -class SpanTree extends React.Component { - renderSpan = ({ - treeDepth, - numOfHiddenSpansAbove, - spanID, - traceID, - lookup, - span, - generateBounds, - pickSpanBarColour, - }: { - treeDepth: number; - numOfHiddenSpansAbove: number; - spanID: string; - traceID: string; - span: Readonly; - lookup: Readonly; - generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType; - pickSpanBarColour: () => string; - }): RenderedSpanTree => { - const spanBarColour: string = pickSpanBarColour(); - - const spanChildren: Array = get(lookup, spanID, []); - - const bounds = generateBounds({ - startTimestamp: span.start_timestamp, - endTimestamp: span.timestamp, - }); - - const isCurrentSpanHidden = bounds.end <= 0 || bounds.start >= 1; - - type AccType = { - renderedSpanChildren: Array; - numOfHiddenSpansAbove: number; - }; - - const reduced: AccType = spanChildren.reduce( - (acc: AccType, spanChild) => { - const key = `${traceID}${spanChild.span_id}`; - - const results = this.renderSpan({ - treeDepth: treeDepth + 1, - numOfHiddenSpansAbove: acc.numOfHiddenSpansAbove, - span: spanChild, - spanID: spanChild.span_id, - traceID, - lookup, - generateBounds, - pickSpanBarColour, - }); - - acc.renderedSpanChildren.push( - {results.spanTree} - ); - - acc.numOfHiddenSpansAbove = results.numOfHiddenSpansAbove; - - return acc; - }, - { - renderedSpanChildren: [], - numOfHiddenSpansAbove: isCurrentSpanHidden ? numOfHiddenSpansAbove + 1 : 0, - } - ); - - const showHiddenSpansMessage = !isCurrentSpanHidden && numOfHiddenSpansAbove > 0; - - const hiddenSpansMessage = showHiddenSpansMessage ? ( - - Number of hidden spans: {numOfHiddenSpansAbove} - - ) : null; - - return { - numOfHiddenSpansAbove: reduced.numOfHiddenSpansAbove, - spanTree: ( - - {hiddenSpansMessage} - - - ), - }; - }; - - renderRootSpan = (): RenderedSpanTree => { - const {dragProps, trace} = this.props; - - // TODO: ideally this should be provided - const rootSpan: SpanType = { - trace_id: trace.traceID, - parent_span_id: void 0, - span_id: trace.rootSpanID, - start_timestamp: trace.traceStartTimestamp, - timestamp: trace.traceEndTimestamp, - same_process_as_parent: true, - op: 'transaction', - data: {}, - }; - - const COLORS = ['#e9e7f7', '#fcefde', '#fffbee', '#f1f5fb']; - let current_index = 0; - - const pickSpanBarColour = () => { - const next_colour = COLORS[current_index]; - - current_index++; - current_index = current_index % COLORS.length; - - return next_colour; - }; - - const generateBounds = boundsGenerator({ - traceStartTimestamp: trace.traceStartTimestamp, - traceEndTimestamp: trace.traceEndTimestamp, - viewStart: dragProps.viewWindowStart, - viewEnd: dragProps.viewWindowEnd, - }); - - return this.renderSpan({ - treeDepth: 0, - numOfHiddenSpansAbove: 0, - span: rootSpan, - spanID: rootSpan.span_id, - traceID: rootSpan.trace_id, - lookup: trace.lookup, - generateBounds, - pickSpanBarColour, - }); - }; - - render() { - const {spanTree, numOfHiddenSpansAbove} = this.renderRootSpan(); - - const hiddenSpansMessage = - numOfHiddenSpansAbove > 0 ? ( - - Number of hidden spans: {numOfHiddenSpansAbove} - - ) : null; - - return ( - - {spanTree} - {hiddenSpansMessage} - - ); - } -} - -type SpanPropTypes = { - span: Readonly; - generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType; - treeDepth: number; - numOfSpanChildren: number; - renderedSpanChildren: Array; - spanBarColour: string; -}; - -type SpanState = { - displayDetail: boolean; - showSpanTree: boolean; -}; - -class Span extends React.Component { - state: SpanState = { - displayDetail: false, - showSpanTree: true, - }; - - toggleSpanTree = () => { - this.setState(state => { - return { - showSpanTree: !state.showSpanTree, - }; - }); - }; - - toggleDisplayDetail = () => { - this.setState(state => { - return { - displayDetail: !state.displayDetail, - }; - }); - }; - - renderDetail = ({isVisible}: {isVisible: boolean}) => { - if (!this.state.displayDetail || !isVisible) { - return null; - } - - const {span} = this.props; - - return ; - }; - - getBounds = () => { - const {span, generateBounds} = this.props; - - return generateBounds({ - startTimestamp: span.start_timestamp, - endTimestamp: span.timestamp, - }); - }; - - renderSpanTreeToggler = ({left}: {left: number}) => { - const {numOfSpanChildren} = this.props; - - const chevron = this.state.showSpanTree ? : ; - - if (numOfSpanChildren <= 0) { - return null; - } - - return ( - - { - event.stopPropagation(); - - this.toggleSpanTree(); - }} - > - - - -
- {chevron} -
-
-
- ); - }; - - renderTitle = () => { - const {span, treeDepth} = this.props; - - const op = span.op ? {`${span.op} \u2014 `} : ''; - const description = get(span, 'description', span.span_id); - - const MARGIN_LEFT = 8; - const TOGGLE_BUTTON_MARGIN_RIGHT = 8; - const TOGGLE_BUTTON_MAX_WIDTH = 40; - - const left = - treeDepth * (TOGGLE_BUTTON_MAX_WIDTH + TOGGLE_BUTTON_MARGIN_RIGHT) + MARGIN_LEFT; - - return ( - - {this.renderSpanTreeToggler({left})} - - - {op} - {description} - - - - ); - }; - - renderSpanChildren = () => { - if (!this.state.showSpanTree) { - return null; - } - - return this.props.renderedSpanChildren; - }; - - render() { - const {span, spanBarColour} = this.props; - - const startTimestamp: number = span.start_timestamp; - const endTimestamp: number = span.timestamp; - - const duration = Math.abs(endTimestamp - startTimestamp); - const durationString = getHumanDuration(duration); - - const bounds = this.getBounds(); - - const isVisible = bounds.end > 0 && bounds.start < 1; - - return ( - - { - this.toggleDisplayDetail(); - }} - > - - {this.renderTitle()} - {durationString} - {this.renderDetail({isVisible})} - - {this.renderSpanChildren()} - - ); - } -} - -const TraceViewContainer = styled('div')` - overflow-x: hidden; - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; -`; - -const SPAN_ROW_HEIGHT = 25; - -const SpanRow = styled('div')` - position: relative; - overflow: hidden; - - cursor: pointer; - transition: background-color 0.15s ease-in-out; - - &:last-child { - & > [data-component='span-detail'] { - border-bottom: none !important; - } - } - - &:hover { - background-color: rgba(189, 180, 199, 0.1); - - & > [data-span='true'] { - transition: border-color 0.15s ease-in-out; - border: 1px solid rgba(0, 0, 0, 0.1); - } - } -`; - -const SpanRowMessage = styled(SpanRow)` - cursor: auto; - - color: #4a3e56; - font-size: 12px; - line-height: ${SPAN_ROW_HEIGHT}px; - - padding-left: ${space(1)}; - padding-right: ${space(1)}; - - background-color: #f1f5fb !important; - - outline: 1px solid #c9d4ea; - - z-index: 99999; -`; - -const SpanBarTitleContainer = styled('div')` - display: flex; - align-items: center; - - height: ${SPAN_ROW_HEIGHT}px; - position: absolute; - left: 0; - top: 0; - width: 100%; -`; - -const SpanBarTitle = styled('div')` - position: relative; - top: 0; - - height: ${SPAN_ROW_HEIGHT}px; - line-height: ${SPAN_ROW_HEIGHT}px; - - color: #4a3e56; - font-size: 12px; - - user-select: none; - - white-space: nowrap; -`; - -const SpanTreeTogglerContainer = styled('div')` - position: relative; - top: 0; - - height: 15px; - - max-width: 40px; - width: 40px; - min-width: 40px; - - margin-right: 8px; - - z-index: 999999; - - user-select: none; - - display: flex; - justify-content: flex-end; -`; - -const SpanTreeToggler = styled('div')` - position: relative; - - white-space: nowrap; - - height: 15px; - min-width: 25px; - - padding-left: 4px; - padding-right: 4px; - - display: flex; - flex-wrap: nowrap; - align-items: center; - align-content: center; - justify-content: center; - - > span { - flex-grow: 999; - } - - border-radius: 99px; - border: 1px solid #6e5f7d; - - background: #fbfaf9; - transition: all 0.15s ease-in-out; - - font-size: 9px; - line-height: 0; - color: #6e5f7d; - - &:hover { - background: #6e5f7d; - border: 1px solid #452650; - color: #ffffff; - - & svg path { - stroke: #fff; - } - } -`; - -const Duration = styled('div')` - position: absolute; - right: 0; - top: 0; - height: ${SPAN_ROW_HEIGHT}px; - line-height: ${SPAN_ROW_HEIGHT}px; - - color: #9585a3; - font-size: 12px; - padding-right: ${space(1)}; - - user-select: none; -`; - -const SpanBar = styled('div')` - position: relative; - min-height: ${SPAN_ROW_HEIGHT - 4}px; - height: ${SPAN_ROW_HEIGHT - 4}px; - max-height: ${SPAN_ROW_HEIGHT - 4}px; - - margin-top: 2px; - margin-bottom: 2px; - border-radius: 3px; - - overflow: hidden; - - user-select: none; - - padding: 4px; - - transition: border-color 0.15s ease-in-out; - border: 1px solid rgba(0, 0, 0, 0); -`; - -const ChevronOpen = props => ( - - - -); - -const ChevronClosed = props => ( - - - -); - -export default SpanTree; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/utils.tsx b/src/sentry/static/sentry/app/components/events/interfaces/spans/utils.tsx deleted file mode 100644 index e5e5bff5bdc640..00000000000000 --- a/src/sentry/static/sentry/app/components/events/interfaces/spans/utils.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import {isString, isNumber} from 'lodash'; - -type Rect = { - // x and y are left/top coords respectively - x: number; - y: number; - width: number; - height: number; -}; - -// get position of element relative to top/left of document -const getOffsetOfElement = (element: HTMLElement) => { - // left and top are relative to viewport - const {left, top} = element.getBoundingClientRect(); - - // get values that the document is currently scrolled by - const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; - const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - - return {x: left + scrollLeft, y: top + scrollTop}; -}; - -export const rectOfContent = (element: HTMLElement): Rect => { - const {x, y} = getOffsetOfElement(element); - - // offsets for the border and any scrollbars (clientLeft and clientTop), - // and if the element was scrolled (scrollLeft and scrollTop) - // - // NOTE: clientLeft and clientTop does not account for any margins nor padding - const contentOffsetLeft = element.clientLeft - element.scrollLeft; - const contentOffsetTop = element.clientTop - element.scrollTop; - - return { - x: x + contentOffsetLeft, - y: y + contentOffsetTop, - width: element.scrollWidth, - height: element.scrollHeight, - }; -}; - -export const rectRelativeTo = (rect: Rect, pos = {x: 0, y: 0}): Rect => { - return { - x: rect.x - pos.x, - y: rect.y - pos.y, - width: rect.width, - height: rect.height, - }; -}; - -export const rectOfElement = (element: HTMLElement): Rect => { - const {x, y} = getOffsetOfElement(element); - return { - x, - y, - width: element.offsetWidth, - height: element.offsetHeight, - }; -}; - -export const clamp = (value: number, min: number, max: number): number => { - if (value < min) { - return min; - } - if (value > max) { - return max; - } - return value; -}; - -export const isValidSpanID = (maybeSpanID: any) => { - return isString(maybeSpanID) && maybeSpanID.length > 0; -}; - -export const toPercent = (value: number) => { - return `${(value * 100).toFixed(3)}%`; -}; - -export type SpanBoundsType = {startTimestamp: number; endTimestamp: number}; -export type SpanGeneratedBoundsType = {start: number; end: number}; - -export const boundsGenerator = (bounds: { - traceStartTimestamp: number; - traceEndTimestamp: number; - viewStart: number; // in [0, 1] - viewEnd: number; // in [0, 1] -}) => { - const {traceEndTimestamp, traceStartTimestamp, viewStart, viewEnd} = bounds; - - // viewStart and viewEnd are percentage values (%) of the view window relative to the left - // side of the trace view minimap - - // invariant: viewStart <= viewEnd - - // duration of the entire trace in seconds - const duration = traceEndTimestamp - traceStartTimestamp; - - const viewStartTimestamp = traceStartTimestamp + viewStart * duration; - const viewEndTimestamp = traceEndTimestamp - (1 - viewEnd) * duration; - const viewDuration = viewEndTimestamp - viewStartTimestamp; - - return (spanBounds: SpanBoundsType): SpanGeneratedBoundsType => { - const {startTimestamp, endTimestamp} = spanBounds; - - const start = (startTimestamp - viewStartTimestamp) / viewDuration; - - if (!isNumber(endTimestamp)) { - return { - start, - end: 1, - }; - } - - return { - start, - end: (endTimestamp - viewStartTimestamp) / viewDuration, - }; - }; -}; - -export const getHumanDuration = (duration: number): string => { - // note: duration is assumed to be in seconds - - const durationMS = duration * 1000; - return `${durationMS.toFixed(3)} ms`; -}; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.jsx b/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.jsx index 5d29fa96741364..bcdcb406df9b9d 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.jsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.jsx @@ -17,6 +17,7 @@ import ModalPagination from './modalPagination'; import ModalLineGraph from './modalLineGraph'; import RelatedEvents from './relatedEvents'; import TagsTable from './tagsTable'; +import TransanctionView from './transactionView'; /** * Render the columns and navigation elements inside the event modal view. @@ -50,7 +51,11 @@ const EventModalContent = props => { })} - + {event.type === 'transaction' ? ( + + ) : ( + + )} {event.groupID && ( diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/dividerHandlerManager.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/dividerHandlerManager.tsx new file mode 100644 index 00000000000000..92761b65daebff --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/dividerHandlerManager.tsx @@ -0,0 +1,228 @@ +import React from 'react'; + +import { + rectOfContent, + clamp, + toPercent, + UserSelectValues, + setBodyUserSelect, +} from './utils'; + +// divider handle is positioned at 50% width from the left-hand side +const DEFAULT_DIVIDER_POSITION = 0.5; + +const selectRefs = ( + refs: Array>, + transform: (dividerDOM: HTMLDivElement) => void +) => { + refs.forEach(ref => { + if (ref.current) { + transform(ref.current); + } + }); +}; + +export type DividerHandlerManagerChildrenProps = { + dividerPosition: number; + setHover: (nextHover: boolean) => void; + onDragStart: (event: React.MouseEvent) => void; + addDividerLineRef: () => React.RefObject; + addGhostDividerLineRef: () => React.RefObject; +}; + +type StateType = { + dividerPosition: number; // between 0 and 1 +}; + +const DividerManagerContext = React.createContext({ + dividerPosition: DEFAULT_DIVIDER_POSITION, + onDragStart: () => {}, + setHover: () => {}, + addDividerLineRef: () => { + return React.createRef(); + }, + addGhostDividerLineRef: () => { + return React.createRef(); + }, +}); + +type PropType = { + children: React.ReactNode; + + // this is the DOM element where the drag events occur. it's also the reference point + // for calculating the relative mouse x coordinate. + interactiveLayerRef: React.RefObject; +}; + +export class Provider extends React.Component { + state: StateType = { + dividerPosition: DEFAULT_DIVIDER_POSITION, + }; + + previousUserSelect: UserSelectValues | null = null; + dividerHandlePosition: number = DEFAULT_DIVIDER_POSITION; + isDragging: boolean = false; + dividerLineRefs: Array> = []; + ghostDividerLineRefs: Array> = []; + + hasInteractiveLayer = (): boolean => { + return !!this.props.interactiveLayerRef.current; + }; + + addDividerLineRef = () => { + const ref = React.createRef(); + this.dividerLineRefs.push(ref); + return ref; + }; + + addGhostDividerLineRef = () => { + const ref = React.createRef(); + this.ghostDividerLineRefs.push(ref); + return ref; + }; + + setHover = (nextHover: boolean) => { + if (this.isDragging) { + return; + } + + selectRefs(this.dividerLineRefs, dividerDOM => { + if (nextHover) { + dividerDOM.classList.add('hovering'); + return; + } + + dividerDOM.classList.remove('hovering'); + }); + }; + + onDragStart = (event: React.MouseEvent) => { + if (this.isDragging || event.type !== 'mousedown' || !this.hasInteractiveLayer()) { + return; + } + + event.stopPropagation(); + + // prevent the user from selecting things outside the minimap when dragging + // the mouse cursor inside the minimap + + this.previousUserSelect = setBodyUserSelect({ + userSelect: 'none', + MozUserSelect: 'none', + msUserSelect: 'none', + }); + + // attach event listeners so that the mouse cursor does not select text during a drag + window.addEventListener('mousemove', this.onDragMove); + window.addEventListener('mouseup', this.onDragEnd); + + // indicate drag has begun + + this.isDragging = true; + + selectRefs(this.dividerLineRefs, (dividerDOM: HTMLDivElement) => { + dividerDOM.style.backgroundColor = 'rgba(73,80,87,0.75)'; + }); + + selectRefs(this.ghostDividerLineRefs, (dividerDOM: HTMLDivElement) => { + dividerDOM.style.display = 'block'; + }); + }; + + onDragMove = (event: MouseEvent) => { + if (!this.isDragging || event.type !== 'mousemove' || !this.hasInteractiveLayer()) { + return; + } + + const rect = rectOfContent(this.props.interactiveLayerRef.current!); + + // mouse x-coordinate relative to the interactive layer's left side + const rawMouseX = (event.pageX - rect.x) / rect.width; + + const min = 0; + const max = 1; + + // clamp rawMouseX to be within [0, 1] + this.dividerHandlePosition = clamp(rawMouseX, min, max); + + const dividerHandlePositionString = toPercent(this.dividerHandlePosition); + + selectRefs(this.dividerLineRefs, (dividerDOM: HTMLDivElement) => { + dividerDOM.style.left = dividerHandlePositionString; + }); + }; + + onDragEnd = (event: MouseEvent) => { + if (!this.isDragging || event.type !== 'mouseup' || !this.hasInteractiveLayer()) { + return; + } + + // remove listeners that were attached in onDragStart + + this.cleanUpListeners(); + + // restore body styles + + if (this.previousUserSelect) { + setBodyUserSelect(this.previousUserSelect); + this.previousUserSelect = null; + } + + // indicate drag has ended + + this.isDragging = false; + + selectRefs(this.dividerLineRefs, (dividerDOM: HTMLDivElement) => { + dividerDOM.style.backgroundColor = null; + }); + + selectRefs(this.ghostDividerLineRefs, (dividerDOM: HTMLDivElement) => { + dividerDOM.style.display = 'none'; + }); + + this.setState({ + // commit dividerHandlePosition to be dividerPosition + dividerPosition: this.dividerHandlePosition, + }); + }; + + cleanUpListeners = () => { + if (this.isDragging) { + // we only remove listeners during a drag + window.removeEventListener('mousemove', this.onDragMove); + window.removeEventListener('mouseup', this.onDragEnd); + } + }; + + componentWillUnmount() { + this.cleanUpListeners(); + } + + render() { + const childrenProps = { + dividerPosition: this.state.dividerPosition, + setHover: this.setHover, + onDragStart: this.onDragStart, + addDividerLineRef: this.addDividerLineRef, + addGhostDividerLineRef: this.addGhostDividerLineRef, + }; + + // NOTE: will not re-render its children + // - if the `value` prop changes, and + // - if the `children` prop stays the same + // + // Thus, only components will re-render. + // This is an optimization for when childrenProps changes, but this.props does not change. + // + // We prefer to minimize the amount of top-down prop drilling from this component + // to the respective divider components. + + return ( + + {this.props.children} + + ); + } +} + +export const Consumer = DividerManagerContext.Consumer; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/dragManager.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/dragManager.tsx similarity index 81% rename from src/sentry/static/sentry/app/components/events/interfaces/spans/dragManager.tsx rename to src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/dragManager.tsx index 41c30cbfb35999..43490d95358c1f 100644 --- a/src/sentry/static/sentry/app/components/events/interfaces/spans/dragManager.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/dragManager.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {rectOfContent, clamp} from './utils'; +import {rectOfContent, clamp, UserSelectValues, setBodyUserSelect} from './utils'; // we establish the minimum window size so that the window size of 0% is not possible const MINIMUM_WINDOW_SIZE = 0.5 / 100; // 0.5% window size @@ -15,19 +15,22 @@ export type DragManagerChildrenProps = { // left-side handle - onLeftHandleDragStart: (event: React.MouseEvent) => void; + onLeftHandleDragStart: (event: React.MouseEvent) => void; leftHandlePosition: number; // between 0 to 1 viewWindowStart: number; // between 0 to 1 // right-side handle - onRightHandleDragStart: (event: React.MouseEvent) => void; + onRightHandleDragStart: (event: React.MouseEvent) => void; rightHandlePosition: number; // between 0 to 1 viewWindowEnd: number; // between 0 to 1 }; type DragManagerProps = { children: (props: DragManagerChildrenProps) => JSX.Element; + + // this is the DOM element where the drag events occur. it's also the reference point + // for calculating the relative mouse x coordinate. interactiveLayerRef: React.RefObject; }; @@ -52,14 +55,14 @@ class DragManager extends React.Component { viewWindowEnd: 1, }; - previousUserSelect: string | null = null; + previousUserSelect: UserSelectValues | null = null; hasInteractiveLayer = (): boolean => { return !!this.props.interactiveLayerRef.current; }; onDragStart = (viewHandle: ViewHandleType) => ( - event: React.MouseEvent + event: React.MouseEvent ) => { if ( this.state.isDragging || @@ -72,8 +75,11 @@ class DragManager extends React.Component { // prevent the user from selecting things outside the minimap when dragging // the mouse cursor outside the minimap - this.previousUserSelect = document.body.style.userSelect; - document.body.style.userSelect = 'none'; + this.previousUserSelect = setBodyUserSelect({ + userSelect: 'none', + MozUserSelect: 'none', + msUserSelect: 'none', + }); // attach event listeners so that the mouse cursor can drag outside of the // minimap @@ -88,11 +94,11 @@ class DragManager extends React.Component { }); }; - onLeftHandleDragStart = (event: React.MouseEvent) => { + onLeftHandleDragStart = (event: React.MouseEvent) => { this.onDragStart(ViewHandleType.Left)(event); }; - onRightHandleDragStart = (event: React.MouseEvent) => { + onRightHandleDragStart = (event: React.MouseEvent) => { this.onDragStart(ViewHandleType.Right)(event); }; @@ -148,13 +154,14 @@ class DragManager extends React.Component { // remove listeners that were attached in onDragStart - window.removeEventListener('mousemove', this.onDragMove); - window.removeEventListener('mouseup', this.onDragEnd); + this.cleanUpListeners(); // restore body styles - document.body.style.userSelect = this.previousUserSelect; - this.previousUserSelect = null; + if (this.previousUserSelect) { + setBodyUserSelect(this.previousUserSelect); + this.previousUserSelect = null; + } // indicate drag has ended @@ -169,7 +176,7 @@ class DragManager extends React.Component { viewWindowStart: state.leftHandlePosition, }; }); - break; + return; } case ViewHandleType.Right: { this.setState(state => { @@ -181,19 +188,25 @@ class DragManager extends React.Component { viewWindowEnd: state.rightHandlePosition, }; }); - break; + return; } default: { throw Error('this.state.currentDraggingHandle is undefined'); } } + }; - this.setState({ - isDragging: false, - currentDraggingHandle: void 0, - }); + cleanUpListeners = () => { + if (this.state.isDragging) { + window.removeEventListener('mousemove', this.onDragMove); + window.removeEventListener('mouseup', this.onDragEnd); + } }; + componentWillUnmount() { + this.cleanUpListeners(); + } + render() { const childrenProps = { isDragging: this.state.isDragging, diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/index.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/index.tsx similarity index 60% rename from src/sentry/static/sentry/app/components/events/interfaces/spans/index.tsx rename to src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/index.tsx index 63fbb6fe7ff462..57d5ae902c0d91 100644 --- a/src/sentry/static/sentry/app/components/events/interfaces/spans/index.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/index.tsx @@ -5,14 +5,14 @@ import SentryTypes from 'app/sentryTypes'; import {Panel, PanelHeader, PanelBody} from 'app/components/panels'; -import {SpanEntry, SentryEvent} from './types'; -import TransactionView from './transactionView'; +import {SentryEvent} from './types'; +import TraceView from './traceView'; -type SpansInterfacePropTypes = { +type PropType = { event: SentryEvent; -} & SpanEntry; +}; -class SpansInterface extends React.Component { +class TransanctionView extends React.Component { static propTypes = { event: SentryTypes.Event.isRequired, }; @@ -22,14 +22,14 @@ class SpansInterface extends React.Component { return ( - {t('Trace View')} + {t('Trace View - This Transaction')} - + ); } } -export default SpansInterface; +export default TransanctionView; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/minimap.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/minimap.tsx new file mode 100644 index 00000000000000..e180a2f96cad04 --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/minimap.tsx @@ -0,0 +1,672 @@ +import React from 'react'; +import styled from 'react-emotion'; + +import space from 'app/styles/space'; +import {get} from 'lodash'; + +import { + rectOfContent, + clamp, + toPercent, + getHumanDuration, + pickSpanBarColour, + boundsGenerator, + SpanBoundsType, + SpanGeneratedBoundsType, +} from './utils'; +import {DragManagerChildrenProps} from './dragManager'; +import {ParsedTraceType, TickAlignment, SpanType, SpanChildrenLookupType} from './types'; +import {zIndex} from './styles'; + +export const MINIMAP_CONTAINER_HEIGHT = 106; +export const MINIMAP_SPAN_BAR_HEIGHT = 5; +const MINIMAP_HEIGHT = 75; +export const NUM_OF_SPANS_FIT_IN_MINI_MAP = MINIMAP_HEIGHT / MINIMAP_SPAN_BAR_HEIGHT; +const TIME_AXIS_HEIGHT = 30; +const VIEW_HANDLE_HEIGHT = 20; + +type PropType = { + minimapInteractiveRef: React.RefObject; + dragProps: DragManagerChildrenProps; + trace: ParsedTraceType; +}; + +type StateType = { + showCursorGuide: boolean; + mousePageX: number | undefined; + startViewHandleX: number; +}; + +class Minimap extends React.Component { + state: StateType = { + showCursorGuide: false, + mousePageX: void 0, + startViewHandleX: 100, + }; + + renderCursorGuide = (cursorGuideHeight: number) => { + if (!this.state.showCursorGuide || !this.state.mousePageX) { + return null; + } + + const interactiveLayer = this.props.minimapInteractiveRef.current; + + if (!interactiveLayer) { + return null; + } + + const rect = rectOfContent(interactiveLayer); + + // clamp mouseLeft to be within [0, 1] + const mouseLeft = clamp((this.state.mousePageX - rect.x) / rect.width, 0, 1); + + return ( + + ); + }; + + renderViewHandles = ({ + isDragging, + onLeftHandleDragStart, + leftHandlePosition, + onRightHandleDragStart, + rightHandlePosition, + viewWindowStart, + viewWindowEnd, + }: DragManagerChildrenProps) => { + const leftHandleGhost = isDragging ? ( + + ) : null; + + const leftHandle = ( + + ); + + const rightHandle = ( + + ); + + const rightHandleGhost = isDragging ? ( + + ) : null; + + return ( + + {leftHandleGhost} + {rightHandleGhost} + {leftHandle} + {rightHandle} + + ); + }; + + renderFog = (dragProps: DragManagerChildrenProps) => { + return ( + + + + + ); + }; + + renderDurationGuide = () => { + if (!this.state.showCursorGuide || !this.state.mousePageX) { + return null; + } + + const interactiveLayer = this.props.minimapInteractiveRef.current; + + if (!interactiveLayer) { + return null; + } + + const rect = rectOfContent(interactiveLayer); + + // clamp mouseLeft to be within [0, 1] + const mouseLeft = clamp((this.state.mousePageX - rect.x) / rect.width, 0, 1); + + const {trace} = this.props; + + const duration = + mouseLeft * Math.abs(trace.traceEndTimestamp - trace.traceStartTimestamp); + + const style = {top: 0, left: `calc(${mouseLeft * 100}% + 4px)`}; + + const alignLeft = (1 - mouseLeft) * rect.width <= 100; + + return ( + + {getHumanDuration(duration)} + + ); + }; + + renderTimeAxis = () => { + const {trace} = this.props; + + const duration = Math.abs(trace.traceEndTimestamp - trace.traceStartTimestamp); + + const firstTick = ( + + ); + + const secondTick = ( + + ); + + const thirdTick = ( + + ); + + const fourthTick = ( + + ); + + const lastTick = ( + + ); + + return ( + + {firstTick} + {secondTick} + {thirdTick} + {fourthTick} + {lastTick} + {this.renderCursorGuide(TIME_AXIS_HEIGHT)} + {this.renderDurationGuide()} + + ); + }; + + render() { + return ( + + +
{ + this.setState({ + showCursorGuide: true, + mousePageX: event.pageX, + }); + }} + onMouseLeave={() => { + this.setState({showCursorGuide: false, mousePageX: void 0}); + }} + onMouseMove={event => { + this.setState({ + showCursorGuide: true, + mousePageX: event.pageX, + }); + }} + > + + {this.renderFog(this.props.dragProps)} + {this.renderCursorGuide(MINIMAP_HEIGHT)} + {this.renderViewHandles(this.props.dragProps)} + + {this.renderTimeAxis()} +
+
+ ); + } +} + +class ActualMinimap extends React.PureComponent<{trace: ParsedTraceType}> { + renderRootSpan = (): JSX.Element => { + const {trace} = this.props; + + const generateBounds = boundsGenerator({ + traceStartTimestamp: trace.traceStartTimestamp, + traceEndTimestamp: trace.traceEndTimestamp, + viewStart: 0, + viewEnd: 1, + }); + + const rootSpan: SpanType = { + trace_id: trace.traceID, + span_id: trace.rootSpanID, + start_timestamp: trace.traceStartTimestamp, + timestamp: trace.traceEndTimestamp, + data: {}, + }; + + return this.renderSpan({ + spanNumber: 0, + generateBounds, + span: rootSpan, + childSpans: trace.childSpans, + }).spanTree; + }; + + getBounds = ( + bounds: SpanGeneratedBoundsType + ): { + left: string; + width: string; + } => { + switch (bounds.type) { + case 'TRACE_TIMESTAMPS_EQUAL': + case 'INVALID_VIEW_WINDOW': { + return { + left: toPercent(0), + width: '0px', + }; + } + + case 'TIMESTAMPS_EQUAL': { + return { + left: toPercent(bounds.start), + width: `${bounds.width}px`, + }; + } + case 'TIMESTAMPS_REVERSED': + case 'TIMESTAMPS_STABLE': { + return { + left: toPercent(bounds.start), + width: toPercent(bounds.end - bounds.start), + }; + } + default: { + const _exhaustiveCheck: never = bounds; + return _exhaustiveCheck; + } + } + }; + + renderSpan = ({ + spanNumber, + childSpans, + generateBounds, + span, + }: { + spanNumber: number; + childSpans: Readonly; + generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType; + span: Readonly; + }): { + spanTree: JSX.Element; + nextSpanNumber: number; + } => { + const spanBarColour: string = pickSpanBarColour(spanNumber); + + const bounds = generateBounds({ + startTimestamp: span.start_timestamp, + endTimestamp: span.timestamp, + }); + + const {left: spanLeft, width: spanWidth} = this.getBounds(bounds); + + const spanChildren: Array = get(childSpans, span.span_id, []); + + type AccType = { + nextSpanNumber: number; + renderedSpanChildren: Array; + }; + + const reduced: AccType = spanChildren.reduce( + (acc: AccType, spanChild) => { + const key = `${spanChild.span_id}`; + + const results = this.renderSpan({ + spanNumber: acc.nextSpanNumber, + childSpans, + generateBounds, + span: spanChild, + }); + + acc.renderedSpanChildren.push( + {results.spanTree} + ); + + acc.nextSpanNumber = results.nextSpanNumber; + + return acc; + }, + { + renderedSpanChildren: [], + nextSpanNumber: spanNumber + 1, + } + ); + + return { + nextSpanNumber: reduced.nextSpanNumber, + spanTree: ( + + + {reduced.renderedSpanChildren} + + ), + }; + }; + + render() { + return ( + + + {this.renderRootSpan()} + + + ); + } +} + +const TimeAxis = styled('div')` + width: 100%; + position: absolute; + left: 0; + top: ${MINIMAP_HEIGHT}px; + + border-top: 1px solid #d1cad8; + + height: ${TIME_AXIS_HEIGHT}px; + background-color: #faf9fb; + + color: #9585a3; + font-size: 10px; + font-weight: 500; +`; + +const TickLabelContainer = styled('div')` + height: ${TIME_AXIS_HEIGHT}px; + + position: absolute; + top: 0; + + user-select: none; +`; + +const TickText = styled('span')` + line-height: 1; + + position: absolute; + bottom: 8px; + white-space: nowrap; + + ${({align}: {align: TickAlignment}) => { + switch (align) { + case TickAlignment.Center: { + return 'transform: translateX(-50%)'; + } + case TickAlignment.Left: { + return null; + } + + case TickAlignment.Right: { + return 'transform: translateX(-100%)'; + } + + default: { + throw Error(`Invalid tick alignment: ${align}`); + } + } + }}; +`; + +const TickMarker = styled('div')` + width: 1px; + height: 5px; + + background-color: #d1cad8; + + position: absolute; + top: 0; + left: 0; + + transform: translateX(-50%); +`; + +const TickLabel = (props: { + style: React.CSSProperties; + hideTickMarker?: boolean; + align?: TickAlignment; + duration: number; +}) => { + const {style, duration, hideTickMarker = false, align = TickAlignment.Center} = props; + + return ( + + {hideTickMarker ? null : } + {getHumanDuration(duration)} + + ); +}; + +const DurationGuideBox = styled('div')` + position: absolute; + + background-color: ${p => p.theme.white}; + padding: 4px; + + border-radius: 3px; + border: 1px solid rgba(0, 0, 0, 0.1); + + height: 16px; + + line-height: 1; + vertical-align: middle; + + transform: translateY(50%); + + white-space: nowrap; + + ${({alignLeft}: {alignLeft: boolean}) => { + if (!alignLeft) { + return null; + } + + return 'transform: translateY(50%) translateX(-100%) translateX(-8px);'; + }}; +`; + +const MinimapContainer = styled('div')` + width: 100%; + position: sticky; + left: 0; + top: 0; + z-index: ${zIndex.minimapContainer}; + + background-color: #fff; + + border-bottom: 1px solid #d1cad8; + + height: ${MINIMAP_HEIGHT + TIME_AXIS_HEIGHT + 1}px; +`; + +const MinimapBackground = styled('div')` + height: ${MINIMAP_HEIGHT}px; + max-height: ${MINIMAP_HEIGHT}px; + overflow-y: hidden; + width: 100%; + position: absolute; + top: 0; + left: 0; +`; + +const InteractiveLayer = styled('div')` + height: ${MINIMAP_HEIGHT}px; + width: 100%; + position: relative; + left: 0; +`; + +const ViewHandleContainer = styled('div')` + position: absolute; + top: 0; + + height: ${MINIMAP_HEIGHT}px; +`; + +const ViewHandle = styled('div')` + position: absolute; + top: 0; + + background-color: #6c5fc7; + + cursor: col-resize; + + height: ${VIEW_HANDLE_HEIGHT}px; + + ${({isDragging}: {isDragging: boolean}) => { + if (isDragging) { + return ` + width: 6px; + transform: translate(-3px, ${MINIMAP_HEIGHT - VIEW_HANDLE_HEIGHT}px); + `; + } + + return ` + width: 4px; + transform: translate(-2px, ${MINIMAP_HEIGHT - VIEW_HANDLE_HEIGHT}px); + `; + }}; + + &:hover { + width: 6px; + transform: translate(-3px, ${MINIMAP_HEIGHT - VIEW_HANDLE_HEIGHT}px); + } +`; + +const Fog = styled('div')` + background-color: rgba(241, 245, 251, 0.5); + position: absolute; + top: 0; +`; + +const MinimapSpanBar = styled('div')` + position: relative; + height: ${MINIMAP_SPAN_BAR_HEIGHT}px; + min-height: ${MINIMAP_SPAN_BAR_HEIGHT}px; + max-height: ${MINIMAP_SPAN_BAR_HEIGHT}px; + + min-width: 1px; + + border-radius: 1px; +`; + +const BackgroundSlider = styled('div')` + position: relative; +`; + +const CursorGuide = styled('div')` + position: absolute; + top: 0; + width: 1px; + background-color: #e03e2f; + + transform: translateX(-50%); +`; + +const Handle = ({ + left, + onMouseDown, + isDragging, +}: { + left: number; + onMouseDown: (event: React.MouseEvent) => void; + isDragging: boolean; +}) => { + return ( + + + + + + + ); +}; + +export default Minimap; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanBar.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanBar.tsx new file mode 100644 index 00000000000000..4f25bf2b6e06e0 --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanBar.tsx @@ -0,0 +1,881 @@ +import React from 'react'; +import styled from 'react-emotion'; +import {get} from 'lodash'; +import 'intersection-observer'; // this is a polyfill + +import {t} from 'app/locale'; +import space from 'app/styles/space'; +import Count from 'app/components/count'; +import Tooltip from 'app/components/tooltip'; + +import { + toPercent, + SpanBoundsType, + SpanGeneratedBoundsType, + getHumanDuration, +} from './utils'; +import {SpanType, ParsedTraceType} from './types'; +import { + MINIMAP_CONTAINER_HEIGHT, + MINIMAP_SPAN_BAR_HEIGHT, + NUM_OF_SPANS_FIT_IN_MINI_MAP, +} from './minimap'; +import {SPAN_ROW_HEIGHT, SpanRow, zIndex} from './styles'; +import * as DividerHandlerManager from './dividerHandlerManager'; +import SpanDetail from './spanDetail'; + +// TODO: maybe use babel-plugin-preval +// for (let i = 0; i <= 1.0; i += 0.01) { +// INTERSECTION_THRESHOLDS.push(i); +// } +const INTERSECTION_THRESHOLDS: Array = [ + 0, + 0.01, + 0.02, + 0.03, + 0.04, + 0.05, + 0.06, + 0.07, + 0.08, + 0.09, + 0.1, + 0.11, + 0.12, + 0.13, + 0.14, + 0.15, + 0.16, + 0.17, + 0.18, + 0.19, + 0.2, + 0.21, + 0.22, + 0.23, + 0.24, + 0.25, + 0.26, + 0.27, + 0.28, + 0.29, + 0.3, + 0.31, + 0.32, + 0.33, + 0.34, + 0.35, + 0.36, + 0.37, + 0.38, + 0.39, + 0.4, + 0.41, + 0.42, + 0.43, + 0.44, + 0.45, + 0.46, + 0.47, + 0.48, + 0.49, + 0.5, + 0.51, + 0.52, + 0.53, + 0.54, + 0.55, + 0.56, + 0.57, + 0.58, + 0.59, + 0.6, + 0.61, + 0.62, + 0.63, + 0.64, + 0.65, + 0.66, + 0.67, + 0.68, + 0.69, + 0.7, + 0.71, + 0.72, + 0.73, + 0.74, + 0.75, + 0.76, + 0.77, + 0.78, + 0.79, + 0.8, + 0.81, + 0.82, + 0.83, + 0.84, + 0.85, + 0.86, + 0.87, + 0.88, + 0.89, + 0.9, + 0.91, + 0.92, + 0.93, + 0.94, + 0.95, + 0.96, + 0.97, + 0.98, + 0.99, + 1.0, +]; + +type SpanBarProps = { + trace: Readonly; + span: Readonly; + spanBarColour: string; + generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType; + treeDepth: number; + showSpanTree: boolean; + numOfSpanChildren: number; + spanNumber: number; + toggleSpanTree: () => void; +}; + +type SpanBarState = { + showDetail: boolean; +}; + +class SpanBar extends React.Component { + state: SpanBarState = { + showDetail: false, + }; + + spanRowDOMRef = React.createRef(); + intersectionObserver?: IntersectionObserver = void 0; + zoomLevel: number = 1; // assume initial zoomLevel is 100% + _mounted: boolean = false; + + toggleDisplayDetail = () => { + this.setState(state => { + return { + showDetail: !state.showDetail, + }; + }); + }; + + renderDetail = ({isVisible}: {isVisible: boolean}) => { + if (!this.state.showDetail || !isVisible) { + return null; + } + + const {span} = this.props; + + return ; + }; + + getBounds = (): { + warning: undefined | string; + left: undefined | string; + width: undefined | string; + isSpanVisibleInView: boolean; + } => { + const {span, generateBounds} = this.props; + + const bounds = generateBounds({ + startTimestamp: span.start_timestamp, + endTimestamp: span.timestamp, + }); + + switch (bounds.type) { + case 'TRACE_TIMESTAMPS_EQUAL': { + return { + warning: t('Trace timestamps are equal'), + left: void 0, + width: void 0, + isSpanVisibleInView: bounds.isSpanVisibleInView, + }; + } + case 'INVALID_VIEW_WINDOW': { + return { + warning: t('Invalid view window'), + left: void 0, + width: void 0, + isSpanVisibleInView: bounds.isSpanVisibleInView, + }; + } + case 'TIMESTAMPS_EQUAL': { + return { + warning: t('The start and end timestamps are equal'), + left: toPercent(bounds.start), + width: `${bounds.width}px`, + isSpanVisibleInView: bounds.isSpanVisibleInView, + }; + } + case 'TIMESTAMPS_REVERSED': { + return { + warning: t('The start and end timestamps are reversed'), + left: toPercent(bounds.start), + width: toPercent(bounds.end - bounds.start), + isSpanVisibleInView: bounds.isSpanVisibleInView, + }; + } + case 'TIMESTAMPS_STABLE': { + return { + warning: void 0, + left: toPercent(bounds.start), + width: toPercent(bounds.end - bounds.start), + isSpanVisibleInView: bounds.isSpanVisibleInView, + }; + } + default: { + const _exhaustiveCheck: never = bounds; + return _exhaustiveCheck; + } + } + }; + + renderSpanTreeToggler = ({left}: {left: number}) => { + const {numOfSpanChildren} = this.props; + + const chevron = this.props.showSpanTree ? : ; + + if (numOfSpanChildren <= 0) { + return null; + } + + return ( + + { + event.stopPropagation(); + + this.props.toggleSpanTree(); + }} + > + + + +
+ {chevron} +
+
+
+ ); + }; + + renderTitle = () => { + const {span, treeDepth} = this.props; + + const op = span.op ? {`${span.op} \u2014 `} : ''; + const description = get(span, 'description', span.span_id); + + const MARGIN_LEFT = 8; + const TOGGLE_BUTTON_MARGIN_RIGHT = 8; + const TOGGLE_BUTTON_MAX_WIDTH = 40; + + const left = + treeDepth * (TOGGLE_BUTTON_MAX_WIDTH + TOGGLE_BUTTON_MARGIN_RIGHT) + MARGIN_LEFT; + + return ( + + {this.renderSpanTreeToggler({left})} + + + {op} + {description} + + + + ); + }; + + connectObservers = () => { + if (!this.spanRowDOMRef.current) { + return; + } + + this.disconnectObservers(); + + /** + + We track intersections events between the span bar's DOM element + and the viewport's (root) intersection area. the intersection area is sized to + exclude the minimap. See below. + + By default, the intersection observer's root intersection is the viewport. + We adjust the margins of this root intersection area to exclude the minimap's + height. The minimap's height is always fixed. + + VIEWPORT (ancestor element used for the intersection events) + +--+-------------------------+--+ + | | | | + | | MINIMAP | | + | | | | + | +-------------------------+ | ^ + | | | | | + | | SPANS | | | ROOT + | | | | | INTERSECTION + | | | | | OBSERVER + | | | | | HEIGHT + | | | | | + | | | | | + | | | | | + | +-------------------------+ | | + | | | + +-------------------------------+ v + + */ + + this.intersectionObserver = new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (!this._mounted) { + return; + } + + const shouldMoveMinimap = + this.props.trace.numOfSpans > NUM_OF_SPANS_FIT_IN_MINI_MAP; + + if (!shouldMoveMinimap) { + return; + } + const spanNumber = this.props.spanNumber; + + const minimapSlider = document.getElementById('minimap-background-slider'); + + if (!minimapSlider) { + return; + } + + // NOTE: THIS IS HACKY. + // + // IntersectionObserver.rootMargin is un-affected by the browser's zoom level. + // The margins of the intersection area needs to be adjusted. + // Thus, IntersectionObserverEntry.rootBounds may not be what we expect. + // + // We address this below. + // + // Note that this function was called whenever an intersection event occurred wrt + // the thresholds. + // + if (entry.rootBounds) { + // After we create the IntersectionObserver instance with rootMargin set as: + // -${MINIMAP_CONTAINER_HEIGHT * this.zoomLevel}px 0px 0px 0px + // + // we can introspect the rootBounds to infer the zoomlevel. + // + // we always expect entry.rootBounds.top to equal MINIMAP_CONTAINER_HEIGHT + + const actualRootTop = Math.ceil(entry.rootBounds.top); + + if (actualRootTop !== MINIMAP_CONTAINER_HEIGHT && actualRootTop > 0) { + // we revert the actualRootTop value by the current zoomLevel factor + const normalizedActualTop = actualRootTop / this.zoomLevel; + + const zoomLevel = MINIMAP_CONTAINER_HEIGHT / normalizedActualTop; + this.zoomLevel = zoomLevel; + + // we reconnect the observers; the callback functions may be invoked + this.connectObservers(); + + // NOTE: since we cannot guarantee that the callback function is invoked on + // the newly connected observers, we continue running this function. + } + } + + // root refers to the root intersection rectangle used for the IntersectionObserver + const rectRelativeToRoot = entry.boundingClientRect as DOMRect; + + const bottomYCoord = rectRelativeToRoot.y + rectRelativeToRoot.height; + + // refers to if the rect is out of view from the viewport + const isOutOfViewAbove = rectRelativeToRoot.y < 0 && bottomYCoord < 0; + + if (isOutOfViewAbove) { + return; + } + + const relativeToMinimap = { + top: rectRelativeToRoot.y - MINIMAP_CONTAINER_HEIGHT, + bottom: bottomYCoord - MINIMAP_CONTAINER_HEIGHT, + }; + + const rectBelowMinimap = + relativeToMinimap.top > 0 && relativeToMinimap.bottom > 0; + + if (rectBelowMinimap) { + // if the first span is below the minimap, we scroll the minimap + // to the top. this addresss spurious scrolling to the top of the page + if (spanNumber <= 1) { + minimapSlider.style.top = '0px'; + return; + } + return; + } + + const inAndAboveMinimap = relativeToMinimap.bottom <= 0; + + if (inAndAboveMinimap) { + return; + } + + // invariant: spanNumber >= 1 + + const numberOfMovedSpans = spanNumber - 1; + const totalHeightOfHiddenSpans = numberOfMovedSpans * MINIMAP_SPAN_BAR_HEIGHT; + const currentSpanHiddenRatio = 1 - entry.intersectionRatio; + + const panYPixels = + totalHeightOfHiddenSpans + currentSpanHiddenRatio * MINIMAP_SPAN_BAR_HEIGHT; + + // invariant: this.props.trace.numOfSpansend - spanNumberToStopMoving + 1 = NUM_OF_SPANS_FIT_IN_MINI_MAP + + const spanNumberToStopMoving = + this.props.trace.numOfSpans + 1 - NUM_OF_SPANS_FIT_IN_MINI_MAP; + + if (spanNumber > spanNumberToStopMoving) { + // if the last span bar appears on the minimap, we do not want the minimap + // to keep panning upwards + minimapSlider.style.top = `-${spanNumberToStopMoving * + MINIMAP_SPAN_BAR_HEIGHT}px`; + return; + } + + minimapSlider.style.top = `-${panYPixels}px`; + }); + }, + { + threshold: INTERSECTION_THRESHOLDS, + rootMargin: `-${MINIMAP_CONTAINER_HEIGHT * this.zoomLevel}px 0px 0px 0px`, + } + ); + + this.intersectionObserver.observe(this.spanRowDOMRef.current); + }; + + disconnectObservers = () => { + if (this.intersectionObserver) { + this.intersectionObserver.disconnect(); + } + }; + + componentDidMount() { + this._mounted = true; + if (this.spanRowDOMRef.current) { + this.connectObservers(); + } + } + + componentWillUnmount() { + this._mounted = false; + this.disconnectObservers(); + } + + renderDivider = ( + dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps + ) => { + if (this.state.showDetail) { + // we would like to hide the divider lines when the span details + // has been expanded + return null; + } + + const { + dividerPosition, + addDividerLineRef, + addGhostDividerLineRef, + } = dividerHandlerChildrenProps; + + // We display the ghost divider line for whenever the divider line is being dragged. + // The ghost divider line indicates the original position of the divider line + const ghostDivider = ( + { + // 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(); + }} + /> + ); + + return ( + + {ghostDivider} + { + 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(); + }} + /> + + ); + }; + + renderWarningText = ({warningText}: {warningText?: string} = {}) => { + if (!warningText) { + return null; + } + + return ( + + + + + + + + ); + }; + + renderHeader = ( + dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps + ) => { + const {span, spanBarColour} = this.props; + + const startTimestamp: number = span.start_timestamp; + const endTimestamp: number = span.timestamp; + + const duration = Math.abs(endTimestamp - startTimestamp); + + const durationString = getHumanDuration(duration); + + const bounds = this.getBounds(); + + const {dividerPosition} = dividerHandlerChildrenProps; + + const displaySpanBar = bounds.left && bounds.width; + + return ( + + + {this.renderTitle()} + + + {displaySpanBar && ( + + )} + {durationString} + {this.renderWarningText({warningText: bounds.warning})} + + {this.renderDivider(dividerHandlerChildrenProps)} + + ); + }; + + render() { + const bounds = this.getBounds(); + + const isSpanVisibleInView = bounds.isSpanVisibleInView; + + return ( + { + this.toggleDisplayDetail(); + }} + > + + {( + dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps + ) => { + return this.renderHeader(dividerHandlerChildrenProps); + }} + + {this.renderDetail({isVisible: isSpanVisibleInView})} + + ); + } +} + +const SpanRowCellContainer = styled('div')` + position: relative; + height: ${SPAN_ROW_HEIGHT}px; +`; + +const SpanRowCell = styled('div')` + position: absolute; + + height: ${SPAN_ROW_HEIGHT}px; + + overflow: hidden; +`; + +export const DividerLine = styled('div')` + position: absolute; + height: ${SPAN_ROW_HEIGHT}px; + + transform: translateX(-50%); + + background-color: #cdc7d5; + z-index: ${zIndex.dividerLine}; + + &.hovering { + width: 4px !important; + cursor: col-resize; + } + + ${({hovering}: {hovering: boolean}) => { + if (!hovering) { + return 'width: 2px;'; + } + + return ` + width: 4px; + cursor: col-resize; + `; + }}; +`; + +const SpanBarTitleContainer = styled('div')` + display: flex; + align-items: center; + + height: ${SPAN_ROW_HEIGHT}px; + position: absolute; + left: 0; + top: 0; + width: 100%; +`; + +const SpanBarTitle = styled('div')` + position: relative; + top: 0; + + height: ${SPAN_ROW_HEIGHT}px; + line-height: ${SPAN_ROW_HEIGHT}px; + + color: #4a3e56; + font-size: 12px; + + user-select: none; + + white-space: nowrap; + + display: flex; + align-items: center; +`; + +const SpanTreeTogglerContainer = styled('div')` + position: relative; + top: 0; + + height: 15px; + + max-width: 40px; + width: 40px; + min-width: 40px; + + margin-right: 8px; + + z-index: ${zIndex.spanTreeToggler}; + + user-select: none; + + display: flex; + justify-content: flex-end; +`; + +const SpanTreeToggler = styled('div')` + position: relative; + + white-space: nowrap; + + height: 15px; + min-width: 25px; + + padding-left: 4px; + padding-right: 4px; + + display: flex; + flex-wrap: nowrap; + align-items: center; + align-content: center; + justify-content: center; + + > span { + flex-grow: 999; + } + + transition: all 0.15s ease-in-out; + + border-radius: 99px; + + ${({isExpanded}: {isExpanded: boolean}) => { + if (!isExpanded) { + return ` + background: #6e5f7d; + border: 1px solid #452650; + color: #ffffff; + & svg path { + stroke: #ffffff; + } + + &:hover { + background: #fbfaf9; + border: 1px solid #6e5f7d; + color: #6e5f7d; + & svg path { + stroke: #452650; + } + } + `; + } + + return ` + background: #fbfaf9; + border: 1px solid #6e5f7d; + color: #6e5f7d; + + &:hover { + background: #6e5f7d; + border: 1px solid #452650; + color: #ffffff; + & svg path { + stroke: #ffffff; + } + } + `; + }}; + + font-size: 9px; + line-height: 0; +`; + +const Duration = styled('div')` + position: absolute; + right: 0; + top: 0; + height: ${SPAN_ROW_HEIGHT}px; + line-height: ${SPAN_ROW_HEIGHT}px; + + color: #9585a3; + font-size: 12px; + padding-right: ${space(1)}; + + user-select: none; +`; + +const SpanBarRectangle = styled('div')` + position: relative; + min-height: ${SPAN_ROW_HEIGHT - 4}px; + height: ${SPAN_ROW_HEIGHT - 4}px; + max-height: ${SPAN_ROW_HEIGHT - 4}px; + + min-width: 1px; + + margin-top: 2px; + margin-bottom: 2px; + border-radius: 3px; + + overflow: hidden; + + user-select: none; + + transition: border-color 0.15s ease-in-out; + border: 1px solid rgba(0, 0, 0, 0); +`; + +const ChevronOpen = props => ( + + + +); + +const ChevronClosed = props => ( + + + +); + +const WarningIcon = props => ( + + + + +); + +const WarningTextWrapper = styled('div')` + height: ${SPAN_ROW_HEIGHT}px; + + position: absolute; + left: 0; + top: 0; + + display: flex; + align-items: center; +`; + +export default SpanBar; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/spanDetail.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanDetail.tsx similarity index 97% rename from src/sentry/static/sentry/app/components/events/interfaces/spans/spanDetail.tsx rename to src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanDetail.tsx index 1f44cc61cd688a..32d45368e7d812 100644 --- a/src/sentry/static/sentry/app/components/events/interfaces/spans/spanDetail.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanDetail.tsx @@ -71,7 +71,7 @@ const SpanDetail = (props: PropTypes) => { const SpanDetailContainer = styled('div')` border-bottom: 1px solid #d1cad8; padding: ${space(2)}; - background-color: #fff; + background-color: #faf9fb; cursor: auto; `; @@ -93,7 +93,7 @@ const Row = ({ {title} -
+        
           {children}
         
diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanGroup.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanGroup.tsx new file mode 100644 index 00000000000000..d4faf94432fb7f --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanGroup.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import {SpanBoundsType, SpanGeneratedBoundsType} from './utils'; +import {SpanType, ParsedTraceType} from './types'; +import SpanBar from './spanBar'; + +type PropType = { + span: Readonly; + trace: Readonly; + generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType; + treeDepth: number; + numOfSpanChildren: number; + renderedSpanChildren: Array; + spanBarColour: string; + spanNumber: number; +}; + +type State = { + showSpanTree: boolean; +}; + +class SpanGroup extends React.Component { + state: State = { + showSpanTree: true, + }; + + toggleSpanTree = () => { + this.setState(state => { + return { + showSpanTree: !state.showSpanTree, + }; + }); + }; + + renderSpanChildren = () => { + if (!this.state.showSpanTree) { + return null; + } + + return this.props.renderedSpanChildren; + }; + + render() { + const { + spanBarColour, + span, + numOfSpanChildren, + trace, + generateBounds, + treeDepth, + spanNumber, + } = this.props; + + return ( + + + {this.renderSpanChildren()} + + ); + } +} + +export default SpanGroup; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanTree.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanTree.tsx new file mode 100644 index 00000000000000..31f127767db317 --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/spanTree.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import styled from 'react-emotion'; +import {get} from 'lodash'; + +import {t} from 'app/locale'; + +import {SpanType, SpanChildrenLookupType, ParsedTraceType} from './types'; +import { + boundsGenerator, + SpanBoundsType, + SpanGeneratedBoundsType, + pickSpanBarColour, +} from './utils'; +import {DragManagerChildrenProps} from './dragManager'; +import SpanGroup from './spanGroup'; +import {SpanRowMessage} from './styles'; +import * as DividerHandlerManager from './dividerHandlerManager'; + +type RenderedSpanTree = { + spanTree: JSX.Element | null; + nextSpanNumber: number; + numOfHiddenSpansAbove: number; +}; + +type PropType = { + trace: ParsedTraceType; + dragProps: DragManagerChildrenProps; +}; + +class SpanTree extends React.Component { + traceViewRef = React.createRef(); + + shouldComponentUpdate(nextProps: PropType) { + if (nextProps.dragProps.isDragging) { + return false; + } + + return true; + } + + renderSpan = ({ + spanNumber, + treeDepth, + numOfHiddenSpansAbove, + childSpans, + span, + generateBounds, + }: { + spanNumber: number; + treeDepth: number; + numOfHiddenSpansAbove: number; + span: Readonly; + childSpans: Readonly; + generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType; + }): RenderedSpanTree => { + const spanBarColour: string = pickSpanBarColour(spanNumber - 1); + + const spanChildren: Array = get(childSpans, span.span_id, []); + + const bounds = generateBounds({ + startTimestamp: span.start_timestamp, + endTimestamp: span.timestamp, + }); + + const isCurrentSpanHidden = !bounds.isSpanVisibleInView; + + type AccType = { + renderedSpanChildren: Array; + nextSpanNumber: number; + numOfHiddenSpansAbove: number; + }; + + const reduced: AccType = spanChildren.reduce( + (acc: AccType, spanChild) => { + const key = `${span.trace_id}${spanChild.span_id}`; + + const results = this.renderSpan({ + spanNumber: acc.nextSpanNumber, + treeDepth: treeDepth + 1, + numOfHiddenSpansAbove: acc.numOfHiddenSpansAbove, + span: spanChild, + childSpans, + generateBounds, + }); + + acc.renderedSpanChildren.push( + {results.spanTree} + ); + + acc.numOfHiddenSpansAbove = results.numOfHiddenSpansAbove; + + acc.nextSpanNumber = results.nextSpanNumber; + + return acc; + }, + { + renderedSpanChildren: [], + nextSpanNumber: spanNumber + 1, + numOfHiddenSpansAbove: isCurrentSpanHidden ? numOfHiddenSpansAbove + 1 : 0, + } + ); + + const showHiddenSpansMessage = !isCurrentSpanHidden && numOfHiddenSpansAbove > 0; + + const hiddenSpansMessage = showHiddenSpansMessage ? ( + + + {t('Number of hidden spans:')} {numOfHiddenSpansAbove} + + + ) : null; + + return { + numOfHiddenSpansAbove: reduced.numOfHiddenSpansAbove, + nextSpanNumber: reduced.nextSpanNumber, + spanTree: ( + + {hiddenSpansMessage} + + + ), + }; + }; + + renderRootSpan = (): RenderedSpanTree => { + const {dragProps, trace} = this.props; + + const rootSpan: SpanType = { + trace_id: trace.traceID, + span_id: trace.rootSpanID, + start_timestamp: trace.traceStartTimestamp, + timestamp: trace.traceEndTimestamp, + op: 'transaction', + data: {}, + }; + + const generateBounds = boundsGenerator({ + traceStartTimestamp: trace.traceStartTimestamp, + traceEndTimestamp: trace.traceEndTimestamp, + viewStart: dragProps.viewWindowStart, + viewEnd: dragProps.viewWindowEnd, + }); + + return this.renderSpan({ + spanNumber: 1, + treeDepth: 0, + numOfHiddenSpansAbove: 0, + span: rootSpan, + childSpans: trace.childSpans, + generateBounds, + }); + }; + + render() { + const {spanTree, numOfHiddenSpansAbove} = this.renderRootSpan(); + + const hiddenSpansMessage = + numOfHiddenSpansAbove > 0 ? ( + + + {t('Number of hidden spans:')} {numOfHiddenSpansAbove} + + + ) : null; + + return ( + + + {spanTree} + {hiddenSpansMessage} + + + ); + } +} + +const TraceViewContainer = styled('div')` + overflow-x: hidden; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +`; + +export default SpanTree; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/styles.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/styles.tsx new file mode 100644 index 00000000000000..f82acdf3f4153c --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/styles.tsx @@ -0,0 +1,48 @@ +import styled from 'react-emotion'; + +import space from 'app/styles/space'; + +export const zIndex = { + minimapContainer: 99999999999, + dividerLine: 999999, + spanTreeToggler: 99999, +}; + +export const SPAN_ROW_HEIGHT = 25; + +export const SpanRow = styled('div')` + position: relative; + overflow: hidden; + + min-height: ${SPAN_ROW_HEIGHT}px; + + cursor: pointer; + transition: background-color 0.15s ease-in-out; + + &:last-child { + & > [data-component='span-detail'] { + border-bottom: none !important; + } + } + + &:hover { + background-color: rgba(189, 180, 199, 0.1); + } +`; + +export const SpanRowMessage = styled(SpanRow)` + cursor: auto; + + color: #4a3e56; + font-size: 12px; + line-height: ${SPAN_ROW_HEIGHT}px; + + padding-left: ${space(1)}; + padding-right: ${space(1)}; + + background-color: #f1f5fb !important; + + outline: 1px solid #c9d4ea; + + z-index: 99999; +`; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/transactionView.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/traceView.tsx similarity index 78% rename from src/sentry/static/sentry/app/components/events/interfaces/spans/transactionView.tsx rename to src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/traceView.tsx index 5437ab5901e287..b5e05a40cead5b 100644 --- a/src/sentry/static/sentry/app/components/events/interfaces/spans/transactionView.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/traceView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {get, set, isNumber, forEach} from 'lodash'; +import {get, set, isNumber} from 'lodash'; import {t} from 'app/locale'; import EmptyStateWarning from 'app/components/emptyStateWarning'; @@ -16,42 +16,16 @@ type TraceContextType = { trace_id: string; }; -type TransactionViewProps = { +type PropType = { event: Readonly; }; -type TransactionViewState = { - renderMinimap: boolean; -}; - -class TransactionView extends React.Component< - TransactionViewProps, - TransactionViewState -> { +class TraceView extends React.Component { minimapInteractiveRef = React.createRef(); - traceViewRef = React.createRef(); - - state: TransactionViewState = { - renderMinimap: false, - }; - - componentDidMount() { - if (this.traceViewRef.current) { - // eslint-disable-next-line react/no-did-mount-set-state - this.setState({ - renderMinimap: true, - }); - } - } renderMinimap = (dragProps: DragManagerChildrenProps, parsedTrace: ParsedTraceType) => { - if (!this.state.renderMinimap) { - return null; - } - return ( { @@ -105,11 +81,11 @@ class TransactionView extends React.Component< return acc; } - const spanChildren: Array = get(acc.lookup, span.parent_span_id!, []); + const spanChildren: Array = get(acc.childSpans, span.parent_span_id!, []); spanChildren.push(span); - set(acc.lookup, span.parent_span_id!, spanChildren); + set(acc.childSpans, span.parent_span_id!, spanChildren); if (!acc.traceStartTimestamp || span.start_timestamp < acc.traceStartTimestamp) { acc.traceStartTimestamp = span.start_timestamp; @@ -143,7 +119,7 @@ class TransactionView extends React.Component< // sort span children by their start timestamps in ascending order - forEach(reduced.lookup, spanChildren => { + Object.values(reduced.childSpans).forEach(spanChildren => { spanChildren.sort((firstSpan, secondSpan) => { if (firstSpan.start_timestamp < secondSpan.start_timestamp) { return -1; @@ -177,11 +153,7 @@ class TransactionView extends React.Component< return ( {this.renderMinimap(dragProps, parsedTrace)} - + ); }} @@ -190,4 +162,4 @@ class TransactionView extends React.Component< } } -export default TransactionView; +export default TraceView; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/types.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/types.tsx similarity index 87% rename from src/sentry/static/sentry/app/components/events/interfaces/spans/types.tsx rename to src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/types.tsx index a2dc2308903339..289e8b8a3e47b8 100644 --- a/src/sentry/static/sentry/app/components/events/interfaces/spans/types.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/types.tsx @@ -4,7 +4,7 @@ export type SpanType = { span_id: string; start_timestamp: number; timestamp: number; // this is essentially end_timestamp - same_process_as_parent: boolean; + same_process_as_parent?: boolean; op?: string; description?: string; data: Object; @@ -24,11 +24,12 @@ export type SentryEvent = { export type SpanChildrenLookupType = {[span_id: string]: Array}; export type ParsedTraceType = { - lookup: SpanChildrenLookupType; + childSpans: SpanChildrenLookupType; traceID: string; rootSpanID: string; traceStartTimestamp: number; traceEndTimestamp: number; + numOfSpans: number; }; export enum TickAlignment { diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/utils.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/utils.tsx new file mode 100644 index 00000000000000..74dc3b1ce34fce --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/transactionView/utils.tsx @@ -0,0 +1,271 @@ +import {isString} from 'lodash'; + +type Rect = { + // x and y are left/top coords respectively + x: number; + y: number; + width: number; + height: number; +}; + +// get position of element relative to top/left of document +const getOffsetOfElement = (element: Element) => { + // left and top are relative to viewport + const {left, top} = element.getBoundingClientRect(); + + // get values that the document is currently scrolled by + const scrollLeft = window.pageXOffset; + const scrollTop = window.pageYOffset; + + return {x: left + scrollLeft, y: top + scrollTop}; +}; + +export const rectOfContent = (element: Element): Rect => { + const {x, y} = getOffsetOfElement(element); + + // offsets for the border and any scrollbars (clientLeft and clientTop), + // and if the element was scrolled (scrollLeft and scrollTop) + // + // NOTE: clientLeft and clientTop does not account for any margins nor padding + const contentOffsetLeft = element.clientLeft - element.scrollLeft; + const contentOffsetTop = element.clientTop - element.scrollTop; + + return { + x: x + contentOffsetLeft, + y: y + contentOffsetTop, + width: element.scrollWidth, + height: element.scrollHeight, + }; +}; + +export const rectOfViewport = (): Rect => { + return { + x: window.pageXOffset, + y: window.pageYOffset, + width: window.document.documentElement.clientWidth, + height: window.document.documentElement.clientHeight, + }; +}; + +export const rectRelativeTo = (rect: Rect, pos = {x: 0, y: 0}): Rect => { + return { + x: rect.x - pos.x, + y: rect.y - pos.y, + width: rect.width, + height: rect.height, + }; +}; + +export const rectOfElement = (element: HTMLElement): Rect => { + const {x, y} = getOffsetOfElement(element); + return { + x, + y, + width: element.offsetWidth, + height: element.offsetHeight, + }; +}; + +export const clamp = (value: number, min: number, max: number): number => { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +}; + +export const isValidSpanID = (maybeSpanID: any) => { + return isString(maybeSpanID) && maybeSpanID.length > 0; +}; + +export const toPercent = (value: number) => { + return `${(value * 100).toFixed(3)}%`; +}; + +export type SpanBoundsType = {startTimestamp: number; endTimestamp: number}; +export type SpanGeneratedBoundsType = + | {type: 'TRACE_TIMESTAMPS_EQUAL'; isSpanVisibleInView: boolean} + | {type: 'INVALID_VIEW_WINDOW'; isSpanVisibleInView: boolean} + | { + type: 'TIMESTAMPS_EQUAL'; + start: number; + width: number; + isSpanVisibleInView: boolean; + } + | { + type: 'TIMESTAMPS_REVERSED'; + start: number; + end: number; + isSpanVisibleInView: boolean; + } + | { + type: 'TIMESTAMPS_STABLE'; + start: number; + end: number; + isSpanVisibleInView: boolean; + }; + +const normalizeTimestamps = (spanBounds: SpanBoundsType): SpanBoundsType => { + const {startTimestamp, endTimestamp} = spanBounds; + + if (startTimestamp > endTimestamp) { + return {startTimestamp: endTimestamp, endTimestamp: startTimestamp}; + } + + return spanBounds; +}; + +export enum TimestampStatus { + Stable, + Reversed, + Equal, +} + +export const parseSpanTimestamps = (spanBounds: SpanBoundsType): TimestampStatus => { + const startTimestamp: number = spanBounds.startTimestamp; + const endTimestamp: number = spanBounds.endTimestamp; + + if (startTimestamp < endTimestamp) { + return TimestampStatus.Stable; + } + + if (startTimestamp === endTimestamp) { + return TimestampStatus.Equal; + } + + return TimestampStatus.Reversed; +}; + +// given the start and end trace timstamps, and the view window, we want to generate a function +// that'll output the relative %'s for the width and placements relative to the left-hand side. +// +// The view window (viewStart and viewEnd) are percentage values (between 0% and 100%), they correspond to the window placement +// between the start and end trace timestamps. +export const boundsGenerator = (bounds: { + traceStartTimestamp: number; // unix timestamp + traceEndTimestamp: number; // unix timestamp + viewStart: number; // in [0, 1] + viewEnd: number; // in [0, 1] +}) => { + const {viewStart, viewEnd} = bounds; + + const { + startTimestamp: traceStartTimestamp, + endTimestamp: traceEndTimestamp, + } = normalizeTimestamps({ + startTimestamp: bounds.traceStartTimestamp, + endTimestamp: bounds.traceEndTimestamp, + }); + + // viewStart and viewEnd are percentage values (%) of the view window relative to the left + // side of the trace view minimap + + // invariant: viewStart <= viewEnd + + // duration of the entire trace in seconds + const traceDuration = traceEndTimestamp - traceStartTimestamp; + + const viewStartTimestamp = traceStartTimestamp + viewStart * traceDuration; + const viewEndTimestamp = traceEndTimestamp - (1 - viewEnd) * traceDuration; + const viewDuration = viewEndTimestamp - viewStartTimestamp; + + return (spanBounds: SpanBoundsType): SpanGeneratedBoundsType => { + // TODO: alberto.... refactor so this is impossible 😠 + if (traceDuration <= 0) { + return { + type: 'TRACE_TIMESTAMPS_EQUAL', + isSpanVisibleInView: true, + }; + } + + if (viewDuration <= 0) { + return { + type: 'INVALID_VIEW_WINDOW', + isSpanVisibleInView: true, + }; + } + + const {startTimestamp, endTimestamp} = normalizeTimestamps(spanBounds); + + const timestampStatus = parseSpanTimestamps(spanBounds); + + const start = (startTimestamp - viewStartTimestamp) / viewDuration; + const end = (endTimestamp - viewStartTimestamp) / viewDuration; + + const isSpanVisibleInView = end > 0 && start < 1; + + switch (timestampStatus) { + case TimestampStatus.Equal: { + return { + type: 'TIMESTAMPS_EQUAL', + start, + width: 1, + isSpanVisibleInView, + }; + } + case TimestampStatus.Reversed: { + return { + type: 'TIMESTAMPS_REVERSED', + start, + end, + isSpanVisibleInView, + }; + } + case TimestampStatus.Stable: { + return { + type: 'TIMESTAMPS_STABLE', + start, + end, + isSpanVisibleInView, + }; + } + default: { + const _exhaustiveCheck: never = timestampStatus; + return _exhaustiveCheck; + } + } + }; +}; + +export const getHumanDuration = (duration: number): string => { + // note: duration is assumed to be in seconds + + const durationMS = duration * 1000; + return `${durationMS.toFixed(3)} ms`; +}; + +const COLORS = ['#8B7FD7', '#F2BE7C', '#ffe066', '#74c0fc']; +export const pickSpanBarColour = (spanNumberIndex: number) => { + spanNumberIndex = spanNumberIndex % COLORS.length; + + return COLORS[spanNumberIndex]; +}; + +export type UserSelectValues = { + userSelect: string | null; + MozUserSelect: string | null; + msUserSelect: string | null; +}; + +export const setBodyUserSelect = (nextValues: UserSelectValues): UserSelectValues => { + // NOTE: Vendor prefixes other than `ms` should begin with a capital letter. + // ref: https://reactjs.org/docs/dom-elements.html#style + + const previousValues = { + userSelect: document.body.style.userSelect, + // MozUserSelect is not typed in TS + // @ts-ignore + MozUserSelect: document.body.style.MozUserSelect, + msUserSelect: document.body.style.msUserSelect, + }; + + document.body.style.userSelect = nextValues.userSelect; + // MozUserSelect is not typed in TS + // @ts-ignore + document.body.style.MozUserSelect = nextValues.MozUserSelect; + document.body.style.msUserSelect = nextValues.msUserSelect; + + return previousValues; +}; diff --git a/yarn.lock b/yarn.lock index ecc70070784ac0..9125705c34c4c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7338,6 +7338,11 @@ interpret@^1.0.0, interpret@^1.1.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614" integrity sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ= +intersection-observer@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.7.0.tgz#ee16bee978db53516ead2f0a8154b09b400bbdc9" + integrity sha512-Id0Fij0HsB/vKWGeBe9PxeY45ttRiBmhFyyt/geBdDHBYNctMRTE3dC1U3ujzz3lap+hVXlEcVaB56kZP/eEUg== + invariant@^2.1.0, invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360"