Browse Source

feat(apm): Transactions view v1 (#14225)

Closes SEN-866
Closes SEN-873
Alberto Leal 5 years ago
parent
commit
c0fa8e041d

+ 1 - 0
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",

+ 0 - 2
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 {

+ 0 - 613
src/sentry/static/sentry/app/components/events/interfaces/spans/minimap.tsx

@@ -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<HTMLDivElement>;
-  minimapInteractiveRef: React.RefObject<HTMLDivElement>;
-  dragProps: DragManagerChildrenProps;
-  trace: ParsedTraceType;
-};
-
-type MinimapState = {
-  showCursorGuide: boolean;
-  mousePageX: number | undefined;
-  startViewHandleX: number;
-};
-
-class Minimap extends React.Component<MinimapProps, MinimapState> {
-  state: MinimapState = {
-    showCursorGuide: false,
-    mousePageX: void 0,
-    startViewHandleX: 100,
-  };
-
-  minimapRef = React.createRef<HTMLCanvasElement>();
-
-  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<HTMLElement>('[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 (
-      <line
-        x1={`${mouseLeft}%`}
-        x2={`${mouseLeft}%`}
-        y1="0"
-        y2={MINIMAP_HEIGHT}
-        strokeWidth="1"
-        strokeOpacity="0.7"
-        style={{stroke: '#E03E2F'}}
-      />
-    );
-  };
-
-  renderViewHandles = ({
-    isDragging,
-    onLeftHandleDragStart,
-    leftHandlePosition,
-    viewWindowStart,
-    onRightHandleDragStart,
-    rightHandlePosition,
-    viewWindowEnd,
-  }: DragManagerChildrenProps) => {
-    const leftHandleGhost = isDragging ? (
-      <g>
-        <line
-          x1={toPercent(viewWindowStart)}
-          x2={toPercent(viewWindowStart)}
-          y1="0"
-          y2={MINIMAP_HEIGHT - 20}
-          strokeWidth="1"
-          strokeDasharray="4 3"
-          style={{stroke: '#6C5FC7'}}
-          opacity="0.5"
-        />
-        <ViewHandle
-          x={toPercent(viewWindowStart)}
-          onMouseDown={onLeftHandleDragStart}
-          isDragging={false}
-          opacity="0.5"
-        />
-      </g>
-    ) : null;
-
-    const leftHandle = (
-      <g>
-        <line
-          x1={toPercent(leftHandlePosition)}
-          x2={toPercent(leftHandlePosition)}
-          y1="0"
-          y2={MINIMAP_HEIGHT - 20}
-          strokeWidth="1"
-          strokeDasharray="4 3"
-          style={{stroke: '#6C5FC7'}}
-        />
-        <ViewHandle
-          x={toPercent(leftHandlePosition)}
-          onMouseDown={onLeftHandleDragStart}
-          isDragging={isDragging}
-        />
-      </g>
-    );
-
-    const rightHandle = (
-      <g>
-        <line
-          x1={toPercent(rightHandlePosition)}
-          x2={toPercent(rightHandlePosition)}
-          y1="0"
-          y2={MINIMAP_HEIGHT - 20}
-          strokeWidth="1"
-          strokeDasharray="4 3"
-          style={{stroke: '#6C5FC7'}}
-        />
-        <ViewHandle
-          x={toPercent(rightHandlePosition)}
-          onMouseDown={onRightHandleDragStart}
-          isDragging={isDragging}
-        />
-      </g>
-    );
-
-    const rightHandleGhost = isDragging ? (
-      <g>
-        <line
-          x1={toPercent(viewWindowEnd)}
-          x2={toPercent(viewWindowEnd)}
-          y1="0"
-          y2={MINIMAP_HEIGHT - 20}
-          strokeWidth="1"
-          strokeDasharray="4 3"
-          style={{stroke: '#6C5FC7'}}
-          opacity="0.5"
-        />
-        <ViewHandle
-          x={toPercent(viewWindowEnd)}
-          onMouseDown={onLeftHandleDragStart}
-          isDragging={false}
-          opacity="0.5"
-        />
-      </g>
-    ) : null;
-
-    return (
-      <React.Fragment>
-        {leftHandleGhost}
-        {rightHandleGhost}
-        {leftHandle}
-        {rightHandle}
-      </React.Fragment>
-    );
-  };
-
-  renderFog = (dragProps: DragManagerChildrenProps) => {
-    return (
-      <React.Fragment>
-        <Fog x={0} y={0} height="100%" width={toPercent(dragProps.viewWindowStart)} />
-        <Fog
-          x={toPercent(dragProps.viewWindowEnd)}
-          y={0}
-          height="100%"
-          width={toPercent(1 - dragProps.viewWindowEnd)}
-        />
-      </React.Fragment>
-    );
-  };
-
-  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 (
-      <DurationGuideBox style={style} alignLeft={alignLeft}>
-        <span>{getHumanDuration(duration)}</span>
-      </DurationGuideBox>
-    );
-  };
-
-  renderTimeAxis = () => {
-    const {trace} = this.props;
-
-    const duration = Math.abs(trace.traceEndTimestamp - trace.traceStartTimestamp);
-
-    const firstTick = (
-      <TickLabel
-        align={TickAlignment.Left}
-        hideTickMarker={true}
-        duration={0}
-        style={{
-          left: space(1),
-        }}
-      />
-    );
-
-    const secondTick = (
-      <TickLabel
-        duration={duration * 0.25}
-        style={{
-          left: '25%',
-        }}
-      />
-    );
-
-    const thirdTick = (
-      <TickLabel
-        duration={duration * 0.5}
-        style={{
-          left: '50%',
-        }}
-      />
-    );
-
-    const fourthTick = (
-      <TickLabel
-        duration={duration * 0.75}
-        style={{
-          left: '75%',
-        }}
-      />
-    );
-
-    const lastTick = (
-      <TickLabel
-        duration={duration}
-        align={TickAlignment.Right}
-        hideTickMarker={true}
-        style={{
-          right: space(1),
-        }}
-      />
-    );
-
-    return (
-      <TimeAxis>
-        {firstTick}
-        {secondTick}
-        {thirdTick}
-        {fourthTick}
-        {lastTick}
-        <svg
-          style={{
-            position: 'relative',
-            left: 0,
-            top: 0,
-            width: '100%',
-            height: `${TIME_AXIS_HEIGHT}px`,
-            overflow: 'visible',
-          }}
-        >
-          {this.renderTimeAxisCursorGuide()}
-        </svg>
-        {this.renderDurationGuide()}
-      </TimeAxis>
-    );
-  };
-
-  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 (
-      <line
-        x1={`${mouseLeft}%`}
-        x2={`${mouseLeft}%`}
-        y1="0"
-        y2={TIME_AXIS_HEIGHT}
-        strokeWidth="1"
-        strokeOpacity="0.7"
-        style={{stroke: '#E03E2F'}}
-      />
-    );
-  };
-
-  render() {
-    return (
-      <React.Fragment>
-        <MinimapContainer>
-          <MinimapBackground innerRef={this.minimapRef} />
-          <div
-            ref={this.props.minimapInteractiveRef}
-            style={{
-              width: '100%',
-              height: `${MINIMAP_HEIGHT + TIME_AXIS_HEIGHT}px`,
-              position: 'absolute',
-              left: 0,
-              top: 0,
-            }}
-            onMouseEnter={event => {
-              this.setState({
-                showCursorGuide: true,
-                mousePageX: event.pageX,
-              });
-            }}
-            onMouseLeave={() => {
-              this.setState({showCursorGuide: false, mousePageX: void 0});
-            }}
-            onMouseMove={event => {
-              this.setState({
-                showCursorGuide: true,
-                mousePageX: event.pageX,
-              });
-            }}
-          >
-            <InteractiveLayer style={{overflow: 'visible'}}>
-              {this.renderFog(this.props.dragProps)}
-              {this.renderMinimapCursorGuide()}
-              {this.renderViewHandles(this.props.dragProps)}
-            </InteractiveLayer>
-            {this.renderTimeAxis()}
-          </div>
-        </MinimapContainer>
-      </React.Fragment>
-    );
-  }
-}
-
-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 (
-    <TickLabelContainer style={style}>
-      {hideTickMarker ? null : <TickMarker />}
-      <TickText align={align}>{getHumanDuration(duration)}</TickText>
-    </TickLabelContainer>
-  );
-};
-
-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;

+ 0 - 547
src/sentry/static/sentry/app/components/events/interfaces/spans/spanTree.tsx

@@ -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<HTMLDivElement>;
-  trace: ParsedTraceType;
-  dragProps: DragManagerChildrenProps;
-};
-
-class SpanTree extends React.Component<SpanTreeProps> {
-  renderSpan = ({
-    treeDepth,
-    numOfHiddenSpansAbove,
-    spanID,
-    traceID,
-    lookup,
-    span,
-    generateBounds,
-    pickSpanBarColour,
-  }: {
-    treeDepth: number;
-    numOfHiddenSpansAbove: number;
-    spanID: string;
-    traceID: string;
-    span: Readonly<SpanType>;
-    lookup: Readonly<SpanChildrenLookupType>;
-    generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
-    pickSpanBarColour: () => string;
-  }): RenderedSpanTree => {
-    const spanBarColour: string = pickSpanBarColour();
-
-    const spanChildren: Array<SpanType> = 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<JSX.Element>;
-      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(
-          <React.Fragment key={key}>{results.spanTree}</React.Fragment>
-        );
-
-        acc.numOfHiddenSpansAbove = results.numOfHiddenSpansAbove;
-
-        return acc;
-      },
-      {
-        renderedSpanChildren: [],
-        numOfHiddenSpansAbove: isCurrentSpanHidden ? numOfHiddenSpansAbove + 1 : 0,
-      }
-    );
-
-    const showHiddenSpansMessage = !isCurrentSpanHidden && numOfHiddenSpansAbove > 0;
-
-    const hiddenSpansMessage = showHiddenSpansMessage ? (
-      <SpanRowMessage>
-        <span>Number of hidden spans: {numOfHiddenSpansAbove}</span>
-      </SpanRowMessage>
-    ) : null;
-
-    return {
-      numOfHiddenSpansAbove: reduced.numOfHiddenSpansAbove,
-      spanTree: (
-        <React.Fragment>
-          {hiddenSpansMessage}
-          <Span
-            span={span}
-            generateBounds={generateBounds}
-            treeDepth={treeDepth}
-            numOfSpanChildren={spanChildren.length}
-            renderedSpanChildren={reduced.renderedSpanChildren}
-            spanBarColour={spanBarColour}
-          />
-        </React.Fragment>
-      ),
-    };
-  };
-
-  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 ? (
-        <SpanRowMessage>
-          <span>Number of hidden spans: {numOfHiddenSpansAbove}</span>
-        </SpanRowMessage>
-      ) : null;
-
-    return (
-      <TraceViewContainer innerRef={this.props.traceViewRef}>
-        {spanTree}
-        {hiddenSpansMessage}
-      </TraceViewContainer>
-    );
-  }
-}
-
-type SpanPropTypes = {
-  span: Readonly<SpanType>;
-  generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
-  treeDepth: number;
-  numOfSpanChildren: number;
-  renderedSpanChildren: Array<JSX.Element>;
-  spanBarColour: string;
-};
-
-type SpanState = {
-  displayDetail: boolean;
-  showSpanTree: boolean;
-};
-
-class Span extends React.Component<SpanPropTypes, SpanState> {
-  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 <SpanDetail span={span} />;
-  };
-
-  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 ? <ChevronOpen /> : <ChevronClosed />;
-
-    if (numOfSpanChildren <= 0) {
-      return null;
-    }
-
-    return (
-      <SpanTreeTogglerContainer style={{left: `${left}px`}}>
-        <SpanTreeToggler
-          onClick={event => {
-            event.stopPropagation();
-
-            this.toggleSpanTree();
-          }}
-        >
-          <span style={{marginRight: '2px', textAlign: 'center'}}>
-            <Count value={numOfSpanChildren} />
-          </span>
-          <div style={{marginRight: '2px', width: '5px', textAlign: 'right'}}>
-            {chevron}
-          </div>
-        </SpanTreeToggler>
-      </SpanTreeTogglerContainer>
-    );
-  };
-
-  renderTitle = () => {
-    const {span, treeDepth} = this.props;
-
-    const op = span.op ? <strong>{`${span.op} \u2014 `}</strong> : '';
-    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 (
-      <SpanBarTitleContainer>
-        {this.renderSpanTreeToggler({left})}
-        <SpanBarTitle
-          data-component="span-bar-title"
-          style={{
-            left: `${left}px`,
-            width: '100%',
-          }}
-        >
-          <span>
-            {op}
-            {description}
-          </span>
-        </SpanBarTitle>
-      </SpanBarTitleContainer>
-    );
-  };
-
-  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 (
-      <React.Fragment>
-        <SpanRow
-          data-span-hidden={isVisible ? 'false' : 'true'}
-          style={{
-            display: isVisible ? 'block' : 'none',
-            boxShadow: this.state.displayDetail ? '0 -1px 0 #d1cad8' : void 0,
-          }}
-          onClick={() => {
-            this.toggleDisplayDetail();
-          }}
-        >
-          <SpanBar
-            data-span="true"
-            style={{
-              backgroundColor: spanBarColour,
-              left: toPercent(bounds.start),
-              width: toPercent(bounds.end - bounds.start),
-            }}
-          />
-          {this.renderTitle()}
-          <Duration>{durationString}</Duration>
-          {this.renderDetail({isVisible})}
-        </SpanRow>
-        {this.renderSpanChildren()}
-      </React.Fragment>
-    );
-  }
-}
-
-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 => (
-  <svg width={5} height={4} fill="none" {...props}>
-    <path
-      d="M.5 1.25l2 2 2-2"
-      stroke="#6E5F7D"
-      strokeWidth={0.75}
-      strokeLinecap="round"
-      strokeLinejoin="round"
-    />
-  </svg>
-);
-
-const ChevronClosed = props => (
-  <svg width={3} height={6} fill="none" {...props}>
-    <path
-      d="M.5 5.25l2-2-2-2"
-      stroke="#6E5F7D"
-      strokeWidth={0.75}
-      strokeLinecap="round"
-      strokeLinejoin="round"
-    />
-  </svg>
-);
-
-export default SpanTree;

+ 0 - 125
src/sentry/static/sentry/app/components/events/interfaces/spans/utils.tsx

@@ -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`;
-};

+ 6 - 1
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 => {
           })}
       </HeaderBox>
       <ContentColumn>
-        <EventInterfaces event={event} projectId={projectId} />
+        {event.type === 'transaction' ? (
+          <TransanctionView event={event} />
+        ) : (
+          <EventInterfaces event={event} projectId={projectId} />
+        )}
       </ContentColumn>
       <SidebarColumn>
         {event.groupID && (

+ 228 - 0
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<React.RefObject<HTMLDivElement>>,
+  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<HTMLDivElement, MouseEvent>) => void;
+  addDividerLineRef: () => React.RefObject<HTMLDivElement>;
+  addGhostDividerLineRef: () => React.RefObject<HTMLDivElement>;
+};
+
+type StateType = {
+  dividerPosition: number; // between 0 and 1
+};
+
+const DividerManagerContext = React.createContext<DividerHandlerManagerChildrenProps>({
+  dividerPosition: DEFAULT_DIVIDER_POSITION,
+  onDragStart: () => {},
+  setHover: () => {},
+  addDividerLineRef: () => {
+    return React.createRef<HTMLDivElement>();
+  },
+  addGhostDividerLineRef: () => {
+    return React.createRef<HTMLDivElement>();
+  },
+});
+
+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<HTMLDivElement>;
+};
+
+export class Provider extends React.Component<PropType, StateType> {
+  state: StateType = {
+    dividerPosition: DEFAULT_DIVIDER_POSITION,
+  };
+
+  previousUserSelect: UserSelectValues | null = null;
+  dividerHandlePosition: number = DEFAULT_DIVIDER_POSITION;
+  isDragging: boolean = false;
+  dividerLineRefs: Array<React.RefObject<HTMLDivElement>> = [];
+  ghostDividerLineRefs: Array<React.RefObject<HTMLDivElement>> = [];
+
+  hasInteractiveLayer = (): boolean => {
+    return !!this.props.interactiveLayerRef.current;
+  };
+
+  addDividerLineRef = () => {
+    const ref = React.createRef<HTMLDivElement>();
+    this.dividerLineRefs.push(ref);
+    return ref;
+  };
+
+  addGhostDividerLineRef = () => {
+    const ref = React.createRef<HTMLDivElement>();
+    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<HTMLDivElement, 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: <DividerManagerContext.Provider /> will not re-render its children
+    // - if the `value` prop changes, and
+    // - if the `children` prop stays the same
+    //
+    // Thus, only <DividerManagerContext.Consumer /> 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 (
+      <DividerManagerContext.Provider value={childrenProps}>
+        {this.props.children}
+      </DividerManagerContext.Provider>
+    );
+  }
+}
+
+export const Consumer = DividerManagerContext.Consumer;

+ 32 - 19
src/sentry/static/sentry/app/components/events/interfaces/spans/dragManager.tsx → 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<SVGRectElement, MouseEvent>) => void;
+  onLeftHandleDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
   leftHandlePosition: number; // between 0 to 1
   viewWindowStart: number; // between 0 to 1
 
   // right-side handle
 
-  onRightHandleDragStart: (event: React.MouseEvent<SVGRectElement, MouseEvent>) => void;
+  onRightHandleDragStart: (event: React.MouseEvent<HTMLDivElement, 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<HTMLDivElement>;
 };
 
@@ -52,14 +55,14 @@ class DragManager extends React.Component<DragManagerProps, DragManagerState> {
     viewWindowEnd: 1,
   };
 
-  previousUserSelect: string | null = null;
+  previousUserSelect: UserSelectValues | null = null;
 
   hasInteractiveLayer = (): boolean => {
     return !!this.props.interactiveLayerRef.current;
   };
 
   onDragStart = (viewHandle: ViewHandleType) => (
-    event: React.MouseEvent<SVGRectElement, MouseEvent>
+    event: React.MouseEvent<HTMLDivElement, MouseEvent>
   ) => {
     if (
       this.state.isDragging ||
@@ -72,8 +75,11 @@ class DragManager extends React.Component<DragManagerProps, DragManagerState> {
     // 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<DragManagerProps, DragManagerState> {
     });
   };
 
-  onLeftHandleDragStart = (event: React.MouseEvent<SVGRectElement, MouseEvent>) => {
+  onLeftHandleDragStart = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
     this.onDragStart(ViewHandleType.Left)(event);
   };
 
-  onRightHandleDragStart = (event: React.MouseEvent<SVGRectElement, MouseEvent>) => {
+  onRightHandleDragStart = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
     this.onDragStart(ViewHandleType.Right)(event);
   };
 
@@ -148,13 +154,14 @@ class DragManager extends React.Component<DragManagerProps, DragManagerState> {
 
     // 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<DragManagerProps, DragManagerState> {
             viewWindowStart: state.leftHandlePosition,
           };
         });
-        break;
+        return;
       }
       case ViewHandleType.Right: {
         this.setState(state => {
@@ -181,19 +188,25 @@ class DragManager extends React.Component<DragManagerProps, DragManagerState> {
             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,

+ 8 - 8
src/sentry/static/sentry/app/components/events/interfaces/spans/index.tsx → 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<SpansInterfacePropTypes> {
+class TransanctionView extends React.Component<PropType> {
   static propTypes = {
     event: SentryTypes.Event.isRequired,
   };
@@ -22,14 +22,14 @@ class SpansInterface extends React.Component<SpansInterfacePropTypes> {
     return (
       <Panel>
         <PanelHeader disablePadding={false} hasButtons={false}>
-          {t('Trace View')}
+          {t('Trace View - This Transaction')}
         </PanelHeader>
         <PanelBody>
-          <TransactionView event={event} />
+          <TraceView event={event} />
         </PanelBody>
       </Panel>
     );
   }
 }
 
-export default SpansInterface;
+export default TransanctionView;

+ 672 - 0
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<HTMLDivElement>;
+  dragProps: DragManagerChildrenProps;
+  trace: ParsedTraceType;
+};
+
+type StateType = {
+  showCursorGuide: boolean;
+  mousePageX: number | undefined;
+  startViewHandleX: number;
+};
+
+class Minimap extends React.Component<PropType, StateType> {
+  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 (
+      <CursorGuide
+        style={{
+          left: toPercent(mouseLeft),
+          height: `${cursorGuideHeight}px`,
+        }}
+      />
+    );
+  };
+
+  renderViewHandles = ({
+    isDragging,
+    onLeftHandleDragStart,
+    leftHandlePosition,
+    onRightHandleDragStart,
+    rightHandlePosition,
+    viewWindowStart,
+    viewWindowEnd,
+  }: DragManagerChildrenProps) => {
+    const leftHandleGhost = isDragging ? (
+      <Handle
+        left={viewWindowStart}
+        onMouseDown={onLeftHandleDragStart}
+        isDragging={false}
+      />
+    ) : null;
+
+    const leftHandle = (
+      <Handle
+        left={leftHandlePosition}
+        onMouseDown={onLeftHandleDragStart}
+        isDragging={isDragging}
+      />
+    );
+
+    const rightHandle = (
+      <Handle
+        left={rightHandlePosition}
+        onMouseDown={onRightHandleDragStart}
+        isDragging={isDragging}
+      />
+    );
+
+    const rightHandleGhost = isDragging ? (
+      <Handle
+        left={viewWindowEnd}
+        onMouseDown={onLeftHandleDragStart}
+        isDragging={false}
+      />
+    ) : null;
+
+    return (
+      <React.Fragment>
+        {leftHandleGhost}
+        {rightHandleGhost}
+        {leftHandle}
+        {rightHandle}
+      </React.Fragment>
+    );
+  };
+
+  renderFog = (dragProps: DragManagerChildrenProps) => {
+    return (
+      <React.Fragment>
+        <Fog style={{height: '100%', width: toPercent(dragProps.viewWindowStart)}} />
+        <Fog
+          style={{
+            height: '100%',
+            width: toPercent(1 - dragProps.viewWindowEnd),
+            left: toPercent(dragProps.viewWindowEnd),
+          }}
+        />
+      </React.Fragment>
+    );
+  };
+
+  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 (
+      <DurationGuideBox style={style} alignLeft={alignLeft}>
+        <span>{getHumanDuration(duration)}</span>
+      </DurationGuideBox>
+    );
+  };
+
+  renderTimeAxis = () => {
+    const {trace} = this.props;
+
+    const duration = Math.abs(trace.traceEndTimestamp - trace.traceStartTimestamp);
+
+    const firstTick = (
+      <TickLabel
+        align={TickAlignment.Left}
+        hideTickMarker={true}
+        duration={0}
+        style={{
+          left: space(1),
+        }}
+      />
+    );
+
+    const secondTick = (
+      <TickLabel
+        duration={duration * 0.25}
+        style={{
+          left: '25%',
+        }}
+      />
+    );
+
+    const thirdTick = (
+      <TickLabel
+        duration={duration * 0.5}
+        style={{
+          left: '50%',
+        }}
+      />
+    );
+
+    const fourthTick = (
+      <TickLabel
+        duration={duration * 0.75}
+        style={{
+          left: '75%',
+        }}
+      />
+    );
+
+    const lastTick = (
+      <TickLabel
+        duration={duration}
+        align={TickAlignment.Right}
+        hideTickMarker={true}
+        style={{
+          right: space(1),
+        }}
+      />
+    );
+
+    return (
+      <TimeAxis>
+        {firstTick}
+        {secondTick}
+        {thirdTick}
+        {fourthTick}
+        {lastTick}
+        {this.renderCursorGuide(TIME_AXIS_HEIGHT)}
+        {this.renderDurationGuide()}
+      </TimeAxis>
+    );
+  };
+
+  render() {
+    return (
+      <MinimapContainer>
+        <ActualMinimap trace={this.props.trace} />
+        <div
+          ref={this.props.minimapInteractiveRef}
+          style={{
+            width: '100%',
+            height: `${MINIMAP_HEIGHT + TIME_AXIS_HEIGHT}px`,
+            position: 'absolute',
+            left: 0,
+            top: 0,
+          }}
+          onMouseEnter={event => {
+            this.setState({
+              showCursorGuide: true,
+              mousePageX: event.pageX,
+            });
+          }}
+          onMouseLeave={() => {
+            this.setState({showCursorGuide: false, mousePageX: void 0});
+          }}
+          onMouseMove={event => {
+            this.setState({
+              showCursorGuide: true,
+              mousePageX: event.pageX,
+            });
+          }}
+        >
+          <InteractiveLayer>
+            {this.renderFog(this.props.dragProps)}
+            {this.renderCursorGuide(MINIMAP_HEIGHT)}
+            {this.renderViewHandles(this.props.dragProps)}
+          </InteractiveLayer>
+          {this.renderTimeAxis()}
+        </div>
+      </MinimapContainer>
+    );
+  }
+}
+
+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<SpanChildrenLookupType>;
+    generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
+    span: Readonly<SpanType>;
+  }): {
+    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<SpanType> = get(childSpans, span.span_id, []);
+
+    type AccType = {
+      nextSpanNumber: number;
+      renderedSpanChildren: Array<JSX.Element>;
+    };
+
+    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(
+          <React.Fragment key={key}>{results.spanTree}</React.Fragment>
+        );
+
+        acc.nextSpanNumber = results.nextSpanNumber;
+
+        return acc;
+      },
+      {
+        renderedSpanChildren: [],
+        nextSpanNumber: spanNumber + 1,
+      }
+    );
+
+    return {
+      nextSpanNumber: reduced.nextSpanNumber,
+      spanTree: (
+        <React.Fragment>
+          <MinimapSpanBar
+            style={{
+              backgroundColor: spanBarColour,
+              left: spanLeft,
+              width: spanWidth,
+            }}
+          />
+          {reduced.renderedSpanChildren}
+        </React.Fragment>
+      ),
+    };
+  };
+
+  render() {
+    return (
+      <MinimapBackground>
+        <BackgroundSlider id="minimap-background-slider">
+          {this.renderRootSpan()}
+        </BackgroundSlider>
+      </MinimapBackground>
+    );
+  }
+}
+
+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 (
+    <TickLabelContainer style={style}>
+      {hideTickMarker ? null : <TickMarker />}
+      <TickText align={align}>{getHumanDuration(duration)}</TickText>
+    </TickLabelContainer>
+  );
+};
+
+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<HTMLDivElement, MouseEvent>) => void;
+  isDragging: boolean;
+}) => {
+  return (
+    <ViewHandleContainer
+      style={{
+        left: toPercent(left),
+      }}
+    >
+      <svg
+        width={1}
+        height={MINIMAP_HEIGHT - VIEW_HANDLE_HEIGHT}
+        fill="none"
+        style={{width: '1px', overflow: 'visible'}}
+      >
+        <line
+          x1="0"
+          x2="0"
+          y1="0"
+          y2={MINIMAP_HEIGHT - VIEW_HANDLE_HEIGHT}
+          strokeWidth="1"
+          strokeDasharray="4 3"
+          style={{stroke: '#6C5FC7'}}
+        />
+      </svg>
+      <ViewHandle
+        onMouseDown={onMouseDown}
+        isDragging={isDragging}
+        style={{
+          height: `${VIEW_HANDLE_HEIGHT}px`,
+        }}
+      />
+    </ViewHandleContainer>
+  );
+};
+
+export default Minimap;

Some files were not shown because too many files changed in this diff