diff --git a/README.md b/README.md index 9b3479414f..7d0d452563 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![ReadTheDocs][doc-img]][doc] [![Build Status][ci-img]][ci] [![Coverage Status][cov-img]][cov] -# Jaeger UI +# Jaeger UI Visualize distributed tracing with Jaeger. @@ -28,7 +28,7 @@ Install dependencies via `npm` or `yarn`: ``` npm install # or -yarn install +yarn ``` Make sure you have the Jaeger Query service running on http://localhost:16686. diff --git a/package.json b/package.json index e17e689989..66955896b4 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "basscss": "^8.0.3", "chance": "^1.0.10", "classnames": "^2.2.5", + "combokeys": "^3.0.0", "cytoscape": "^3.2.1", "cytoscape-dagre": "^2.0.0", "d3-scale": "^1.0.6", @@ -65,7 +66,8 @@ "reselect": "^3.0.1", "semantic-ui-css": "^2.2.12", "semantic-ui-react": "^0.71.4", - "store": "^2.0.12" + "store": "^2.0.12", + "tween-functions": "^1.2.0" }, "scripts": { "start": "react-scripts start", @@ -83,7 +85,7 @@ "clear-homepage": "json -I -f package.json -e 'delete this.homepage'", "deploy-docs": "./bin/deploy-docs.sh", "postpublish": "npm run build:docs && npm run deploy-docs", - "add-license": "uber-licence", + "add-license": "cd src && uber-licence", "precommit": "lint-staged" }, "lint-staged": { diff --git a/src/components/SearchTracePage/SearchDropdownInput.js b/src/components/SearchTracePage/SearchDropdownInput.js index 60a080f6e2..bf7bae9dab 100644 --- a/src/components/SearchTracePage/SearchDropdownInput.js +++ b/src/components/SearchTracePage/SearchDropdownInput.js @@ -37,7 +37,6 @@ export default class SearchDropdownInput extends Component { this.state = { currentItems: props.items.slice(0, props.maxResults), }; - this.onSearch = this.onSearch.bind(this); } componentWillReceiveProps(nextProps) { if (this.props.items.map(i => i.text).join(',') !== nextProps.items.map(i => i.text).join(',')) { @@ -46,12 +45,12 @@ export default class SearchDropdownInput extends Component { }); } } - onSearch(_, searchText) { + onSearch = (_, searchText) => { const { items, maxResults } = this.props; const regexStr = regexpEscape(searchText); const regex = new RegExp(regexStr, 'i'); return items.filter(v => regex.test(v.text)).slice(0, maxResults); - } + }; render() { const { input: { value, onChange } } = this.props; const { currentItems } = this.state; diff --git a/src/components/TracePage/ScrollManager.js b/src/components/TracePage/ScrollManager.js new file mode 100644 index 0000000000..d0121f9d4b --- /dev/null +++ b/src/components/TracePage/ScrollManager.js @@ -0,0 +1,251 @@ +// @flow + +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import type { Span, Trace } from '../../types'; + +/** + * `Accessors` is necessary because `ScrollManager` needs to be created by + * `TracePage` so it can be passed into the keyboard shortcut manager. But, + * `ScrollManager` needs to know about the state of `ListView` and `Positions`, + * which are very low-level. And, storing their state info in redux or + * `TracePage#state` would be inefficient because the state info only rarely + * needs to be accessed (when a keyboard shortcut is triggered). `Accessors` + * allows that state info to be accessed in a loosely coupled fashion on an + * as-needed basis. + */ +export type Accessors = { + getViewRange: () => [number, number], + getSearchedSpanIDs: () => ?Set, + getCollapsedChildren: () => ?Set, + getViewHeight: () => number, + getBottomRowIndexVisible: () => number, + getTopRowIndexVisible: () => number, + getRowPosition: number => { height: number, y: number }, + mapRowIndexToSpanIndex: number => number, + mapSpanIndexToRowIndex: number => number, +}; + +interface Scroller { + scrollTo: number => void, + scrollBy: (number, ?boolean) => void, +} + +/** + * Returns `{ isHidden: true, ... }` if one of the parents of `span` is + * collapsed, e.g. has children hidden. + * + * @param {Span} span The Span to check for. + * @param {Set} childrenAreHidden The set of Spans known to have hidden + * children, either because it is + * collapsed or has a collapsed parent. + * @param {Map }} + */ +function isSpanHidden(span: Span, childrenAreHidden: Set, spansMap: Map) { + const parentIDs = new Set(); + let { references } = span; + let parentID: ?string; + const checkRef = ref => { + if (ref.refType === 'CHILD_OF') { + parentID = ref.spanID; + parentIDs.add(parentID); + return childrenAreHidden.has(parentID); + } + return false; + }; + while (Array.isArray(references) && references.length) { + const isHidden = references.some(checkRef); + if (isHidden) { + return { isHidden, parentIDs }; + } + if (!parentID) { + break; + } + const parent = spansMap.get(parentID); + parentID = undefined; + references = parent && parent.references; + } + return { parentIDs, isHidden: false }; +} + +/** + * ScrollManager is intended for scrolling the TracePage. Has two modes, paging + * and scrolling to the previous or next visible span. + */ +export default class ScrollManager { + _trace: ?Trace; + _scroller: Scroller; + _accessors: ?Accessors; + + constructor(trace: ?Trace, scroller: Scroller) { + this._trace = trace; + this._scroller = scroller; + this._accessors = undefined; + } + + _scrollPast(rowIndex: number, direction: 1 | -1) { + const xrs = this._accessors; + if (!xrs) { + throw new Error('Accessors not set'); + } + const isUp = direction < 0; + const position = xrs.getRowPosition(rowIndex); + if (!position) { + console.warn('Invalid row index'); + return; + } + let { y } = position; + const vh = xrs.getViewHeight(); + if (!isUp) { + y += position.height; + // scrollTop is based on the top of the window + y -= vh; + } + y += direction * 0.5 * vh; + this._scroller.scrollTo(y); + } + + _scrollToVisibleSpan(direction: 1 | -1) { + const xrs = this._accessors; + if (!xrs) { + throw new Error('Accessors not set'); + } + if (!this._trace) { + return; + } + const { duration, spans, startTime: traceStartTime } = this._trace; + const isUp = direction < 0; + const boundaryRow = isUp ? xrs.getTopRowIndexVisible() : xrs.getBottomRowIndexVisible(); + const spanIndex = xrs.mapRowIndexToSpanIndex(boundaryRow); + if ((spanIndex === 0 && isUp) || (spanIndex === spans.length - 1 && !isUp)) { + return; + } + // fullViewSpanIndex is one row inside the view window unless already at the top or bottom + let fullViewSpanIndex = spanIndex; + if (spanIndex !== 0 && spanIndex !== spans.length - 1) { + fullViewSpanIndex -= direction; + } + const [viewStart, viewEnd] = xrs.getViewRange(); + const checkVisibility = viewStart !== 0 || viewEnd !== 1; + // use NaN as fallback to make flow happy + const startTime = checkVisibility ? traceStartTime + duration * viewStart : NaN; + const endTime = checkVisibility ? traceStartTime + duration * viewEnd : NaN; + const findMatches = xrs.getSearchedSpanIDs(); + const _collapsed = xrs.getCollapsedChildren(); + const childrenAreHidden = _collapsed ? new Set(_collapsed) : null; + // use empty Map as fallback to make flow happy + const spansMap = childrenAreHidden ? new Map(spans.map(s => [s.spanID, s])) : new Map(); + const boundary = direction < 0 ? -1 : spans.length; + let nextSpanIndex: number; + for (let i = fullViewSpanIndex + direction; i !== boundary; i += direction) { + const span = spans[i]; + const { duration: spanDuration, spanID, startTime: spanStartTime } = span; + const spanEndTime = spanStartTime + spanDuration; + if (checkVisibility && (spanStartTime > endTime || spanEndTime < startTime)) { + // span is not visible within the view range + continue; + } + if (findMatches && !findMatches.has(spanID)) { + // skip to search matches (when searching) + continue; + } + if (childrenAreHidden) { + // make sure the span is not collapsed + const { isHidden, parentIDs } = isSpanHidden(span, childrenAreHidden, spansMap); + if (isHidden) { + childrenAreHidden.add(...parentIDs); + continue; + } + } + nextSpanIndex = i; + break; + } + if (!nextSpanIndex || nextSpanIndex === boundary) { + // might as well scroll to the top or bottom + nextSpanIndex = boundary - direction; + } + const nextRow = xrs.mapSpanIndexToRowIndex(nextSpanIndex); + this._scrollPast(nextRow, direction); + } + + /** + * Sometimes the ScrollManager is created before the trace is loaded. This + * setter allows the trace to be set asynchronously. + */ + setTrace(trace: ?Trace) { + this._trace = trace; + } + + /** + * `setAccessors` is bound in the ctor, so it can be passed as a prop to + * children components. + */ + setAccessors = (accessors: Accessors) => { + this._accessors = accessors; + }; + + /** + * Scrolls around one page down (0.95x). It is bounds in the ctor, so it can + * be used as a keyboard shortcut handler. + */ + scrollPageDown = () => { + if (!this._scroller || !this._accessors) { + return; + } + this._scroller.scrollBy(0.95 * this._accessors.getViewHeight(), true); + }; + + /** + * Scrolls around one page up (0.95x). It is bounds in the ctor, so it can + * be used as a keyboard shortcut handler. + */ + scrollPageUp = () => { + if (!this._scroller || !this._accessors) { + return; + } + this._scroller.scrollBy(-0.95 * this._accessors.getViewHeight(), true); + }; + + /** + * Scrolls to the next visible span, ignoring spans that do not match the + * text filter, if there is one. It is bounds in the ctor, so it can + * be used as a keyboard shortcut handler. + */ + scrollToNextVisibleSpan = () => { + this._scrollToVisibleSpan(1); + }; + + /** + * Scrolls to the previous visible span, ignoring spans that do not match the + * text filter, if there is one. It is bounds in the ctor, so it can + * be used as a keyboard shortcut handler. + */ + scrollToPrevVisibleSpan = () => { + this._scrollToVisibleSpan(-1); + }; + + destroy() { + this._trace = undefined; + this._scroller = (undefined: any); + this._accessors = undefined; + } +} diff --git a/src/components/TracePage/ScrollManager.test.js b/src/components/TracePage/ScrollManager.test.js new file mode 100644 index 0000000000..719b75617d --- /dev/null +++ b/src/components/TracePage/ScrollManager.test.js @@ -0,0 +1,191 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +/* eslint-disable import/first */ +jest.mock('./scroll-page'); + +import { scrollBy, scrollTo } from './scroll-page'; +import ScrollManager from './ScrollManager'; + +const SPAN_HEIGHT = 2; + +function getTrace() { + let nextSpanID = 0; + const spans = []; + const trace = { + spans, + duration: 2000, + startTime: 1000, + }; + for (let i = 0; i < 10; i++) { + spans.push({ duration: 1, startTime: 1000, spanID: nextSpanID++ }); + } + return trace; +} + +function getAccessors() { + return { + getViewRange: jest.fn(() => [0, 1]), + getSearchedSpanIDs: jest.fn(), + getCollapsedChildren: jest.fn(), + getViewHeight: jest.fn(() => SPAN_HEIGHT * 2), + getBottomRowIndexVisible: jest.fn(), + getTopRowIndexVisible: jest.fn(), + getRowPosition: jest.fn(), + mapRowIndexToSpanIndex: jest.fn(n => n), + mapSpanIndexToRowIndex: jest.fn(n => n), + }; +} + +describe('ScrollManager', () => { + let trace; + let accessors; + let manager; + + beforeEach(() => { + scrollBy.mockReset(); + scrollTo.mockReset(); + trace = getTrace(); + accessors = getAccessors(); + manager = new ScrollManager(trace, { scrollBy, scrollTo }); + manager.setAccessors(accessors); + }); + + it('saves the accessors', () => { + const n = Math.random(); + manager.setAccessors(n); + expect(manager._accessors).toBe(n); + }); + + describe('_scrollPast()', () => { + it('throws if accessors is not set', () => { + manager.setAccessors(null); + expect(manager._scrollPast).toThrow(); + }); + + it('scrolls up with direction is `-1`', () => { + const y = 10; + const expectTo = y - 0.5 * accessors.getViewHeight(); + accessors.getRowPosition.mockReturnValue({ y, height: SPAN_HEIGHT }); + manager._scrollPast(NaN, -1); + expect(scrollTo.mock.calls).toEqual([[expectTo]]); + }); + + it('scrolls down with direction `1`', () => { + const y = 10; + const vh = accessors.getViewHeight(); + const expectTo = y + SPAN_HEIGHT - 0.5 * vh; + accessors.getRowPosition.mockReturnValue({ y, height: SPAN_HEIGHT }); + manager._scrollPast(NaN, 1); + expect(scrollTo.mock.calls).toEqual([[expectTo]]); + }); + }); + + describe('_scrollToVisibleSpan()', () => { + let scrollPastMock; + + beforeEach(() => { + scrollPastMock = jest.fn(); + manager._scrollPast = scrollPastMock; + }); + it('throws if accessors is not set', () => { + manager.setAccessors(null); + expect(manager._scrollToVisibleSpan).toThrow(); + }); + it('exits if the trace is not set', () => { + manager.setTrace(null); + manager._scrollToVisibleSpan(); + expect(scrollPastMock.mock.calls.length).toBe(0); + }); + + it('does nothing if already at the boundary', () => { + accessors.getTopRowIndexVisible.mockReturnValue(0); + accessors.getBottomRowIndexVisible.mockReturnValue(trace.spans.length - 1); + manager._scrollToVisibleSpan(-1); + expect(scrollPastMock.mock.calls.length).toBe(0); + manager._scrollToVisibleSpan(1); + expect(scrollPastMock.mock.calls.length).toBe(0); + }); + + it('centers the current top or bottom span', () => { + accessors.getTopRowIndexVisible.mockReturnValue(5); + accessors.getBottomRowIndexVisible.mockReturnValue(5); + manager._scrollToVisibleSpan(-1); + expect(scrollPastMock).lastCalledWith(5, -1); + manager._scrollToVisibleSpan(1); + expect(scrollPastMock).lastCalledWith(5, 1); + }); + + it('skips spans that are out of view', () => { + trace.spans[4].startTime = trace.startTime + trace.duration * 0.5; + accessors.getViewRange = jest.fn(() => [0.4, 0.6]); + accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1); + accessors.getBottomRowIndexVisible.mockReturnValue(0); + manager._scrollToVisibleSpan(1); + expect(scrollPastMock).lastCalledWith(4, 1); + manager._scrollToVisibleSpan(-1); + expect(scrollPastMock).lastCalledWith(4, -1); + }); + + it('skips spans that do not match the text search', () => { + accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1); + accessors.getBottomRowIndexVisible.mockReturnValue(0); + accessors.getSearchedSpanIDs = jest.fn(() => new Set([trace.spans[4].spanID])); + manager._scrollToVisibleSpan(1); + expect(scrollPastMock).lastCalledWith(4, 1); + manager._scrollToVisibleSpan(-1); + expect(scrollPastMock).lastCalledWith(4, -1); + }); + + it('skips spans that are hidden because their parent is collapsed', () => { + const getRefs = spanID => [{ refType: 'CHILD_OF', spanID }]; + // change spans so 0 and 4 are top-level and their children are collapsed + const spans = trace.spans; + let parentID; + for (let i = 0; i < spans.length; i++) { + switch (i) { + case 0: + case 4: + parentID = spans[i].spanID; + break; + default: + spans[i].references = getRefs(parentID); + } + } + accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1); + accessors.getBottomRowIndexVisible.mockReturnValue(0); + accessors.getCollapsedChildren.mockReturnValue(new Set([spans[0].spanID, spans[4].spanID])); + manager.scrollToNextVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, 1); + manager.scrollToPrevVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, -1); + }); + }); + + it('scrolls down by ~viewHeight when scrollPageDown is invoked', () => { + manager.scrollPageDown(); + expect(scrollBy).lastCalledWith(0.95 * accessors.getViewHeight(), true); + }); + + it('scrolls up by ~viewHeight when scrollPageUp is invoked', () => { + manager.scrollPageUp(); + expect(scrollBy).lastCalledWith(-0.95 * accessors.getViewHeight(), true); + }); +}); diff --git a/src/components/TracePage/SpanGraph/CanvasSpanGraph.js b/src/components/TracePage/SpanGraph/CanvasSpanGraph.js index eb36e5f573..c5d50fe4a6 100644 --- a/src/components/TracePage/SpanGraph/CanvasSpanGraph.js +++ b/src/components/TracePage/SpanGraph/CanvasSpanGraph.js @@ -22,7 +22,7 @@ import * as React from 'react'; -import renderIntoCanvas from './render-into-canvas'; +import renderIntoCanvas, { CV_WIDTH } from './render-into-canvas'; import colorGenerator from '../../../utils/color-generator'; import './CanvasSpanGraph.css'; @@ -32,9 +32,7 @@ type CanvasSpanGraphProps = { valueWidth: number, }; -const CV_WIDTH = 4000; - -const getColor = str => colorGenerator.getColorByKey(str); +const getColor: string => [number, number, number] = str => colorGenerator.getRgbColorByKey(str); export default class CanvasSpanGraph extends React.PureComponent { props: CanvasSpanGraphProps; @@ -43,7 +41,6 @@ export default class CanvasSpanGraph extends React.PureComponent { this._canvasElm = elm; }; diff --git a/src/components/TracePage/SpanGraph/CanvasSpanGraph.test.js b/src/components/TracePage/SpanGraph/CanvasSpanGraph.test.js new file mode 100644 index 0000000000..46ee625370 --- /dev/null +++ b/src/components/TracePage/SpanGraph/CanvasSpanGraph.test.js @@ -0,0 +1,38 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import CanvasSpanGraph from './CanvasSpanGraph'; + +describe('', () => { + it('renders without exploding', () => { + const items = [{ valueWidth: 1, valueOffset: 1, serviceName: 'service-name-0' }]; + const wrapper = shallow(); + expect(wrapper).toBeDefined(); + wrapper.instance()._setCanvasRef({ + getContext: () => ({ + fillRect: () => {}, + }), + }); + wrapper.setProps({ items }); + }); +}); diff --git a/src/components/TracePage/SpanGraph/Scrubber.css b/src/components/TracePage/SpanGraph/Scrubber.css index accdda7bbd..dc48a568e1 100644 --- a/src/components/TracePage/SpanGraph/Scrubber.css +++ b/src/components/TracePage/SpanGraph/Scrubber.css @@ -20,31 +20,33 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -.timeline-scrubber { - cursor: ew-resize; +.Scrubber--handleExpansion { + cursor: col-resize; + fill-opacity: 0; + fill: #44f; } -.timeline-scrubber__line { - stroke: #999; - stroke-width: 1; -} + .Scrubber.isDragging .Scrubber--handleExpansion, + .Scrubber--handles:hover > .Scrubber--handleExpansion { + fill-opacity: 1; + } -.timeline-scrubber:hover .timeline-scrubber__line { - stroke: #777; +.Scrubber--handle { + cursor: col-resize; + fill: #555; } -.timeline-scrubber__handle { - stroke: #999; - fill: #fff; -} + .Scrubber.isDragging .Scrubber--handle, + .Scrubber--handles:hover > .Scrubber--handle { + fill: #44f; + } -.timeline-scrubber:hover .timeline-scrubber__handle { - stroke: #777; +.Scrubber--line { + pointer-events: none; + stroke: #555; } -.timeline-scrubber__handle--grip { - fill: #bbb; -} -.timeline-scrubber:hover .timeline-scrubber__handle--grip { - fill: #999; -} + .Scrubber.isDragging > .Scrubber--line, + .Scrubber--handles:hover + .Scrubber--line { + stroke: #44f; + } diff --git a/src/components/TracePage/SpanGraph/Scrubber.js b/src/components/TracePage/SpanGraph/Scrubber.js index 16f2008206..f02bea6a31 100644 --- a/src/components/TracePage/SpanGraph/Scrubber.js +++ b/src/components/TracePage/SpanGraph/Scrubber.js @@ -21,57 +21,52 @@ // THE SOFTWARE. import React from 'react'; +import cx from 'classnames'; import './Scrubber.css'; type ScrubberProps = { + isDragging: boolean, position: number, onMouseDown: (SyntheticMouseEvent) => void, - handleTopOffset: number, - handleWidth: number, - handleHeight: number, + onMouseEnter: (SyntheticMouseEvent) => void, + onMouseLeave: (SyntheticMouseEvent) => void, }; -const HANDLE_WIDTH = 6; -const HANDLE_HEIGHT = 20; -const HANDLE_TOP_OFFSET = 0; - export default function Scrubber({ - position, + isDragging, onMouseDown, - handleTopOffset = HANDLE_TOP_OFFSET, - handleWidth = HANDLE_WIDTH, - handleHeight = HANDLE_HEIGHT, + onMouseEnter, + onMouseLeave, + position, }: ScrubberProps) { const xPercent = `${position * 100}%`; + const className = cx('Scrubber', { isDragging }); return ( - - - - - - + + + {/* handleExpansion is only visible when `isDragging` is true */} + + + + ); } diff --git a/src/components/TracePage/SpanGraph/Scrubber.test.js b/src/components/TracePage/SpanGraph/Scrubber.test.js index 8711ac97d1..961390e8e7 100644 --- a/src/components/TracePage/SpanGraph/Scrubber.test.js +++ b/src/components/TracePage/SpanGraph/Scrubber.test.js @@ -39,12 +39,12 @@ describe('', () => { it('contains the proper svg components', () => { expect( wrapper.matchesElement( - - - - - - + + + + + + ) ).toBeTruthy(); @@ -61,11 +61,7 @@ describe('', () => { it('supports onMouseDown', () => { const event = {}; - wrapper.find('g').prop('onMouseDown')(event); + wrapper.find('.Scrubber--handles').prop('onMouseDown')(event); expect(defaultProps.onMouseDown.calledWith(event)).toBeTruthy(); }); - - it("doesn't fail if onMouseDown is not provided", () => { - expect(() => wrapper.find('g').prop('onMouseDown')()).not.toThrow(); - }); }); diff --git a/src/components/TracePage/SpanGraph/ViewingLayer.css b/src/components/TracePage/SpanGraph/ViewingLayer.css new file mode 100644 index 0000000000..5c24072c1e --- /dev/null +++ b/src/components/TracePage/SpanGraph/ViewingLayer.css @@ -0,0 +1,70 @@ +/* +Copyright (c) 2017 Uber Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +.ViewingLayer { + position: relative; + z-index: 1; + margin-bottom: 0.5em; + cursor: vertical-text; +} + +.ViewingLayer--graph { + border: 1px solid #999; + /* need !important here to overcome something from semantic UI */ + overflow: visible !important; + position: relative; + transform-origin: 0 0; + width: 100%; +} + +.ViewingLayer--inactive { + fill: rgba(214, 214, 214, 0.5); +} + +.ViewingLayer--cursorGuide { + stroke: #f44; + stroke-width: 1; +} + +.ViewingLayer--draggedShift { + fill-opacity: 0.2; +} + +.ViewingLayer--draggedShift.isShiftDrag, +.ViewingLayer--draggedEdge.isShiftDrag { + fill: #44f; +} + +.ViewingLayer--draggedShift.isReframeDrag, +.ViewingLayer--draggedEdge.isReframeDrag { + fill: #f44; +} + +.ViewingLayer--fullOverlay { + bottom: 0; + cursor: col-resize; + left: 0; + position: fixed; + right: 0; + top: 0; + user-select: none; +} diff --git a/src/components/TracePage/SpanGraph/ViewingLayer.js b/src/components/TracePage/SpanGraph/ViewingLayer.js new file mode 100644 index 0000000000..054ffcb599 --- /dev/null +++ b/src/components/TracePage/SpanGraph/ViewingLayer.js @@ -0,0 +1,336 @@ +// @flow + +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import * as React from 'react'; +import cx from 'classnames'; + +import GraphTicks from './GraphTicks'; +import Scrubber from './Scrubber'; +import type { ViewRange, ViewRangeTimeUpdate } from '../types'; +import type { DraggableBounds, DraggingUpdate } from '../../../utils/DraggableManager'; +import DraggableManager, { updateTypes } from '../../../utils/DraggableManager'; + +import './ViewingLayer.css'; + +type ViewingLayerProps = { + height: number, + numTicks: number, + updateViewRangeTime: (number, number) => void, + updateNextViewRangeTime: ViewRangeTimeUpdate => void, + viewRange: ViewRange, +}; + +type ViewingLayerState = { + /** + * Cursor line should not be drawn when the mouse is over the scrubber handle. + */ + preventCursorLine: boolean, +}; + +/** + * Designate the tags for the different dragging managers. Exported for tests. + */ +export const dragTypes = { + /** + * Tag for dragging the right scrubber, e.g. end of the current view range. + */ + SHIFT_END: 'SHIFT_END', + /** + * Tag for dragging the left scrubber, e.g. start of the current view range. + */ + SHIFT_START: 'SHIFT_START', + /** + * Tag for dragging a new view range. + */ + REFRAME: 'REFRAME', +}; + +/** + * Returns the layout information for drawing the view-range differential, e.g. + * show what will change when the mouse is released. Basically, this is the + * difference from the start of the drag to the current position. + * + * @returns {{ x: string, width: string, leadginX: string }} + */ +function getNextViewLayout(start: number, position: number) { + const [left, right] = start < position ? [start, position] : [position, start]; + return { + x: `${left * 100}%`, + width: `${(right - left) * 100}%`, + leadingX: `${position * 100}%`, + }; +} + +/** + * `ViewingLayer` is rendered on top of the Canvas rendering of the minimap and + * handles showing the current view range and handles mouse UX for modifying it. + */ +export default class ViewingLayer extends React.PureComponent { + props: ViewingLayerProps; + state: ViewingLayerState; + + _root: ?Element; + + /** + * `_draggerReframe` handles clicking and dragging on the `ViewingLayer` to + * redefined the view range. + */ + _draggerReframe: DraggableManager; + + /** + * `_draggerStart` handles dragging the left scrubber to adjust the start of + * the view range. + */ + _draggerStart: DraggableManager; + + /** + * `_draggerEnd` handles dragging the right scrubber to adjust the end of + * the view range. + */ + _draggerEnd: DraggableManager; + + constructor(props: ViewingLayerProps) { + super(props); + + this._draggerReframe = new DraggableManager({ + getBounds: this._getDraggingBounds, + onDragEnd: this._handleReframeDragEnd, + onDragMove: this._handleReframeDragUpdate, + onDragStart: this._handleReframeDragUpdate, + onMouseMove: this._handleReframeMouseMove, + onMouseLeave: this._handleReframeMouseLeave, + tag: dragTypes.REFRAME, + }); + + this._draggerStart = new DraggableManager({ + getBounds: this._getDraggingBounds, + onDragEnd: this._handleScrubberDragEnd, + onDragMove: this._handleScrubberDragUpdate, + onDragStart: this._handleScrubberDragUpdate, + onMouseEnter: this._handleScrubberEnterLeave, + onMouseLeave: this._handleScrubberEnterLeave, + tag: dragTypes.SHIFT_START, + }); + + this._draggerEnd = new DraggableManager({ + getBounds: this._getDraggingBounds, + onDragEnd: this._handleScrubberDragEnd, + onDragMove: this._handleScrubberDragUpdate, + onDragStart: this._handleScrubberDragUpdate, + onMouseEnter: this._handleScrubberEnterLeave, + onMouseLeave: this._handleScrubberEnterLeave, + tag: dragTypes.SHIFT_END, + }); + + this._root = undefined; + this.state = { + preventCursorLine: false, + }; + } + + componentWillUnmount() { + this._draggerReframe.dispose(); + this._draggerEnd.dispose(); + this._draggerStart.dispose(); + } + + _setRoot = (elm: ?Element) => { + this._root = elm; + }; + + _getDraggingBounds = (tag: ?string): DraggableBounds => { + if (!this._root) { + throw new Error('invalid state'); + } + const { left: clientXLeft, width } = this._root.getBoundingClientRect(); + const [viewStart, viewEnd] = this.props.viewRange.time.current; + let maxValue = 1; + let minValue = 0; + if (tag === dragTypes.SHIFT_START) { + maxValue = viewEnd; + } else if (tag === dragTypes.SHIFT_END) { + minValue = viewStart; + } + return { clientXLeft, maxValue, minValue, width }; + }; + + _handleReframeMouseMove = ({ value }: DraggingUpdate) => { + this.props.updateNextViewRangeTime({ cursor: value }); + }; + + _handleReframeMouseLeave = () => { + this.props.updateNextViewRangeTime({ cursor: null }); + }; + + _handleReframeDragUpdate = ({ value }: DraggingUpdate) => { + const shift = value; + const { time } = this.props.viewRange; + const anchor = time.reframe ? time.reframe.anchor : shift; + const update = { reframe: { anchor, shift } }; + this.props.updateNextViewRangeTime(update); + }; + + _handleReframeDragEnd = ({ manager, value }: DraggingUpdate) => { + const { time } = this.props.viewRange; + const anchor = time.reframe ? time.reframe.anchor : value; + const [start, end] = value < anchor ? [value, anchor] : [anchor, value]; + manager.resetBounds(); + this.props.updateViewRangeTime(start, end); + }; + + _handleScrubberEnterLeave = ({ type }: DraggingUpdate) => { + const preventCursorLine = type === updateTypes.MOUSE_ENTER; + this.setState({ preventCursorLine }); + }; + + _handleScrubberDragUpdate = ({ event, tag, type, value }: DraggingUpdate) => { + if (type === updateTypes.DRAG_START) { + event.stopPropagation(); + } + if (tag === dragTypes.SHIFT_START) { + this.props.updateNextViewRangeTime({ shiftStart: value }); + } else if (tag === dragTypes.SHIFT_END) { + this.props.updateNextViewRangeTime({ shiftEnd: value }); + } + }; + + _handleScrubberDragEnd = ({ manager, tag, value }: DraggingUpdate) => { + const [viewStart, viewEnd] = this.props.viewRange.time.current; + let update: [number, number]; + if (tag === dragTypes.SHIFT_START) { + update = [value, viewEnd]; + } else if (tag === dragTypes.SHIFT_END) { + update = [viewStart, value]; + } else { + // to satisfy flow + throw new Error('bad state'); + } + manager.resetBounds(); + this.setState({ preventCursorLine: false }); + this.props.updateViewRangeTime(...update); + }; + + /** + * Randers the difference between where the drag started and the current + * position, e.g. the red or blue highlight. + * + * @returns React.Node[] + */ + _getMarkers(from: number, to: number, isShift: boolean) { + const layout = getNextViewLayout(from, to); + const cls = cx({ + isShiftDrag: isShift, + isReframeDrag: !isShift, + }); + return [ + , + , + ]; + } + + render() { + const { height, viewRange, numTicks } = this.props; + const { preventCursorLine } = this.state; + const { current, cursor, shiftStart, shiftEnd, reframe } = viewRange.time; + const haveNextTimeRange = shiftStart != null || shiftEnd != null || reframe != null; + const [viewStart, viewEnd] = current; + let leftInactive = 0; + if (viewStart) { + leftInactive = viewStart * 100; + } + let rightInactive = 100; + if (viewEnd) { + rightInactive = 100 - viewEnd * 100; + } + let cursorPosition: ?string; + if (!haveNextTimeRange && cursor != null && !preventCursorLine) { + cursorPosition = `${cursor * 100}%`; + } + + return ( +
+ + {leftInactive > 0 && + } + {rightInactive > 0 && + } + + {cursorPosition && + } + {shiftStart != null && this._getMarkers(viewStart, shiftStart, true)} + {shiftEnd != null && this._getMarkers(viewEnd, shiftEnd, true)} + + + {reframe != null && this._getMarkers(reframe.anchor, reframe.shift, false)} + + {/* fullOverlay updates the mouse cursor blocks mouse events */} + {haveNextTimeRange &&
} +
+ ); + } +} diff --git a/src/components/TracePage/SpanGraph/ViewingLayer.test.js b/src/components/TracePage/SpanGraph/ViewingLayer.test.js new file mode 100644 index 0000000000..ee77bc962d --- /dev/null +++ b/src/components/TracePage/SpanGraph/ViewingLayer.test.js @@ -0,0 +1,301 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import GraphTicks from './GraphTicks'; +import Scrubber from './Scrubber'; +import ViewingLayer, { dragTypes } from './ViewingLayer'; +import { updateTypes } from '../../../utils/DraggableManager'; +import { polyfill as polyfillAnimationFrame } from '../../../utils/test/requestAnimationFrame'; + +function getViewRange(viewStart, viewEnd) { + return { + time: { + current: [viewStart, viewEnd], + }, + }; +} + +describe('', () => { + polyfillAnimationFrame(window); + + let props; + let wrapper; + + beforeEach(() => { + props = { + height: 60, + numTicks: 5, + updateNextViewRangeTime: jest.fn(), + updateViewRangeTime: jest.fn(), + viewRange: getViewRange(0, 1), + }; + wrapper = shallow(); + }); + + describe('_getDraggingBounds()', () => { + beforeEach(() => { + props = { ...props, viewRange: getViewRange(0.1, 0.9) }; + wrapper = shallow(); + wrapper.instance()._setRoot({ + getBoundingClientRect() { + return { left: 10, width: 100 }; + }, + }); + }); + + it('throws if _root is not set', () => { + const instance = wrapper.instance(); + instance._root = null; + expect(() => instance._getDraggingBounds(dragTypes.REFRAME)).toThrow(); + }); + + it('returns the correct bounds for reframe', () => { + const bounds = wrapper.instance()._getDraggingBounds(dragTypes.REFRAME); + expect(bounds).toEqual({ + clientXLeft: 10, + width: 100, + maxValue: 1, + minValue: 0, + }); + }); + + it('returns the correct bounds for shiftStart', () => { + const bounds = wrapper.instance()._getDraggingBounds(dragTypes.SHIFT_START); + expect(bounds).toEqual({ + clientXLeft: 10, + width: 100, + maxValue: 0.9, + minValue: 0, + }); + }); + + it('returns the correct bounds for shiftEnd', () => { + const bounds = wrapper.instance()._getDraggingBounds(dragTypes.SHIFT_END); + expect(bounds).toEqual({ + clientXLeft: 10, + width: 100, + maxValue: 1, + minValue: 0.1, + }); + }); + }); + + describe('DraggableManager callbacks', () => { + describe('reframe', () => { + it('handles mousemove', () => { + const value = 0.5; + wrapper.instance()._handleReframeMouseMove({ value }); + const calls = props.updateNextViewRangeTime.mock.calls; + expect(calls).toEqual([[{ cursor: value }]]); + }); + + it('handles mouseleave', () => { + wrapper.instance()._handleReframeMouseLeave(); + const calls = props.updateNextViewRangeTime.mock.calls; + expect(calls).toEqual([[{ cursor: null }]]); + }); + + describe('drag update', () => { + it('handles sans anchor', () => { + const value = 0.5; + wrapper.instance()._handleReframeDragUpdate({ value }); + const calls = props.updateNextViewRangeTime.mock.calls; + expect(calls).toEqual([[{ reframe: { anchor: value, shift: value } }]]); + }); + + it('handles the existing anchor', () => { + const value = 0.5; + const anchor = 0.1; + const time = { ...props.viewRange.time, reframe: { anchor } }; + props = { ...props, viewRange: { time } }; + wrapper = shallow(); + wrapper.instance()._handleReframeDragUpdate({ value }); + const calls = props.updateNextViewRangeTime.mock.calls; + expect(calls).toEqual([[{ reframe: { anchor, shift: value } }]]); + }); + }); + + describe('drag end', () => { + let manager; + + beforeEach(() => { + manager = { resetBounds: jest.fn() }; + }); + + it('handles sans anchor', () => { + const value = 0.5; + wrapper.instance()._handleReframeDragEnd({ manager, value }); + expect(manager.resetBounds.mock.calls).toEqual([[]]); + const calls = props.updateViewRangeTime.mock.calls; + expect(calls).toEqual([[value, value]]); + }); + + it('handles dragged left (anchor is greater)', () => { + const value = 0.5; + const anchor = 0.6; + const time = { ...props.viewRange.time, reframe: { anchor } }; + props = { ...props, viewRange: { time } }; + wrapper = shallow(); + wrapper.instance()._handleReframeDragEnd({ manager, value }); + + expect(manager.resetBounds.mock.calls).toEqual([[]]); + const calls = props.updateViewRangeTime.mock.calls; + expect(calls).toEqual([[value, anchor]]); + }); + + it('handles dragged right (anchor is less)', () => { + const value = 0.5; + const anchor = 0.4; + const time = { ...props.viewRange.time, reframe: { anchor } }; + props = { ...props, viewRange: { time } }; + wrapper = shallow(); + wrapper.instance()._handleReframeDragEnd({ manager, value }); + + expect(manager.resetBounds.mock.calls).toEqual([[]]); + const calls = props.updateViewRangeTime.mock.calls; + expect(calls).toEqual([[anchor, value]]); + }); + }); + }); + + describe('scrubber', () => { + it('prevents the cursor from being drawn on scrubber mouseover', () => { + wrapper.instance()._handleScrubberEnterLeave({ type: updateTypes.MOUSE_ENTER }); + expect(wrapper.state('preventCursorLine')).toBe(true); + }); + + it('prevents the cursor from being drawn on scrubber mouseleave', () => { + wrapper.instance()._handleScrubberEnterLeave({ type: updateTypes.MOUSE_LEAVE }); + expect(wrapper.state('preventCursorLine')).toBe(false); + }); + + describe('drag start and update', () => { + it('stops propagation on drag start', () => { + const stopPropagation = jest.fn(); + const update = { + event: { stopPropagation }, + type: updateTypes.DRAG_START, + }; + wrapper.instance()._handleScrubberDragUpdate(update); + expect(stopPropagation.mock.calls).toEqual([[]]); + }); + + it('updates the viewRange for shiftStart and shiftEnd', () => { + const instance = wrapper.instance(); + const value = 0.5; + const cases = [ + { + dragUpdate: { + value, + tag: dragTypes.SHIFT_START, + type: updateTypes.DRAG_MOVE, + }, + viewRangeUpdate: { shiftStart: value }, + }, + { + dragUpdate: { + value, + tag: dragTypes.SHIFT_END, + type: updateTypes.DRAG_MOVE, + }, + viewRangeUpdate: { shiftEnd: value }, + }, + ]; + cases.forEach(_case => { + instance._handleScrubberDragUpdate(_case.dragUpdate); + expect(props.updateNextViewRangeTime).lastCalledWith(_case.viewRangeUpdate); + }); + }); + }); + + it('updates the view on drag end', () => { + const instance = wrapper.instance(); + const [viewStart, viewEnd] = props.viewRange.time.current; + const value = 0.5; + const cases = [ + { + dragUpdate: { + value, + manager: { resetBounds: jest.fn() }, + tag: dragTypes.SHIFT_START, + }, + viewRangeUpdate: [value, viewEnd], + }, + { + dragUpdate: { + value, + manager: { resetBounds: jest.fn() }, + tag: dragTypes.SHIFT_END, + }, + viewRangeUpdate: [viewStart, value], + }, + ]; + cases.forEach(_case => { + const { manager } = _case.dragUpdate; + wrapper.setState({ preventCursorLine: true }); + expect(wrapper.state('preventCursorLine')).toBe(true); + instance._handleScrubberDragEnd(_case.dragUpdate); + expect(wrapper.state('preventCursorLine')).toBe(false); + expect(manager.resetBounds.mock.calls).toEqual([[]]); + expect(props.updateViewRangeTime).lastCalledWith(..._case.viewRangeUpdate); + }); + }); + }); + }); + + it('renders a ', () => { + expect(wrapper.find(GraphTicks).length).toBe(1); + }); + + it('renders a filtering box if leftBound exists', () => { + const _props = { ...props, viewRange: getViewRange(0.2, 1) }; + wrapper = shallow(); + + const leftBox = wrapper.find('.ViewingLayer--inactive'); + expect(leftBox.length).toBe(1); + const width = Number(leftBox.prop('width').slice(0, -1)); + const x = leftBox.prop('x'); + expect(Math.round(width)).toBe(20); + expect(x).toBe(0); + }); + + it('renders a filtering box if rightBound exists', () => { + const _props = { ...props, viewRange: getViewRange(0, 0.8) }; + wrapper = shallow(); + + const rightBox = wrapper.find('.ViewingLayer--inactive'); + expect(rightBox.length).toBe(1); + const width = Number(rightBox.prop('width').slice(0, -1)); + const x = Number(rightBox.prop('x').slice(0, -1)); + expect(Math.round(width)).toBe(20); + expect(x).toBe(80); + }); + + it('renders handles for the timeRangeFilter', () => { + const [viewStart, viewEnd] = props.viewRange.time.current; + let scrubber = ; + expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy(); + scrubber = ; + expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy(); + }); +}); diff --git a/src/components/TracePage/SpanGraph/index.js b/src/components/TracePage/SpanGraph/index.js index cae22b935f..7e54b7e394 100644 --- a/src/components/TracePage/SpanGraph/index.js +++ b/src/components/TracePage/SpanGraph/index.js @@ -21,214 +21,86 @@ // THE SOFTWARE. import * as React from 'react'; -import PropTypes from 'prop-types'; -import { window } from 'global'; -import GraphTicks from './GraphTicks'; import CanvasSpanGraph from './CanvasSpanGraph'; import TickLabels from './TickLabels'; -import Scrubber from './Scrubber'; -import type { Trace } from '../../../types'; - -import './index.css'; +import ViewingLayer from './ViewingLayer'; +import type { ViewRange, ViewRangeTimeUpdate } from '../types'; +import type { Span, Trace } from '../../../types'; const TIMELINE_TICK_INTERVAL = 4; type SpanGraphProps = { height: number, trace: Trace, - viewRange: [number, number], + viewRange: ViewRange, + updateViewRangeTime: (number, number) => void, + updateNextViewRangeTime: ViewRangeTimeUpdate => void, }; +/** + * Store `items` in state so they are not regenerated every render. Otherwise, + * the canvas graph will re-render itself every time. + */ type SpanGraphState = { - currentlyDragging: ?string, - leftBound: ?number, - prevX: ?number, - rightBound: ?number, + items: { + valueOffset: number, + valueWidth: number, + serviceName: string, + }[], }; -export default class SpanGraph extends React.Component { +function getItem(span: Span) { + return { + valueOffset: span.relativeStartTime, + valueWidth: span.duration, + serviceName: span.process.serviceName, + }; +} + +export default class SpanGraph extends React.PureComponent { props: SpanGraphProps; state: SpanGraphState; - _wrapper: ?HTMLElement; - _publishIntervalID: ?number; - static defaultProps = { height: 60, }; - static contextTypes = { - updateTimeRangeFilter: PropTypes.func.isRequired, - }; - constructor(props: SpanGraphProps) { super(props); + const { trace } = props; this.state = { - currentlyDragging: null, - leftBound: null, - prevX: null, - rightBound: null, - }; - this._wrapper = undefined; - this._setWrapper = this._setWrapper.bind(this); - this._publishTimeRange = this._publishTimeRange.bind(this); - this._publishIntervalID = undefined; - } - - shouldComponentUpdate(nextProps: SpanGraphProps, nextState: SpanGraphState) { - const { trace: newTrace, viewRange: newViewRange } = nextProps; - const { - currentlyDragging: newCurrentlyDragging, - leftBound: newLeftBound, - rightBound: newRightBound, - } = nextState; - const { trace, viewRange } = this.props; - const { currentlyDragging, leftBound, rightBound } = this.state; - - return ( - trace.traceID !== newTrace.traceID || - viewRange[0] !== newViewRange[0] || - viewRange[1] !== newViewRange[1] || - currentlyDragging !== newCurrentlyDragging || - leftBound !== newLeftBound || - rightBound !== newRightBound - ); - } - - _setWrapper = function _setWrapper(elm: React.Node) { - this._wrapper = elm; - }; - - _startDragging(boundName: string, { clientX }: SyntheticMouseEvent) { - const { viewRange } = this.props; - const [leftBound, rightBound] = viewRange; - - this.setState({ currentlyDragging: boundName, prevX: clientX, leftBound, rightBound }); - - const mouseMoveHandler = (...args) => this._onMouseMove(...args); - const mouseUpHandler = () => { - this._stopDragging(); - window.removeEventListener('mouseup', mouseUpHandler); - window.removeEventListener('mousemove', mouseMoveHandler); + items: trace ? trace.spans.map(getItem) : [], }; - - window.addEventListener('mouseup', mouseUpHandler); - window.addEventListener('mousemove', mouseMoveHandler); - } - - _stopDragging() { - this._publishTimeRange(); - this.setState({ currentlyDragging: null, prevX: null }); } - _publishTimeRange = function _publishTimeRange() { - const { currentlyDragging, leftBound, rightBound } = this.state; - const { updateTimeRangeFilter } = this.context; - clearTimeout(this._publishIntervalID); - this._publishIntervalID = undefined; - if (currentlyDragging) { - updateTimeRangeFilter(leftBound, rightBound); - } - }; - - _onMouseMove({ clientX }: SyntheticMouseEvent) { - const { currentlyDragging } = this.state; - let { leftBound, rightBound } = this.state; - if (!currentlyDragging || !this._wrapper) { - return; - } - const newValue = clientX / this._wrapper.clientWidth; - switch (currentlyDragging) { - case 'leftBound': - leftBound = Math.max(0, newValue); - break; - case 'rightBound': - rightBound = Math.min(1, newValue); - break; - default: - break; - } - this.setState({ prevX: clientX, leftBound, rightBound }); - if (this._publishIntervalID == null) { - this._publishIntervalID = window.requestAnimationFrame(this._publishTimeRange); + componentWillReceiveProps(nextProps: SpanGraphProps) { + const { trace } = nextProps; + if (this.props.trace !== trace) { + this.setState({ + items: trace ? trace.spans.map(getItem) : [], + }); } } render() { - const { height, trace, viewRange } = this.props; + const { height, trace, viewRange, updateNextViewRangeTime, updateViewRangeTime } = this.props; if (!trace) { return
; } - const { currentlyDragging } = this.state; - let { leftBound, rightBound } = this.state; - if (!currentlyDragging) { - leftBound = viewRange[0]; - rightBound = viewRange[1]; - } - let leftInactive; - if (leftBound) { - leftInactive = leftBound * 100; - } - let rightInactive; - if (rightBound) { - rightInactive = 100 - rightBound * 100; - } + const { items } = this.state; return (
-
- ({ - valueOffset: span.relativeStartTime, - valueWidth: span.duration, - serviceName: span.process.serviceName, - }))} +
+ + -
- - {leftInactive && - } - {rightInactive && - } - ({ - valueOffset: span.relativeStartTime, - valueWidth: span.duration, - serviceName: span.process.serviceName, - }))} - /> - { - this._startDragging('leftBound', event)} - /> - } - { - this._startDragging('rightBound', event)} - /> - } - -
); diff --git a/src/components/TracePage/SpanGraph/index.test.js b/src/components/TracePage/SpanGraph/index.test.js index b64ce19940..3c0f1a0ad5 100644 --- a/src/components/TracePage/SpanGraph/index.test.js +++ b/src/components/TracePage/SpanGraph/index.test.js @@ -19,14 +19,12 @@ // THE SOFTWARE. import React from 'react'; -import sinon from 'sinon'; import { shallow } from 'enzyme'; import CanvasSpanGraph from './CanvasSpanGraph'; import SpanGraph from './index'; -import GraphTicks from './GraphTicks'; import TickLabels from './TickLabels'; -import TimelineScrubber from './Scrubber'; +import ViewingLayer from './ViewingLayer'; import traceGenerator from '../../../../src/demo/trace-generators'; import transformTraceData from '../../../model/transform-trace-data'; import { polyfill as polyfillAnimationFrame } from '../../../utils/test/requestAnimationFrame'; @@ -35,17 +33,20 @@ describe('', () => { polyfillAnimationFrame(window); const trace = transformTraceData(traceGenerator.trace({})); - const props = { trace, viewRange: [0, 1] }; - const options = { - context: { - updateTimeRangeFilter: () => {}, + const props = { + trace, + updateViewRangeTime: () => {}, + viewRange: { + time: { + current: [0, 1], + }, }, }; let wrapper; beforeEach(() => { - wrapper = shallow(, options); + wrapper = shallow(); }); it('renders a ', () => { @@ -57,60 +58,16 @@ describe('', () => { }); it('returns a
if a trace is not provided', () => { - wrapper = shallow(, options); + wrapper = shallow(); expect(wrapper.matchesElement(
)).toBeTruthy(); }); - it('renders a filtering box if leftBound exists', () => { - const _props = { ...props, viewRange: [0.2, 1] }; - wrapper = shallow(, options); - const leftBox = wrapper.find('.SpanGraph--inactive'); - expect(leftBox.length).toBe(1); - const width = Number(leftBox.prop('width').slice(0, -1)); - const x = leftBox.prop('x'); - expect(Math.round(width)).toBe(20); - expect(x).toBe(0); - }); - - it('renders a filtering box if rightBound exists', () => { - const _props = { ...props, viewRange: [0, 0.8] }; - wrapper = shallow(, options); - const rightBox = wrapper.find('.SpanGraph--inactive'); - const width = Number(rightBox.prop('width').slice(0, -1)); - const x = Number(rightBox.prop('x').slice(0, -1)); - expect(rightBox.length).toBe(1); - expect(Math.round(width)).toBe(20); - expect(Math.round(x)).toBe(80); - }); - - it('renders handles for the timeRangeFilter', () => { - const { viewRange } = props; - let scrubber = ; - expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy(); - scrubber = ; - expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy(); - }); - - it('calls startDragging() for the leftBound handle', () => { - const event = { clientX: 50 }; - sinon.stub(wrapper.instance(), '_startDragging'); - wrapper.find('#trace-page-timeline__left-bound-handle').prop('onMouseDown')(event); - expect(wrapper.instance()._startDragging.calledWith('leftBound', event)).toBeTruthy(); - }); - - it('calls startDragging for the rightBound handle', () => { - const event = { clientX: 50 }; - sinon.stub(wrapper.instance(), '_startDragging'); - wrapper.find('#trace-page-timeline__right-bound-handle').prop('onMouseDown')(event); - expect(wrapper.instance()._startDragging.calledWith('rightBound', event)).toBeTruthy(); - }); - it('passes the number of ticks to render to components', () => { const tickHeader = wrapper.find(TickLabels); - const graphTicks = wrapper.find(GraphTicks); + const viewingLayer = wrapper.find(ViewingLayer); expect(tickHeader.prop('numTicks')).toBeGreaterThan(1); - expect(graphTicks.prop('numTicks')).toBeGreaterThan(1); - expect(tickHeader.prop('numTicks')).toBe(graphTicks.prop('numTicks')); + expect(viewingLayer.prop('numTicks')).toBeGreaterThan(1); + expect(tickHeader.prop('numTicks')).toBe(viewingLayer.prop('numTicks')); }); it('passes items to CanvasSpanGraph', () => { @@ -122,114 +79,4 @@ describe('', () => { })); expect(canvasGraph.prop('items')).toEqual(items); }); - - describe('# shouldComponentUpdate()', () => { - it('returns true for new timeRangeFilter', () => { - const state = { ...wrapper.state(), leftBound: Math.random(), rightBound: Math.random() }; - const instance = wrapper.instance(); - expect(instance.shouldComponentUpdate(props, state, options.context)).toBe(true); - }); - - it('returns true for new trace', () => { - const state = wrapper.state(); - const instance = wrapper.instance(); - const trace2 = transformTraceData(traceGenerator.trace({})); - const altProps = { trace: trace2 }; - expect(instance.shouldComponentUpdate(altProps, state, options.context)).toBe(true); - }); - - it('returns true for currentlyDragging', () => { - const state = { ...wrapper.state(), currentlyDragging: !wrapper.state('currentlyDragging') }; - const instance = wrapper.instance(); - expect(instance.shouldComponentUpdate(props, state, options.context)).toBe(true); - }); - - it('returns false, generally', () => { - const state = wrapper.state(); - const instance = wrapper.instance(); - expect(instance.shouldComponentUpdate(props, state, options.context)).toBe(false); - }); - }); - - describe('# onMouseMove()', () => { - it('does nothing if currentlyDragging is false', () => { - const updateTimeRangeFilter = sinon.spy(); - const context = { ...options.context, updateTimeRangeFilter }; - wrapper = shallow(, { ...options, context }); - wrapper.instance()._onMouseMove({ clientX: 45 }); - expect(wrapper.state('prevX')).toBe(null); - expect(updateTimeRangeFilter.called).toBeFalsy(); - }); - - it('stores the clientX on .state', () => { - wrapper.instance()._wrapper = { clientWidth: 100 }; - wrapper.setState({ currentlyDragging: 'leftBound' }); - wrapper.instance()._onMouseMove({ clientX: 45 }); - expect(wrapper.state('prevX')).toBe(45); - }); - - it('updates the timeRangeFilter for the left handle', () => { - const updateTimeRangeFilter = sinon.spy(); - const context = { ...options.context, updateTimeRangeFilter }; - wrapper = shallow(, { ...options, context }); - wrapper.instance()._wrapper = { clientWidth: 100 }; - const [leftBound, rightBound] = props.viewRange; - const state = { ...wrapper.state(), leftBound, rightBound, prevX: 0, currentlyDragging: 'leftBound' }; - wrapper.setState(state); - wrapper.instance()._onMouseMove({ clientX: 45 }); - wrapper.instance()._publishTimeRange(); - expect(updateTimeRangeFilter.calledWith(0.45, 1)).toBeTruthy(); - }); - - it('updates the timeRangeFilter for the right handle', () => { - const updateTimeRangeFilter = sinon.spy(); - const context = { ...options.context, updateTimeRangeFilter }; - wrapper = shallow(, { ...options, context }); - wrapper.instance()._wrapper = { clientWidth: 100 }; - const [leftBound, rightBound] = props.viewRange; - const state = { - ...wrapper.state(), - leftBound, - rightBound, - prevX: 100, - currentlyDragging: 'rightBound', - }; - wrapper.setState(state); - wrapper.instance()._onMouseMove({ clientX: 45 }); - wrapper.instance()._publishTimeRange(); - expect(updateTimeRangeFilter.calledWith(0, 0.45)).toBeTruthy(); - }); - }); - - describe('# _startDragging()', () => { - it('stores the boundName and the prevX in state', () => { - wrapper.instance()._startDragging('leftBound', { clientX: 100 }); - expect(wrapper.state('currentlyDragging')).toBe('leftBound'); - expect(wrapper.state('prevX')).toBe(100); - }); - - it('binds event listeners to the window', () => { - const oldFn = window.addEventListener; - const fn = jest.fn(); - window.addEventListener = fn; - - wrapper.instance()._startDragging('leftBound', { clientX: 100 }); - expect(fn.mock.calls.length).toBe(2); - const eventNames = [fn.mock.calls[0][0], fn.mock.calls[1][0]].sort(); - expect(eventNames).toEqual(['mousemove', 'mouseup']); - window.addEventListener = oldFn; - }); - }); - - describe('# _stopDragging()', () => { - it('clears currentlyDragging and prevX', () => { - const instance = wrapper.instance(); - instance._startDragging('leftBound', { clientX: 100 }); - expect(wrapper.state('currentlyDragging')).toBe('leftBound'); - expect(wrapper.state('prevX')).toBe(100); - instance._stopDragging(); - expect(wrapper.state('currentlyDragging')).toBe(null); - expect(wrapper.state('prevX')).toBe(null); - }); - }); }); diff --git a/src/components/TracePage/SpanGraph/render-into-canvas.js b/src/components/TracePage/SpanGraph/render-into-canvas.js index eb53e9c998..9d631f733b 100644 --- a/src/components/TracePage/SpanGraph/render-into-canvas.js +++ b/src/components/TracePage/SpanGraph/render-into-canvas.js @@ -20,27 +20,32 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -const CV_WIDTH = 4000; -const MIN_WIDTH = 50; -const MIN_TOTAL_HEIGHT = 60; +// exported for tests +export const CV_WIDTH = 4000; +export const MIN_WIDTH = 16; +export const MIN_TOTAL_HEIGHT = 60; +export const ALPHA = 0.8; export default function renderIntoCanvas( canvas: HTMLCanvasElement, items: { valueWidth: number, valueOffset: number, serviceName: string }[], totalValueWidth: number, - getFillColor: string => string + getFillColor: string => [number, number, number] ) { // eslint-disable-next-line no-param-reassign canvas.width = CV_WIDTH; let itemHeight = 1; + let itemYChange = 1; if (items.length < MIN_TOTAL_HEIGHT) { // eslint-disable-next-line no-param-reassign canvas.height = MIN_TOTAL_HEIGHT; itemHeight = MIN_TOTAL_HEIGHT / items.length; + itemYChange = MIN_TOTAL_HEIGHT / items.length; } else { // eslint-disable-next-line no-param-reassign canvas.height = items.length; - itemHeight = 1; + itemYChange = 1; + itemHeight = 1 / (MIN_TOTAL_HEIGHT / items.length); } const ctx = canvas.getContext('2d'); for (let i = 0; i < items.length; i++) { @@ -52,7 +57,7 @@ export default function renderIntoCanvas( if (width < MIN_WIDTH) { width = MIN_WIDTH; } - ctx.fillStyle = getFillColor(serviceName); - ctx.fillRect(x, i * itemHeight, width, itemHeight); + ctx.fillStyle = `rgba(${getFillColor(serviceName).concat(ALPHA).join()})`; + ctx.fillRect(x, i * itemYChange, width, itemHeight); } } diff --git a/src/components/TracePage/SpanGraph/render-into-canvas.test.js b/src/components/TracePage/SpanGraph/render-into-canvas.test.js new file mode 100644 index 0000000000..2ad24f0bf9 --- /dev/null +++ b/src/components/TracePage/SpanGraph/render-into-canvas.test.js @@ -0,0 +1,164 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import _range from 'lodash/range'; + +import renderIntoCanvas, { ALPHA, CV_WIDTH, MIN_TOTAL_HEIGHT, MIN_WIDTH } from './render-into-canvas'; + +describe('renderIntoCanvas()', () => { + const basicItem = { valueWidth: 100, valueOffset: 50, serviceName: 'some-name' }; + + class CanvasContext { + constructor() { + this.fillStyle = undefined; + this.fillRectAccumulator = []; + } + + fillRect(x, y, width, height) { + const fillStyle = this.fillStyle; + this.fillRectAccumulator.push({ + fillStyle, + height, + width, + x, + y, + }); + } + } + + class Canvas { + constructor() { + this.contexts = []; + this.height = NaN; + this.width = NaN; + this.getContext = jest.fn(this._getContext.bind(this)); + } + + _getContext() { + const ctx = new CanvasContext(); + this.contexts.push(ctx); + return ctx; + } + } + + function getColorFactory() { + let i = 0; + const inputOutput = []; + function getFakeColor(str) { + const rv = [i, i, i]; + i++; + inputOutput.push({ + input: str, + output: rv.slice(), + }); + return rv; + } + getFakeColor.inputOutput = inputOutput; + return getFakeColor; + } + + it('sets the width', () => { + const canvas = new Canvas(); + expect(canvas.width !== canvas.width).toBe(true); + renderIntoCanvas(canvas, [basicItem], 150, getColorFactory()); + expect(canvas.width).toBe(CV_WIDTH); + }); + + describe('when there are limited number of items', () => { + it('sets the height', () => { + const canvas = new Canvas(); + expect(canvas.height !== canvas.height).toBe(true); + renderIntoCanvas(canvas, [basicItem], 150, getColorFactory()); + expect(canvas.height).toBe(MIN_TOTAL_HEIGHT); + }); + + it('draws the map', () => { + const totalValueWidth = 4000; + const items = [ + { valueWidth: 50, valueOffset: 50, serviceName: 'service-name-0' }, + { valueWidth: 100, valueOffset: 100, serviceName: 'service-name-1' }, + { valueWidth: 150, valueOffset: 150, serviceName: 'service-name-2' }, + ]; + const expectedColors = [ + { input: items[0].serviceName, output: [0, 0, 0] }, + { input: items[1].serviceName, output: [1, 1, 1] }, + { input: items[2].serviceName, output: [2, 2, 2] }, + ]; + const expectedDrawings = items.map((item, i) => { + const { valueWidth: width, valueOffset: x } = item; + const color = expectedColors[i].output; + const fillStyle = `rgba(${color.concat(ALPHA).join()})`; + const height = MIN_TOTAL_HEIGHT / items.length; + const y = height * i; + return { fillStyle, height, width, x, y }; + }); + const canvas = new Canvas(); + const getFillColor = getColorFactory(); + renderIntoCanvas(canvas, items, totalValueWidth, getFillColor); + expect(getFillColor.inputOutput).toEqual(expectedColors); + expect(canvas.getContext.mock.calls).toEqual([['2d']]); + expect(canvas.contexts.length).toBe(1); + expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawings); + }); + }); + + describe('when there are many items', () => { + it('sets the height when there are many items', () => { + const canvas = new Canvas(); + const items = []; + for (let i = 0; i < MIN_TOTAL_HEIGHT + 1; i++) { + items.push(basicItem); + } + expect(canvas.height !== canvas.height).toBe(true); + renderIntoCanvas(canvas, items, 150, getColorFactory()); + expect(canvas.height).toBe(items.length); + }); + + it('draws the map', () => { + const totalValueWidth = 4000; + const items = _range(MIN_TOTAL_HEIGHT * 10).map(i => ({ + valueWidth: i, + valueOffset: i, + serviceName: `service-name-${i}`, + })); + const itemHeight = 1 / (MIN_TOTAL_HEIGHT / items.length); + const expectedColors = items.map((item, i) => ({ + input: item.serviceName, + output: [i, i, i], + })); + const expectedDrawings = items.map((item, i) => { + const { valueWidth, valueOffset: x } = item; + const width = Math.max(valueWidth, MIN_WIDTH); + const color = expectedColors[i].output; + const fillStyle = `rgba(${color.concat(ALPHA).join()})`; + const height = itemHeight; + const y = i; + return { fillStyle, height, width, x, y }; + }); + const canvas = new Canvas(); + const getFillColor = getColorFactory(); + renderIntoCanvas(canvas, items, totalValueWidth, getFillColor); + expect(getFillColor.inputOutput).toEqual(expectedColors); + expect(canvas.getContext.mock.calls).toEqual([['2d']]); + expect(canvas.contexts.length).toBe(1); + expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawings); + }); + }); +}); diff --git a/src/components/TracePage/TracePageHeader.js b/src/components/TracePage/TracePageHeader.js index 3b30f79952..ab7a5c3a47 100644 --- a/src/components/TracePage/TracePageHeader.js +++ b/src/components/TracePage/TracePageHeader.js @@ -52,9 +52,8 @@ export const HEADER_ITEMS = [ }, ]; -export default function TracePageHeader(props, context) { - const { traceID, name, slimView, onSlimViewClicked } = props; - const { updateTextFilter, textFilter } = context; +export default function TracePageHeader(props) { + const { traceID, name, slimView, onSlimViewClicked, updateTextFilter, textFilter } = props; if (!traceID) { return null; @@ -115,18 +114,15 @@ export default function TracePageHeader(props, context) { } TracePageHeader.propTypes = { - traceID: PropTypes.string, - name: PropTypes.string, + duration: PropTypes.number, // eslint-disable-line react/no-unused-prop-types maxDepth: PropTypes.number, // eslint-disable-line react/no-unused-prop-types + name: PropTypes.string, numServices: PropTypes.number, // eslint-disable-line react/no-unused-prop-types numSpans: PropTypes.number, // eslint-disable-line react/no-unused-prop-types - duration: PropTypes.number, // eslint-disable-line react/no-unused-prop-types - timestamp: PropTypes.number, // eslint-disable-line react/no-unused-prop-types - slimView: PropTypes.bool, onSlimViewClicked: PropTypes.func, -}; - -TracePageHeader.contextTypes = { - textFilter: PropTypes.string.isRequired, + slimView: PropTypes.bool, + textFilter: PropTypes.string, + timestamp: PropTypes.number, // eslint-disable-line react/no-unused-prop-types + traceID: PropTypes.string, updateTextFilter: PropTypes.func.isRequired, }; diff --git a/src/components/TracePage/TracePageHeader.test.js b/src/components/TracePage/TracePageHeader.test.js index 1c64837392..8389fcf473 100644 --- a/src/components/TracePage/TracePageHeader.test.js +++ b/src/components/TracePage/TracePageHeader.test.js @@ -28,19 +28,14 @@ describe('', () => { const defaultProps = { traceID: 'some-trace-id', name: 'some-trace-name', - }; - - const defaultOptions = { - context: { - textFilter: '', - updateTextFilter: () => {}, - }, + textFilter: '', + updateTextFilter: () => {}, }; let wrapper; beforeEach(() => { - wrapper = shallow(, defaultOptions); + wrapper = shallow(); }); it('renders a
', () => { @@ -48,7 +43,7 @@ describe('', () => { }); it('renders an empty
if no traceID is present', () => { - wrapper = mount(, defaultOptions); + wrapper = mount(); expect(wrapper.children().length).toBe(0); }); @@ -66,13 +61,8 @@ describe('', () => { it('calls the context updateTextFilter() function for onChange of the input', () => { const updateTextFilter = sinon.spy(); - wrapper = shallow(, { - ...defaultOptions, - context: { - ...defaultOptions.context, - updateTextFilter, - }, - }); + const props = { ...defaultProps, updateTextFilter }; + wrapper = shallow(); const event = { target: { value: 'my new value' } }; wrapper.find('#trace-page__text-filter').first().prop('onChange')(event); expect(updateTextFilter.calledWith('my new value')).toBeTruthy(); diff --git a/src/components/TracePage/TraceTimelineViewer/ListView/Positions.js b/src/components/TracePage/TraceTimelineViewer/ListView/Positions.js index bfc630771d..7883c251cc 100644 --- a/src/components/TracePage/TraceTimelineViewer/ListView/Positions.js +++ b/src/components/TracePage/TraceTimelineViewer/ListView/Positions.js @@ -108,6 +108,34 @@ export default class Positions { } } + /** + * Get the latest height for index `_i`. If it's in new terretory + * (_i > lastI), find the heights (and y-values) leading up to it. If it's in + * known territory (_i <= lastI) and the height is different than what is + * known, recalculate subsequent y values, but don't confirm the heights of + * those items, just update based on the difference. + */ + confirmHeight(_i: number, heightGetter: number => number) { + let i = _i; + if (i > this.lastI) { + this.calcHeights(i, heightGetter); + return; + } + const h = heightGetter(i); + if (h === this.heights[i]) { + return; + } + const chg = h - this.heights[i]; + this.heights[i] = h; + // shift the y positions by `chg` for all known y positions + while (++i <= this.lastI) { + this.ys[i] += chg; + } + if (this.ys[this.lastI + 1] != null) { + this.ys[this.lastI + 1] += chg; + } + } + /** * Given a target y-value (`yValue`), find the closest index (in the `.ys` * array) that is prior to the y-value; e.g. map from y-value to index in @@ -145,6 +173,19 @@ export default class Positions { throw new Error(`unable to find floor index for y=${yValue}`); } + /** + * Get the `y` and `height` for a given row. + * + * @returns {{ height: number, y: number }} + */ + getRowPosition(index: number, heightGetter: number => number) { + this.confirmHeight(index, heightGetter); + return { + height: this.heights[index], + y: this.ys[index], + }; + } + /** * Get the estimated height of the whole shebang by extrapolating based on * the average known height. @@ -158,32 +199,4 @@ export default class Positions { // eslint-disable-next-line no-bitwise return (known / (this.lastI + 1) * this.heights.length) | 0; } - - /** - * Get the latest height for index `_i`. If it's in new terretory - * (_i > lastI), find the heights (and y-values) leading up to it. If it's in - * known territory (_i <= lastI) and the height is different than what is - * known, recalculate subsequent y values, but don't confirm the heights of - * those items, just update based on the difference. - */ - confirmHeight(_i: number, heightGetter: number => number) { - let i = _i; - if (i > this.lastI) { - this.calcHeights(i, heightGetter); - return; - } - const h = heightGetter(i); - if (h === this.heights[i]) { - return; - } - const chg = h - this.heights[i]; - this.heights[i] = h; - // shift the y positions by `chg` for all known y positions - while (++i <= this.lastI) { - this.ys[i] += chg; - } - if (this.ys[this.lastI + 1] != null) { - this.ys[this.lastI + 1] += chg; - } - } } diff --git a/src/components/TracePage/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap b/src/components/TracePage/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap index fb07e256a6..82d8ca8ef4 100644 --- a/src/components/TracePage/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap +++ b/src/components/TracePage/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap @@ -326,6 +326,10 @@ ShallowWrapper { ], }, "context": Object {}, + "getBottomVisibleIndex": [Function], + "getRowPosition": [Function], + "getTopVisibleIndex": [Function], + "getViewHeight": [Function], "props": Object { "dataLength": 40, "getIndexFromKey": [Function], diff --git a/src/components/TracePage/TraceTimelineViewer/ListView/index.js b/src/components/TracePage/TraceTimelineViewer/ListView/index.js index 18516d4d19..bc79afd356 100644 --- a/src/components/TracePage/TraceTimelineViewer/ListView/index.js +++ b/src/components/TracePage/TraceTimelineViewer/ListView/index.js @@ -181,8 +181,6 @@ export default class ListView extends React.Component { this._yPositions = new Positions(200); // _knownHeights is (item-key -> observed height) of list items this._knownHeights = new Map(); - this._getHeight = this._getHeight.bind(this); - this._scanItemHeights = this._scanItemHeights.bind(this); this._startIndexDrawn = 2 ** 20; this._endIndexDrawn = -(2 ** 20); @@ -192,17 +190,12 @@ export default class ListView extends React.Component { this._scrollTop = -1; this._isScrolledOrResized = false; - this._onScroll = this._onScroll.bind(this); - this._positionList = this._positionList.bind(this); - this._htmlTopOffset = -1; this._windowScrollListenerAdded = false; // _htmlElm is only relevant if props.windowScroller is true - this._htmlElm = window.document.querySelector('html'); + this._htmlElm = (document.documentElement: any); this._wrapperElm = undefined; this._itemHolderElm = undefined; - this._initWrapper = this._initWrapper.bind(this); - this._initItemHolder = this._initItemHolder.bind(this); } componentDidMount() { @@ -228,11 +221,29 @@ export default class ListView extends React.Component { } } + getViewHeight = () => this._viewHeight; + + /** + * Get the index of the item at the bottom of the current view. + */ + getBottomVisibleIndex = (): number => { + const bottomY = this._scrollTop + this._viewHeight; + return this._yPositions.findFloorIndex(bottomY, this._getHeight); + }; + + /** + * Get the index of the item at the top of the current view. + */ + getTopVisibleIndex = (): number => this._yPositions.findFloorIndex(this._scrollTop, this._getHeight); + + getRowPosition = (index: number): { height: number, y: number } => + this._yPositions.getRowPosition(index, this._getHeight); + /** * Scroll event listener that schedules a remeasuring of which items should be * rendered. */ - _onScroll = function _onScroll() { + _onScroll = () => { if (!this._isScrolledOrResized) { this._isScrolledOrResized = true; window.requestAnimationFrame(this._positionList); @@ -269,23 +280,11 @@ export default class ListView extends React.Component { this._viewHeight = this._wrapperElm.clientHeight; this._scrollTop = this._wrapperElm.scrollTop; } else { - this._viewHeight = this._htmlElm.clientHeight; - this._scrollTop = this._htmlElm.scrollTop; - } - let yStart; - let yEnd; - if (useRoot) { - if (this._scrollTop < this._htmlTopOffset) { - yStart = 0; - yEnd = this._viewHeight - this._htmlTopOffset + this._scrollTop; - } else { - yStart = this._scrollTop - this._htmlTopOffset; - yEnd = yStart + this._viewHeight; - } - } else { - yStart = this._scrollTop; - yEnd = this._scrollTop + this._viewHeight; + this._viewHeight = window.innerHeight - this._htmlTopOffset; + this._scrollTop = window.scrollY; } + const yStart = this._scrollTop; + const yEnd = this._scrollTop + this._viewHeight; this._startIndex = this._yPositions.findFloorIndex(yStart, this._getHeight); this._endIndex = this._yPositions.findFloorIndex(yEnd, this._getHeight); } @@ -294,7 +293,7 @@ export default class ListView extends React.Component { * Checked to see if the currently rendered items are sufficient, if not, * force an update to trigger more items to be rendered. */ - _positionList = function _positionList() { + _positionList = () => { this._isScrolledOrResized = false; if (!this._wrapperElm) { return; @@ -314,14 +313,14 @@ export default class ListView extends React.Component { } }; - _initWrapper = function _initWrapper(elm: HTMLElement) { + _initWrapper = (elm: HTMLElement) => { this._wrapperElm = elm; if (!this.props.windowScroller) { this._viewHeight = elm && elm.clientHeight; } }; - _initItemHolder = function _initItemHolder(elm: HTMLElement) { + _initItemHolder = (elm: HTMLElement) => { this._itemHolderElm = elm; this._scanItemHeights(); }; @@ -331,7 +330,7 @@ export default class ListView extends React.Component { * item-key (which is on a data-* attribute). If any new or adjusted heights * are found, re-measure the current known y-positions (via .yPositions). */ - _scanItemHeights = function _scanItemHeights() { + _scanItemHeights = () => { const getIndexFromKey = this.props.getIndexFromKey; if (!this._itemHolderElm) { return; @@ -384,7 +383,7 @@ export default class ListView extends React.Component { * Get the height of the element at index `i`; first check the known heigths, * fallbck to `.props.itemHeightGetter(...)`. */ - _getHeight = function _getHeight(i: number) { + _getHeight = (i: number) => { const key = this.props.getKeyFromIndex(i); const known = this._knownHeights.get(key); // known !== known iff known is NaN @@ -432,11 +431,11 @@ export default class ListView extends React.Component { items.length = end - start + 1; for (let i = start; i <= end; i++) { - this._yPositions.confirmHeight(i, heightGetter); + const { y: top, height } = this._yPositions.getRowPosition(i, heightGetter); const style = { + height, + top, position: 'absolute', - top: this._yPositions.ys[i], - height: this._yPositions.heights[i], }; const itemKey = getKeyFromIndex(i); const attrs = { 'data-item-key': itemKey }; diff --git a/src/components/TracePage/TraceTimelineViewer/SpanBar.test.js b/src/components/TracePage/TraceTimelineViewer/SpanBar.test.js new file mode 100644 index 0000000000..4c5b386522 --- /dev/null +++ b/src/components/TracePage/TraceTimelineViewer/SpanBar.test.js @@ -0,0 +1,55 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import React from 'react'; +import { mount } from 'enzyme'; + +import SpanBar from './SpanBar'; + +describe('', () => { + const shortLabel = 'omg-so-awesome'; + const longLabel = 'omg-awesome-long-label'; + + const props = { + longLabel, + shortLabel, + color: '#fff', + hintSide: 'right', + viewEnd: 1, + viewStart: 0, + rpc: { + viewStart: 0.25, + viewEnd: 0.75, + color: '#000', + }, + }; + + it('renders without exploding', () => { + const wrapper = mount(); + expect(wrapper).toBeDefined(); + const { onMouseOver, onMouseOut } = wrapper.find('.SpanBar--wrapper').props(); + const labelElm = wrapper.find('.SpanBar--label'); + expect(labelElm.text()).toBe(shortLabel); + onMouseOver(); + expect(labelElm.text()).toBe(longLabel); + onMouseOut(); + expect(labelElm.text()).toBe(shortLabel); + }); +}); diff --git a/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js b/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js index 5ccae96224..c362915a97 100644 --- a/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js +++ b/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js @@ -20,7 +20,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React from 'react'; +import * as React from 'react'; import TimelineRow from './TimelineRow'; import SpanTreeOffset from './SpanTreeOffset'; @@ -35,12 +35,12 @@ type SpanBarRowProps = { columnDivision: number, depth: number, isChildrenExpanded: boolean, - isDetailExapnded: boolean, + isDetailExpanded: boolean, isFilteredOut: boolean, isParent: boolean, label: string, - onDetailToggled: () => void, - onChildrenToggled: () => void, + onDetailToggled: string => void, + onChildrenToggled: string => void, operationName: string, numTicks: number, rpc: ?{ @@ -52,107 +52,126 @@ type SpanBarRowProps = { }, serviceName: string, showErrorIcon: boolean, + spanID: string, viewEnd: number, viewStart: number, }; -export default function SpanBarRow(props: SpanBarRowProps) { - const { - className, - color, - columnDivision, - depth, - isChildrenExpanded, - isDetailExapnded, - isFilteredOut, - isParent, - label, - onDetailToggled, - onChildrenToggled, - numTicks, - operationName, - rpc, - serviceName, - showErrorIcon, - viewEnd, - viewStart, - } = props; +/** + * This was originally a stateless function, but changing to a PureComponent + * reduced the render time of expanding a span row detail by ~50%. This is + * even true in the case where the stateless function has the same prop types as + * this class and arrow functions are created in the stateless function as + * handlers to the onClick props. E.g. for now, the PureComponent is more + * performance than the stateless function. + */ +export default class SpanBarRow extends React.PureComponent { + props: SpanBarRowProps; - const labelDetail = `${serviceName}::${operationName}`; - let longLabel; - let hintSide; - if (viewStart > 1 - viewEnd) { - longLabel = `${labelDetail} | ${label}`; - hintSide = 'left'; - } else { - longLabel = `${label} | ${labelDetail}`; - hintSide = 'right'; - } - return ( - - -