From e28ba7cf55659e232ed542011ae858348c12d473 Mon Sep 17 00:00:00 2001 From: Joe Farro Date: Fri, 8 Sep 2017 18:23:51 -0400 Subject: [PATCH] Virtualized scrolling for trace detail view (#68) * Update prettier and wrap at 110 instead of 80 * WIP commit for refactoring trace detail view TODO - Test are currently in a bad state - Finish shifting to transformed trace, exclusively, instead of raw trace - Misc cleanup (unused / ordering of vars, imports, etc) is outstanding while changes are WIP - Continued refinement of trace-detail components, selectors and utils - Many styles will be moved to CSS - Will likely remove `model/trace-viewer.js` SpanGraph - Fix #49 - Span position in graph doesn not match its position in the detail - Ticks in span graph made to match trace detail (in number and formatting) - Span graph refactored to trim down files and DOM elements TracePageHeader - `trace` prop removed - Added props for various title values instead of deriving them from `trace` Trace Detail - Several components split out into separate files - `transformTrace` to use alread span tree to determine span depth Span Detail - Fix uber/jaeger #326: extraneous scrollbars in trace views - Fix: Some tags were not being rendered due to clashing keys (observed in a log message) - Tall content scrolls via entire table instead of single table cell - Horizontal scrolling for wide content (e.g. long log values) - Full width of the header is clickable for tags, process, and logs headers (instead of header text, only) - Service and endpoint are shown on mouseover anywhere in span bar row Misc - Several TraceTimelineViewer / utils removed - `TreeNode` `.walk()` method can now be used to calculate the depth, avoiding use of less efficient `.getPath()` - Removed several `console.error` warnings caused by React key issues * WIP commit for refactoring trace detail view TODO - Test are currently in a bad state - Finish shifting to transformed trace, exclusively, instead of raw trace - Misc cleanup (unused / ordering of vars, imports, etc) is outstanding while changes are WIP - Continued refinement of trace-detail components, selectors and utils - Many styles will be moved to CSS - Will likely remove `model/trace-viewer.js` Span Bar / Detail - Label on span bars no longer off-screen - Clip or hide span bars when zoomed in (insted of flush left) - Add shadow to left / right boundary when span bar view is clipped - Darkened span name column to differentiate from span bar section - Span detail left column color coded by service - Clicking span detail left column collapses detail - Clicking anywhere left of parent span name toggles children visibility * WIP refactor trace detail, split out css, start fixing tests * WIP refactor trace detail, tests working again * WIP refactor trace, yarn upgrade Sub-page scrolling used for trace detail (TODO: revert this). This lays the ground work for using react-virtualized, but unfortunately has major perf issues, hence the TODO: revert. yarn upgrade --latest. Notable changes: - Removed react-sticky - react-router v4 - react-vis CSS needed to be included via a sym-link Misc tweaks: - Styling adjusted on trace mini-map - Scatterplot dots are sized based on number of spans - Scatterplot dots mouseover shows trace name * WIP refactor trace, test maintenance * remove unused import * add license to css files * Revert sub-page scrolling for trace detail * Support URL prefix via homepage in package.json * Prevent collision of logs in log entries table * Add comments to new utils * WIP Use react-virtualized in trace detail view * Fix #59 - "Span Name" to "Service & Operation" * Fix unreleased regression - ellipsis on span name Add back the styling that adds an ellipsis to truncated span name text. * Address PR comment on search results scatter plot https://github.com/uber/jaeger-ui/pull/53#discussion_r134313013 * Misc cleanup from PR comment https://github.com/uber/jaeger-ui/pull/53#discussion_r134314020 * PR comment - Remove ms and use nano seconds https://github.com/uber/jaeger-ui/pull/53#discussion_r134316418 * PR comment - Adjust export, relates to recompose https://github.com/uber/jaeger-ui/pull/53#discussion_r134318188 * Reorganize span detail components * PR comment - Comment getTraceSpanIdsAsTree() https://github.com/uber/jaeger-ui/pull/53#discussion_r134321990 * PR comment - Add "SpanID:" label https://github.com/uber/jaeger-ui/pull/64#issuecomment-324080675 * WIP react-virtualized layout progress Outstanding: - Window scroller used https://bvaughn.github.io/react-virtualized/#/components/WindowScroller - Expanding span details - Collapsing / expanding children * WIP react-virtualized layout progress Switching to use a props oriented ui state management for span details. This is necessary because the virtualized scroller removes and adds span details as it scrolls. The span detail components cannot retain their own state (e.g. tags table is expanded / collapsed, etc.) because they are not long-lived. * PR comment - Flow typing for props https://github.com/uber/jaeger-ui/pull/64#discussion_r134792924 * WIP Switching react-virtualized to custom solution * fix file-header licenses * WIP Switching react-virtualized to custom solution * Add flow to ListView, comment ListView, Positions * Positions tests, fix edge-case bug in Positions * Fix comment issue from prettier * Unit tests for ListView, remove unused propType/* * Minor changes to a comment and a test case description * Unit tests for TraceTimelineViewer/duck * remove react-virtualized from package.json * PR feedback - JSDoc, move trace transform - JSDoc tweaks - documentation.js can infer types automatically - Move trace transform - Move trace tranform out of src/components/TracePage/TraceTimelineViewer/transforms and into src/model/transform-trace-data.js - Apply trace transform to data from HTTP response so the trace data stored in the state is always the enriched form * Use top-level redux store for traceTimeline state Signed-off-by: vvvprabhakar --- .eslintrc | 22 +- .flowconfig | 3 + package.json | 1 + src/components/TracePage/TraceSpanGraph.js | 64 +- .../TracePage/TraceSpanGraph.test.js | 30 +- .../TraceTimelineViewer/ListView/Positions.js | 189 ++++++ .../ListView/Positions.test.js | 250 ++++++++ .../ListView/__snapshots__/index.test.js.snap | 586 ++++++++++++++++++ .../TraceTimelineViewer/ListView/index.js | 486 +++++++++++++++ .../ListView/index.test.js | 174 ++++++ .../TracePage/TraceTimelineViewer/SpanBar.css | 28 +- .../TracePage/TraceTimelineViewer/SpanBar.js | 24 +- .../TraceTimelineViewer/SpanBarRow.js | 2 +- .../SpanDetail/AccordianKeyValues.css | 8 +- .../SpanDetail/AccordianKeyValues.js | 44 +- .../SpanDetail/AccordianLogs.js | 21 +- .../SpanDetail/DetailState.js | 66 ++ .../SpanDetail/KeyValuesTable.js | 1 + .../TraceTimelineViewer/SpanDetail/index.js | 56 +- .../SpanDetail/toggle-enhancer.js | 30 - .../TraceTimelineViewer/SpanDetailRow.js | 57 +- .../TraceTimelineViewer/TimelineRow.css | 27 + .../TraceTimelineViewer/TimelineRow.js | 6 +- .../TraceTimelineViewer/TraceView.js | 187 ------ .../VirtualizedTraceView.css | 45 ++ .../VirtualizedTraceView.js | 387 ++++++++++++ .../TracePage/TraceTimelineViewer/duck.js | 155 +++++ .../TraceTimelineViewer/duck.test.js | 134 ++++ .../TracePage/TraceTimelineViewer/index.css | 3 +- .../TracePage/TraceTimelineViewer/index.js | 62 +- .../TraceTimelineViewer/index.test.js | 6 +- .../TraceTimelineViewer/transforms.js | 93 --- src/components/TracePage/index.js | 27 +- src/components/TracePage/index.test.js | 7 +- src/demo/trace-generators.js | 2 - src/model/search.js | 2 +- src/model/search.test.js | 18 +- src/model/transform-trace-data.js | 103 +++ src/propTypes/log.js | 28 - src/propTypes/spanGraphTick.js | 25 - src/propTypes/tag.js | 26 - src/propTypes/traceColumn.js | 30 - src/propTypes/traceTableColumn.js | 25 - src/reducers/trace.js | 11 +- src/reducers/trace.test.js | 5 +- src/types/index.js | 23 +- src/types/search.js | 6 +- src/utils/configure-store.js | 2 + .../generate-action-types.js} | 30 +- .../test/requestAnimationFrame.js} | 34 +- yarn.lock | 2 +- 51 files changed, 2948 insertions(+), 705 deletions(-) create mode 100644 src/components/TracePage/TraceTimelineViewer/ListView/Positions.js create mode 100644 src/components/TracePage/TraceTimelineViewer/ListView/Positions.test.js create mode 100644 src/components/TracePage/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap create mode 100644 src/components/TracePage/TraceTimelineViewer/ListView/index.js create mode 100644 src/components/TracePage/TraceTimelineViewer/ListView/index.test.js create mode 100644 src/components/TracePage/TraceTimelineViewer/SpanDetail/DetailState.js delete mode 100644 src/components/TracePage/TraceTimelineViewer/SpanDetail/toggle-enhancer.js create mode 100644 src/components/TracePage/TraceTimelineViewer/TimelineRow.css delete mode 100644 src/components/TracePage/TraceTimelineViewer/TraceView.js create mode 100644 src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.css create mode 100644 src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js create mode 100644 src/components/TracePage/TraceTimelineViewer/duck.js create mode 100644 src/components/TracePage/TraceTimelineViewer/duck.test.js delete mode 100644 src/components/TracePage/TraceTimelineViewer/transforms.js create mode 100644 src/model/transform-trace-data.js delete mode 100644 src/propTypes/log.js delete mode 100644 src/propTypes/spanGraphTick.js delete mode 100644 src/propTypes/tag.js delete mode 100644 src/propTypes/traceColumn.js delete mode 100644 src/propTypes/traceTableColumn.js rename src/{propTypes/trace.js => utils/generate-action-types.js} (58%) rename src/{propTypes/span.js => utils/test/requestAnimationFrame.js} (57%) diff --git a/.eslintrc b/.eslintrc index fbcf4c5e89..d59697bc2f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -13,10 +13,13 @@ ], "rules": { /* general */ - "comma-dangle": 0, "arrow-parens": [1, "as-needed"], - "no-restricted-syntax": 0, + "comma-dangle": 0, + "no-continue": 0, "no-plusplus": 0, + "no-restricted-syntax": 0, + "no-self-compare": 0, + "no-underscore-dangle": 0, /* jsx */ "jsx-a11y/no-static-element-interactions": 1, @@ -29,7 +32,20 @@ "react/forbid-prop-types": 1, "react/require-default-props": 1, "react/no-array-index-key": 1, - + "react/sort-comp": [2, { + "order": [ + "type-annotations", + "statics", + "state", + "propTypes", + "static-methods", + "constructor", + "lifecycle", + "everything-else", + "/^on.+$/", + "render" + ] + }], /* import */ "import/prefer-default-export": 1, diff --git a/.flowconfig b/.flowconfig index cd8775744c..d928c88e81 100644 --- a/.flowconfig +++ b/.flowconfig @@ -10,3 +10,6 @@ [libs] [options] + +[version] +0.53.1 diff --git a/package.json b/package.json index 39c0593056..a6b079d43e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "homepage": null, "devDependencies": { "babel-eslint": "^7.2.3", + "bluebird": "^3.5.0", "enzyme": "^2.9.1", "eslint": "^4.5.0", "eslint-config-airbnb": "^15.1.0", diff --git a/src/components/TracePage/TraceSpanGraph.js b/src/components/TracePage/TraceSpanGraph.js index 4261616143..c9bbfd9540 100644 --- a/src/components/TracePage/TraceSpanGraph.js +++ b/src/components/TracePage/TraceSpanGraph.js @@ -25,7 +25,6 @@ import PropTypes from 'prop-types'; import SpanGraph from './SpanGraph'; import SpanGraphTickHeader from './SpanGraph/SpanGraphTickHeader'; import TimelineScrubber from './TimelineScrubber'; -import { getTraceId, getTraceTimestamp, getTraceEndTimestamp, getTraceDuration } from '../../selectors/trace'; import { getPercentageOfInterval } from '../../utils/date'; const TIMELINE_TICK_INTERVAL = 4; @@ -33,7 +32,6 @@ const TIMELINE_TICK_INTERVAL = 4; export default class TraceSpanGraph extends Component { static get propTypes() { return { - xformedTrace: PropTypes.object, trace: PropTypes.object, height: PropTypes.number.isRequired, }; @@ -71,13 +69,31 @@ export default class TraceSpanGraph extends Component { const newRightBound = newTimeRangeFilter[1]; return ( - getTraceId(trace) !== getTraceId(newTrace) || + trace.traceID !== newTrace.traceID || leftBound !== newLeftBound || rightBound !== newRightBound || currentlyDragging !== newCurrentlyDragging ); } + startDragging(boundName, { clientX }) { + this.setState({ currentlyDragging: boundName, prevX: clientX }); + + const mouseMoveHandler = (...args) => this.onMouseMove(...args); + const mouseUpHandler = () => { + this.stopDragging(); + window.removeEventListener('mouseup', mouseUpHandler); + window.removeEventListener('mousemove', mouseMoveHandler); + }; + + window.addEventListener('mouseup', mouseUpHandler); + window.addEventListener('mousemove', mouseMoveHandler); + } + + stopDragging() { + this.setState({ currentlyDragging: null, prevX: null }); + } + onMouseMove({ clientX }) { const { trace } = this.props; const { prevX, currentlyDragging } = this.state; @@ -91,19 +107,16 @@ export default class TraceSpanGraph extends Component { let rightBound = timeRangeFilter[1]; const deltaX = (clientX - prevX) / this.svg.clientWidth; - const timestamp = getTraceTimestamp(trace); - const endTimestamp = getTraceEndTimestamp(trace); - const duration = getTraceDuration(this.props.trace); const prevValue = { leftBound, rightBound }[currentlyDragging]; - const newValue = prevValue + duration * deltaX; + const newValue = prevValue + trace.duration * deltaX; // enforce the edges of the graph switch (currentlyDragging) { case 'leftBound': - leftBound = Math.max(timestamp, newValue); + leftBound = Math.max(trace.startTime, newValue); break; case 'rightBound': - rightBound = Math.min(endTimestamp, newValue); + rightBound = Math.min(trace.endTime, newValue); break; /* istanbul ignore next */ default: break; @@ -115,26 +128,8 @@ export default class TraceSpanGraph extends Component { } } - startDragging(boundName, { clientX }) { - this.setState({ currentlyDragging: boundName, prevX: clientX }); - - const mouseMoveHandler = (...args) => this.onMouseMove(...args); - const mouseUpHandler = () => { - this.stopDragging(); - window.removeEventListener('mouseup', mouseUpHandler); - window.removeEventListener('mousemove', mouseMoveHandler); - }; - - window.addEventListener('mouseup', mouseUpHandler); - window.addEventListener('mousemove', mouseMoveHandler); - } - - stopDragging() { - this.setState({ currentlyDragging: null, prevX: null }); - } - render() { - const { trace, xformedTrace, height } = this.props; + const { trace, height } = this.props; const { currentlyDragging } = this.state; const { timeRangeFilter } = this.context; const leftBound = timeRangeFilter[0]; @@ -144,23 +139,20 @@ export default class TraceSpanGraph extends Component { return
; } - const initialTimestamp = getTraceTimestamp(trace); - const traceDuration = getTraceDuration(trace); - let leftInactive; if (leftBound) { - leftInactive = getPercentageOfInterval(leftBound, initialTimestamp, traceDuration); + leftInactive = getPercentageOfInterval(leftBound, trace.startTime, trace.duration); } let rightInactive; if (rightBound) { - rightInactive = 100 - getPercentageOfInterval(rightBound, initialTimestamp, traceDuration); + rightInactive = 100 - getPercentageOfInterval(rightBound, trace.startTime, trace.duration); } return (
- +
} ({ + items={trace.spans.map(span => ({ valueOffset: span.relativeStartTime, valueWidth: span.duration, serviceName: span.process.serviceName, diff --git a/src/components/TracePage/TraceSpanGraph.test.js b/src/components/TracePage/TraceSpanGraph.test.js index 3e64cbd325..9c9c8e1450 100644 --- a/src/components/TracePage/TraceSpanGraph.test.js +++ b/src/components/TracePage/TraceSpanGraph.test.js @@ -27,21 +27,14 @@ import TraceSpanGraph from './TraceSpanGraph'; import SpanGraphTickHeader from './SpanGraph/SpanGraphTickHeader'; import TimelineScrubber from './TimelineScrubber'; import traceGenerator from '../../../src/demo/trace-generators'; -import { transformTrace } from './TraceTimelineViewer/transforms'; -import { hydrateSpansWithProcesses } from '../../selectors/trace'; +import transformTraceData from '../../model/transform-trace-data'; describe('', () => { - const trace = hydrateSpansWithProcesses(traceGenerator.trace({})); - const xformedTrace = transformTrace(trace); - - const props = { - trace, - xformedTrace, - }; - + const trace = transformTraceData(traceGenerator.trace({})); + const props = { trace }; const options = { context: { - timeRangeFilter: [trace.timestamp, trace.timestamp + trace.duration], + timeRangeFilter: [trace.startTime, trace.startTime + trace.duration], updateTimeRangeFilter: () => {}, }, }; @@ -68,7 +61,7 @@ describe('', () => { it('renders a filtering box if leftBound exists', () => { const context = { ...options.context, - timeRangeFilter: [trace.timestamp + 0.2 * trace.duration, trace.timestamp + trace.duration], + timeRangeFilter: [trace.startTime + 0.2 * trace.duration, trace.startTime + trace.duration], }; wrapper = shallow(, { ...options, context }); const leftBox = wrapper.find('.trace-page-timeline__graph--inactive'); @@ -82,7 +75,7 @@ describe('', () => { it('renders a filtering box if rightBound exists', () => { const context = { ...options.context, - timeRangeFilter: [trace.timestamp, trace.timestamp + 0.8 * trace.duration], + timeRangeFilter: [trace.startTime, trace.startTime + 0.8 * trace.duration], }; wrapper = shallow(, { ...options, context }); const rightBox = wrapper.find('.trace-page-timeline__graph--inactive'); @@ -132,7 +125,7 @@ describe('', () => { it('passes items to SpanGraph', () => { const spanGraph = wrapper.find(SpanGraph).first(); - const items = xformedTrace.spans.map(span => ({ + const items = trace.spans.map(span => ({ valueOffset: span.relativeStartTime, valueWidth: span.duration, serviceName: span.process.serviceName, @@ -151,9 +144,8 @@ describe('', () => { it('returns true for new trace', () => { const state = wrapper.state(); const instance = wrapper.instance(); - const trace2 = hydrateSpansWithProcesses(traceGenerator.trace({})); - const xformedTrace2 = transformTrace(trace2); - const altProps = { trace: trace2, xformedTrace: xformedTrace2 }; + const trace2 = transformTraceData(traceGenerator.trace({})); + const altProps = { trace: trace2 }; expect(instance.shouldComponentUpdate(altProps, state, options.context)).toBe(true); }); @@ -190,7 +182,7 @@ describe('', () => { }); it('updates the timeRangeFilter for the left handle', () => { - const timestamp = trace.timestamp; + const timestamp = trace.startTime; const duration = trace.duration; const updateTimeRangeFilter = sinon.spy(); const context = { ...options.context, updateTimeRangeFilter }; @@ -204,7 +196,7 @@ describe('', () => { }); it('updates the timeRangeFilter for the right handle', () => { - const timestamp = trace.timestamp; + const timestamp = trace.startTime; const duration = trace.duration; const updateTimeRangeFilter = sinon.spy(); const context = { ...options.context, updateTimeRangeFilter }; diff --git a/src/components/TracePage/TraceTimelineViewer/ListView/Positions.js b/src/components/TracePage/TraceTimelineViewer/ListView/Positions.js new file mode 100644 index 0000000000..bfc630771d --- /dev/null +++ b/src/components/TracePage/TraceTimelineViewer/ListView/Positions.js @@ -0,0 +1,189 @@ +// @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. + +/** + * Keeps track of the height and y-position for anything sequenctial where + * y-positions follow one-after-another and can be derived from the height of + * the prior entries. The height is known from an accessor function parameter + * to the methods that require new knowledge the heights. + * + * @export + * @class Positions + */ +export default class Positions { + /** + * Indicates how far past the explicitly required height or y-values should + * checked. + */ + bufferLen: number; + dataLen: number; + heights: number[]; + /** + * `lastI` keeps track of which values have already been visited. In many + * scenarios, values do not need to be revisited. But, revisiting is required + * when heights have changed, so `lastI` can be forced. + */ + lastI: number; + ys: number[]; + + constructor(bufferLen: number) { + this.ys = []; + this.heights = []; + this.bufferLen = bufferLen; + this.dataLen = -1; + this.lastI = -1; + } + + /** + * Used to make sure the length of y-values and heights is consistent with + * the context; in particular `lastI` needs to remain valid. + */ + profileData(dataLength: number) { + if (dataLength !== this.dataLen) { + this.dataLen = dataLength; + this.ys.length = dataLength; + this.heights.length = dataLength; + if (this.lastI >= dataLength) { + this.lastI = dataLength - 1; + } + } + } + + /** + * Calculate and save the heights and y-values, based on `heightGetter`, from + * `lastI` until the`max` index; the starting point (`lastI`) can be forced + * via the `forcedLastI` parameter. + * @param {number=} forcedLastI + */ + calcHeights(max: number, heightGetter: number => number, forcedLastI?: number) { + if (forcedLastI != null) { + this.lastI = forcedLastI; + } + let _max = max + this.bufferLen; + if (_max <= this.lastI) { + return; + } + if (_max >= this.heights.length) { + _max = this.heights.length - 1; + } + let i = this.lastI; + if (this.lastI === -1) { + i = 0; + this.ys[0] = 0; + } + while (i <= _max) { + // eslint-disable-next-line no-multi-assign + const h = (this.heights[i] = heightGetter(i)); + this.ys[i + 1] = this.ys[i] + h; + i++; + } + this.lastI = _max; + } + + /** + * Verify the height and y-values from `lastI` up to `yValue`. + */ + calcYs(yValue: number, heightGetter: number => number) { + while ((this.ys[this.lastI] == null || yValue > this.ys[this.lastI]) && this.lastI < this.dataLen - 1) { + this.calcHeights(this.lastI, heightGetter); + } + } + + /** + * 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 + * `.ys`. + */ + findFloorIndex(yValue: number, heightGetter: number => number): number { + this.calcYs(yValue, heightGetter); + + let imin = 0; + let imax = this.lastI; + + if (this.ys.length < 2 || yValue < this.ys[1]) { + return 0; + } else if (yValue > this.ys[imax]) { + return imax; + } + let i; + while (imin < imax) { + // eslint-disable-next-line no-bitwise + i = (imin + 0.5 * (imax - imin)) | 0; + if (yValue > this.ys[i]) { + if (yValue <= this.ys[i + 1]) { + return i; + } + imin = i; + } else if (yValue < this.ys[i]) { + if (yValue >= this.ys[i - 1]) { + return i - 1; + } + imax = i; + } else { + return i; + } + } + throw new Error(`unable to find floor index for y=${yValue}`); + } + + /** + * Get the estimated height of the whole shebang by extrapolating based on + * the average known height. + */ + getEstimatedHeight(): number { + const known = this.ys[this.lastI] + this.heights[this.lastI]; + if (this.lastI >= this.dataLen - 1) { + // eslint-disable-next-line no-bitwise + return known | 0; + } + // 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/Positions.test.js b/src/components/TracePage/TraceTimelineViewer/ListView/Positions.test.js new file mode 100644 index 0000000000..832e56a7e4 --- /dev/null +++ b/src/components/TracePage/TraceTimelineViewer/ListView/Positions.test.js @@ -0,0 +1,250 @@ +// 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 Positions from './Positions'; + +describe('Positions', () => { + const bufferLen = 1; + const getHeight = i => i * 2 + 2; + let ps; + + beforeEach(() => { + ps = new Positions(bufferLen); + ps.profileData(10); + }); + + describe('constructor()', () => { + it('intializes member variables correctly', () => { + ps = new Positions(1); + expect(ps.ys).toEqual([]); + expect(ps.heights).toEqual([]); + expect(ps.bufferLen).toBe(1); + expect(ps.dataLen).toBe(-1); + expect(ps.lastI).toBe(-1); + }); + }); + + describe('profileData(...)', () => { + it('manages increases in data length correctly', () => { + expect(ps.dataLen).toBe(10); + expect(ps.ys.length).toBe(10); + expect(ps.heights.length).toBe(10); + expect(ps.lastI).toBe(-1); + }); + + it('manages decreases in data length correctly', () => { + ps.lastI = 9; + ps.profileData(5); + expect(ps.dataLen).toBe(5); + expect(ps.ys.length).toBe(5); + expect(ps.heights.length).toBe(5); + expect(ps.lastI).toBe(4); + }); + + it('does nothing when data length is unchanged', () => { + expect(ps.dataLen).toBe(10); + expect(ps.ys.length).toBe(10); + expect(ps.heights.length).toBe(10); + expect(ps.lastI).toBe(-1); + ps.profileData(10); + expect(ps.dataLen).toBe(10); + expect(ps.ys.length).toBe(10); + expect(ps.heights.length).toBe(10); + expect(ps.lastI).toBe(-1); + }); + }); + + describe('calcHeights()', () => { + it('updates lastI correctly', () => { + ps.calcHeights(1, getHeight); + expect(ps.lastI).toBe(bufferLen + 1); + }); + + it('saves the heights and y-values up to `lastI <= max + bufferLen`', () => { + const ys = [0, 2, 6, 12]; + ys.length = 10; + const heights = [2, 4, 6]; + heights.length = 10; + ps.calcHeights(1, getHeight); + expect(ps.ys).toEqual(ys); + expect(ps.heights).toEqual(heights); + }); + + it('does nothing when `max + buffer <= lastI`', () => { + ps.calcHeights(2, getHeight); + const ys = ps.ys.slice(); + const heights = ps.heights.slice(); + ps.calcHeights(1, getHeight); + expect(ps.ys).toEqual(ys); + expect(ps.heights).toEqual(heights); + }); + + describe('recalculates values up to `max + bufferLen` when `max + buffer <= lastI` and `forcedLastI = 0` is passed', () => { + beforeEach(() => { + // the initial state for the test + ps.calcHeights(2, getHeight); + }); + + it('test-case has a valid initial state', () => { + const initialYs = [0, 2, 6, 12, 20]; + initialYs.length = 10; + const initialHeights = [2, 4, 6, 8]; + initialHeights.length = 10; + expect(ps.ys).toEqual(initialYs); + expect(ps.heights).toEqual(initialHeights); + expect(ps.lastI).toBe(3); + }); + + it('recalcualtes the y-values correctly', () => { + // recalc a sub-set of the calcualted values using a different getHeight + ps.calcHeights(1, () => 2, 0); + const ys = [0, 2, 4, 6, 20]; + ys.length = 10; + expect(ps.ys).toEqual(ys); + }); + it('recalcualtes the heights correctly', () => { + // recalc a sub-set of the calcualted values using a different getHeight + ps.calcHeights(1, () => 2, 0); + const heights = [2, 2, 2, 8]; + heights.length = 10; + expect(ps.heights).toEqual(heights); + }); + it('saves lastI correctly', () => { + // recalc a sub-set of the calcualted values + ps.calcHeights(1, getHeight, 0); + expect(ps.lastI).toBe(2); + }); + }); + + it('limits caclulations to the known data length', () => { + ps.calcHeights(999, getHeight); + expect(ps.lastI).toBe(ps.dataLen - 1); + }); + }); + + describe('calcYs()', () => { + it('scans forward until `yValue` is met or exceeded', () => { + ps.calcYs(11, getHeight); + const ys = [0, 2, 6, 12, 20]; + ys.length = 10; + const heights = [2, 4, 6, 8]; + heights.length = 10; + expect(ps.ys).toEqual(ys); + expect(ps.heights).toEqual(heights); + }); + + it('exits early if the known y-values exceed `yValue`', () => { + ps.calcYs(11, getHeight); + const spy = jest.spyOn(ps, 'calcHeights'); + ps.calcYs(10, getHeight); + expect(spy).not.toHaveBeenCalled(); + }); + + it('exits when exceeds the data length even if yValue is unmet', () => { + ps.calcYs(999, getHeight); + expect(ps.ys[ps.ys.length - 1]).toBeLessThan(999); + }); + }); + + describe('findFloorIndex()', () => { + beforeEach(() => { + ps.calcYs(11, getHeight); + // Note: ps.ys = [0, 2, 6, 12, 20, undefined x 5]; + }); + + it('scans y-values for index that equals or preceeds `yValue`', () => { + let i = ps.findFloorIndex(3, getHeight); + expect(i).toBe(1); + i = ps.findFloorIndex(21, getHeight); + expect(i).toBe(4); + ps.calcYs(999, getHeight); + i = ps.findFloorIndex(11, getHeight); + expect(i).toBe(2); + i = ps.findFloorIndex(12, getHeight); + expect(i).toBe(3); + i = ps.findFloorIndex(20, getHeight); + expect(i).toBe(4); + }); + + it('is robust against non-positive y-values', () => { + let i = ps.findFloorIndex(0, getHeight); + expect(i).toBe(0); + i = ps.findFloorIndex(-10, getHeight); + expect(i).toBe(0); + }); + + it('scans no further than dataLen even if `yValue` is unmet', () => { + const i = ps.findFloorIndex(999, getHeight); + expect(i).toBe(ps.lastI); + }); + }); + + describe('getEstimatedHeight()', () => { + const simpleGetHeight = () => 2; + + beforeEach(() => { + ps.calcYs(5, simpleGetHeight); + // Note: ps.ys = [0, 2, 4, 6, 8, undefined x 5]; + }); + + it('returns the estimated max height, surpassing known values', () => { + const estHeight = ps.getEstimatedHeight(); + expect(estHeight).toBeGreaterThan(ps.heights[ps.lastI]); + }); + + it('returns the known max height, if all heights have been calculated', () => { + ps.calcYs(999, simpleGetHeight); + const totalHeight = ps.getEstimatedHeight(); + expect(totalHeight).toBeGreaterThan(ps.heights[ps.heights.length - 1]); + }); + }); + + describe('confirmHeight()', () => { + const simpleGetHeight = () => 2; + + beforeEach(() => { + ps.calcYs(5, simpleGetHeight); + // Note: ps.ys = [0, 2, 4, 6, 8, undefined x 5]; + }); + + it('calculates heights up to and including `_i` if necessary', () => { + const startNumHeights = ps.heights.filter(Boolean).length; + const calcHeightsSpy = jest.spyOn(ps, 'calcHeights'); + ps.confirmHeight(7, simpleGetHeight); + const endNumHeights = ps.heights.filter(Boolean).length; + expect(startNumHeights).toBeLessThan(endNumHeights); + expect(calcHeightsSpy).toHaveBeenCalled(); + }); + + it('invokes `heightGetter` at `_i` to compare result with known height', () => { + const getHeightSpy = jest.fn(simpleGetHeight); + ps.confirmHeight(ps.lastI - 1, getHeightSpy); + expect(getHeightSpy).toHaveBeenCalled(); + }); + + it('cascades difference in observed height vs known height to known y-values', () => { + const getLargerHeight = () => simpleGetHeight() + 2; + const knownYs = ps.ys.slice(); + const expectedYValues = knownYs.map(value => (value ? value + 2 : value)); + ps.confirmHeight(0, getLargerHeight); + expect(ps.ys).toEqual(expectedYValues); + }); + }); +}); diff --git a/src/components/TracePage/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap b/src/components/TracePage/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..b889b23fba --- /dev/null +++ b/src/components/TracePage/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap @@ -0,0 +1,586 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` shallow tests matches a snapshot 1`] = ` +ShallowWrapper { + "complexSelector": ComplexSelector { + "buildPredicate": [Function], + "childrenOfNode": [Function], + "findWhereUnwrapped": [Function], + }, + "length": 1, + "node":
+
+
+ + + 1 + + + 2 + + + 3 + + + 4 + +
+
+
, + "nodes": Array [ +
+
+
+ + + 1 + + + 2 + + + 3 + + + 4 + +
+
+
, + ], + "options": Object {}, + "renderer": ReactShallowRenderer { + "_instance": ShallowComponentWrapper { + "_calledComponentWillUnmount": false, + "_compositeType": 0, + "_context": Object {}, + "_currentElement": , + "_debugID": 3, + "_hostContainerInfo": null, + "_hostParent": null, + "_instance": ListView { + "_endIndex": 0, + "_endIndexDrawn": 4, + "_getHeight": [Function], + "_htmlElm": + + +, + "_htmlTopOffset": -1, + "_initItemHolder": [Function], + "_initWrapper": [Function], + "_isScrolledOrResized": false, + "_itemHolderElm": undefined, + "_knownHeights": Map {}, + "_onScroll": [Function], + "_positionList": [Function], + "_reactInternalInstance": [Circular], + "_scanItemHeights": [Function], + "_scrollTop": -1, + "_startIndex": 0, + "_startIndexDrawn": 0, + "_viewHeight": -1, + "_windowScrollListenerAdded": false, + "_wrapperElm": undefined, + "_yPositions": Positions { + "bufferLen": 200, + "dataLen": 40, + "heights": Array [ + 2, + 4, + 6, + 8, + 10, + 12, + 14, + 16, + 18, + 20, + 22, + 24, + 26, + 28, + 30, + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 46, + 48, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 70, + 72, + 74, + 76, + 78, + 80, + ], + "lastI": 39, + "ys": Array [ + 0, + 2, + 6, + 12, + 20, + 30, + 42, + 56, + 72, + 90, + 110, + 132, + 156, + 182, + 210, + 240, + 272, + 306, + 342, + 380, + 420, + 462, + 506, + 552, + 600, + 650, + 702, + 756, + 812, + 870, + 930, + 992, + 1056, + 1122, + 1190, + 1260, + 1332, + 1406, + 1482, + 1560, + 1640, + ], + }, + "context": Object {}, + "props": Object { + "dataLength": 40, + "getIndexFromKey": [Function], + "getKeyFromIndex": [Function], + "initialDraw": 5, + "itemHeightGetter": [Function], + "itemRenderer": [Function], + "itemsWrapperClassName": "SomeClassName", + "viewBuffer": 10, + "viewBufferMin": 5, + "windowScroller": false, + }, + "refs": Object {}, + "state": null, + "updater": Object { + "enqueueCallback": [Function], + "enqueueCallbackInternal": [Function], + "enqueueElementInternal": [Function], + "enqueueForceUpdate": [Function], + "enqueueReplaceState": [Function], + "enqueueSetState": [Function], + "isMounted": [Function], + "validateCallback": [Function], + }, + }, + "_mountOrder": 2, + "_pendingCallbacks": null, + "_pendingElement": null, + "_pendingForceUpdate": false, + "_pendingReplaceState": false, + "_pendingStateQueue": null, + "_renderedComponent": NoopInternalComponent { + "_currentElement":
+
+
+ + + 1 + + + 2 + + + 3 + + + 4 + +
+
+
, + "_debugID": 4, + "_renderedOutput":
+
+
+ + + 1 + + + 2 + + + 3 + + + 4 + +
+
+
, + }, + "_renderedNodeType": 0, + "_rootNodeID": 0, + "_topLevelWrapper": null, + "_updateBatchNumber": null, + "_warnedAboutRefsInRender": false, + }, + "getRenderOutput": [Function], + "render": [Function], + }, + "root": [Circular], + "unrendered": , +} +`; diff --git a/src/components/TracePage/TraceTimelineViewer/ListView/index.js b/src/components/TracePage/TraceTimelineViewer/ListView/index.js new file mode 100644 index 0000000000..572b375739 --- /dev/null +++ b/src/components/TracePage/TraceTimelineViewer/ListView/index.js @@ -0,0 +1,486 @@ +// @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 Positions from './Positions'; + +/** + * @typedef + */ +type ListViewProps = { + /** + * Number of elements in the list. + */ + dataLength: number, + /** + * Convert item index (number) to the key (string). ListView uses both indexes + * and keys to handle the addtion of new rows. + */ + getIndexFromKey: string => number, + /** + * Convert item key (string) to the index (number). ListView uses both indexes + * and keys to handle the addtion of new rows. + */ + getKeyFromIndex: number => string, + /** + * Number of items to draw and add to the DOM, initially. + */ + initialDraw: number, + /** + * The parent provides fallback height measurements when there is not a + * rendered element to measure. + */ + itemHeightGetter: (number, string) => number, + /** + * Function that renders an item; rendered items are added directly to the + * DOM, they are not wrapped in list item wrapper HTMLElement. + */ + itemRenderer: (string, {}, number, {}) => React.Node, + /** + * `className` for the HTMLElement that holds the items. + */ + itemsWrapperClassName?: string, + /** + * When adding new items to the DOM, this is the number of items to add above + * and below the current view. E.g. if list is 100 items and is srcolled + * halfway down (so items [46, 55] are in view), then when a new range of + * items is rendered, it will render items `46 - viewBuffer` to + * `55 + viewBuffer`. + */ + viewBuffer: number, + /** + * The minimum number of items offscreen in either direction; e.g. at least + * `viewBuffer` number of items must be off screen above and below the + * current view, or more items will be rendered. + */ + viewBufferMin: number, + /** + * When `true`, expect `_wrapperElm` to have `overflow: visible` and to, + * essentially, be tall to the point the entire page will will end up + * scrolling as a result of the ListView. Similar to react-virtualized + * window scroller. + * + * - Ref: https://bvaughn.github.io/react-virtualized/#/components/WindowScroller + * - Ref:https://github.com/bvaughn/react-virtualized/blob/497e2a1942529560681d65a9ef9f5e9c9c9a49ba/docs/WindowScroller.md + */ + windowScroller?: boolean, +}; + +/** + * Virtualized list view component, for the most part, only renders the window + * of items that are in-view with some buffer before and after. Listens for + * scroll events and updates which items are rendered. See react-virtualized + * for a suite of components with similar, but generalized, functinality. + * https://github.com/bvaughn/react-virtualized + * + * Note: Presently, ListView cannot be a PureComponent. This is because ListView + * is sensitive to the underlying state that drives the list items, but it + * doesn't actually receive that state. So, a render may still be required even + * if ListView's props are unchanged. + * + * @export + * @class ListView + */ +export default class ListView extends React.Component { + props: ListViewProps; + /** + * Keeps track of the height and y-value of items, by item index, in the + * ListView. + */ + _yPositions: Positions; + /** + * Keep track of the known / measured heights of the rendered items; populated + * with values through observation and keyed on the item key, not the item + * index. + */ + _knownHeights: Map; + /** + * The start index of the items currently drawn. + */ + _startIndexDrawn: number; + /** + * The end index of the items currently drawn. + */ + _endIndexDrawn: number; + /** + * The start index of the items currently in view. + */ + _startIndex: number; + /** + * The end index of the items currently in view. + */ + _endIndex: number; + /** + * Height of the visual window, e.g. height of the scroller element. + */ + _viewHeight: number; + /** + * `scrollTop` of the current scroll position. + */ + _scrollTop: number; + /** + * Used to keep track of whether or not a re-calculation of what should be + * drawn / viewable has been scheduled. + */ + _isScrolledOrResized: boolean; + /** + * If `windowScroller` is true, this notes how far down the page the scroller + * is located. (Note: repositioning and below-the-fold views are untested) + */ + _htmlTopOffset: number; + _windowScrollListenerAdded: boolean; + _htmlElm: HTMLElement; + /** + * HTMLElement holding the scroller. + */ + _wrapperElm: ?HTMLElement; + /** + * HTMLElement holding the rendered items. + */ + _itemHolderElm: ?HTMLElement; + + static defaultProps = { + /** + * E.g.`str => Number(str)` + */ + getIndexFromKey: Number, + /** + * E.g.`num => String(num)` + */ + getKeyFromIndex: String, + initialDraw: 300, + itemsWrapperClassName: '', + viewBuffer: 90, + viewBufferMin: 30, + windowScroller: false, + }; + + constructor(props: ListViewProps) { + super(props); + + 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); + this._startIndex = 0; + this._endIndex = 0; + this._viewHeight = -1; + 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._wrapperElm = undefined; + this._itemHolderElm = undefined; + this._initWrapper = this._initWrapper.bind(this); + this._initItemHolder = this._initItemHolder.bind(this); + } + + componentDidMount() { + if (this.props.windowScroller) { + if (this._wrapperElm) { + const { top } = this._wrapperElm.getBoundingClientRect(); + this._htmlTopOffset = top + this._htmlElm.scrollTop; + } + window.addEventListener('scroll', this._onScroll); + this._windowScrollListenerAdded = true; + } + } + + componentDidUpdate() { + if (this._itemHolderElm) { + this._scanItemHeights(); + } + } + + componentWillUnmount() { + if (this._windowScrollListenerAdded) { + window.removeEventListener('scroll', this._onScroll); + } + } + + /** + * Scroll event listener that schedules a remeasuring of which items should be + * rendered. + */ + _onScroll = function _onScroll() { + if (!this._isScrolledOrResized) { + this._isScrolledOrResized = true; + window.requestAnimationFrame(this._positionList); + } + }; + + /** + * Returns true is the view height (scroll window) or scroll position have + * changed. + */ + _isViewChanged() { + if (!this._wrapperElm) { + return false; + } + const useRoot = this.props.windowScroller; + const clientHeight = useRoot ? this._htmlElm.clientHeight : this._wrapperElm.clientHeight; + const scrollTop = useRoot ? this._htmlElm.scrollTop : this._wrapperElm.scrollTop; + return clientHeight !== this._viewHeight || scrollTop !== this._scrollTop; + } + + /** + * Recalculate _startIndex and _endIndex, e.g. which items are in view. + */ + _calcViewIndexes() { + const useRoot = this.props.windowScroller; + // funky if statement is to satisfy flow + if (!useRoot) { + if (!this._wrapperElm) { + this._viewHeight = -1; + this._startIndex = 0; + this._endIndex = 0; + return; + } + 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._startIndex = this._yPositions.findFloorIndex(yStart, this._getHeight); + this._endIndex = this._yPositions.findFloorIndex(yEnd, this._getHeight); + } + + /** + * 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() { + this._isScrolledOrResized = false; + if (!this._wrapperElm) { + return; + } + this._calcViewIndexes(); + // indexes drawn should be padded by at least props.viewBufferMin + const maxStart = + this.props.viewBufferMin > this._startIndex ? 0 : this._startIndex - this.props.viewBufferMin; + const minEnd = + this.props.viewBufferMin < this.props.dataLength - this._endIndex + ? this._endIndex + this.props.viewBufferMin + : this.props.dataLength - 1; + if (maxStart < this._startIndexDrawn || minEnd > this._endIndexDrawn) { + // console.time('force update'); + // setTimeout(() => console.timeEnd('force update'), 0); + this.forceUpdate(); + } + }; + + _initWrapper = function _initWrapper(elm: HTMLElement) { + this._wrapperElm = elm; + if (!this.props.windowScroller) { + this._viewHeight = elm && elm.clientHeight; + } + }; + + _initItemHolder = function _initItemHolder(elm: HTMLElement) { + this._itemHolderElm = elm; + this._scanItemHeights(); + }; + + /** + * Go through all items that are rendered and save their height based on their + * 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() { + const getIndexFromKey = this.props.getIndexFromKey; + if (!this._itemHolderElm) { + return; + } + // note the keys for the first and last altered heights, the `yPositions` + // needs to be updated + let lowDirtyKey = null; + let highDirtyKey = null; + let isDirty = false; + // iterating childNodes is faster than children + // https://jsperf.com/large-htmlcollection-vs-large-nodelist + const nodes = this._itemHolderElm.childNodes; + const max = nodes.length; + for (let i = 0; i < max; i++) { + const node: HTMLElement = (nodes[i]: any); + // use `.getAttribute(...)` instead of `.dataset` for jest / JSDOM + const itemKey = node.getAttribute('data-item-key'); + if (!itemKey) { + // eslint-disable-next-line no-console + console.warn('itemKey not found'); + continue; + } + // measure the first child, if it's available, otherwise the node itself + // (likely not transferable to other contexts, and instead is specific to + // how we have the items rendered) + const measureSrc: Element = node.firstElementChild || node; + const observed = measureSrc.scrollHeight; + const known = this._knownHeights.get(itemKey); + if (observed !== known) { + this._knownHeights.set(itemKey, observed); + if (!isDirty) { + isDirty = true; + // eslint-disable-next-line no-multi-assign + lowDirtyKey = highDirtyKey = itemKey; + } else { + highDirtyKey = itemKey; + } + } + } + if (lowDirtyKey != null && highDirtyKey != null) { + // update yPositions, then redraw + const imin = getIndexFromKey(lowDirtyKey); + const imax = highDirtyKey === lowDirtyKey ? imin : getIndexFromKey(highDirtyKey); + this._yPositions.calcHeights(imax, this._getHeight, imin); + this.forceUpdate(); + } + }; + + /** + * Get the height of the element at index `i`; first check the known heigths, + * fallbck to `.props.itemHeightGetter(...)`. + */ + _getHeight = function _getHeight(i: number) { + const key = this.props.getKeyFromIndex(i); + const known = this._knownHeights.get(key); + // known !== known iff known is NaN + // eslint-disable-next-line no-self-compare + if (known != null && known === known) { + return known; + } + return this.props.itemHeightGetter(i, key); + }; + + render() { + const { dataLength, getKeyFromIndex, initialDraw, itemRenderer, viewBuffer, viewBufferMin } = this.props; + const heightGetter = this._getHeight; + const items = []; + + let start; + let end; + + if (!this._wrapperElm) { + start = 0; + end = (initialDraw < dataLength ? initialDraw : dataLength) - 1; + } else { + if (this._isViewChanged()) { + this._calcViewIndexes(); + } + const maxStart = viewBufferMin > this._startIndex ? 0 : this._startIndex - viewBufferMin; + const minEnd = + viewBufferMin < dataLength - this._endIndex ? this._endIndex + viewBufferMin : dataLength - 1; + if (maxStart < this._startIndexDrawn || minEnd > this._endIndexDrawn) { + start = viewBuffer > this._startIndex ? 0 : this._startIndex - viewBuffer; + end = this._endIndex + viewBuffer; + if (end >= dataLength) { + end = dataLength - 1; + } + } else { + start = this._startIndexDrawn; + end = this._endIndexDrawn > dataLength - 1 ? dataLength - 1 : this._endIndexDrawn; + } + } + + this._yPositions.profileData(dataLength); + this._yPositions.calcHeights(end, heightGetter, start || -1); + this._startIndexDrawn = start; + this._endIndexDrawn = end; + + items.length = end - start + 1; + for (let i = start; i <= end; i++) { + this._yPositions.confirmHeight(i, heightGetter); + const style = { + position: 'absolute', + top: this._yPositions.ys[i], + height: this._yPositions.heights[i], + overflow: 'hidden', + }; + const itemKey = getKeyFromIndex(i); + const attrs = { 'data-item-key': itemKey }; + items.push(itemRenderer(itemKey, style, i, attrs)); + } + + type wrapperPropsT = { + style: { [string]: string }, + ref: Function, + onScroll?: Function, + }; + const wrapperProps: wrapperPropsT = { + style: { + overflowY: 'auto', + position: 'relative', + height: '100%', + }, + ref: this._initWrapper, + }; + if (!this.props.windowScroller) { + wrapperProps.onScroll = this._onScroll; + } + const scrollerStyle = { + position: 'relative', + height: this._yPositions.getEstimatedHeight(), + }; + return ( +
+
+
+ {items} +
+
+
+ ); + } +} diff --git a/src/components/TracePage/TraceTimelineViewer/ListView/index.test.js b/src/components/TracePage/TraceTimelineViewer/ListView/index.test.js new file mode 100644 index 0000000000..8c5f5a43ff --- /dev/null +++ b/src/components/TracePage/TraceTimelineViewer/ListView/index.test.js @@ -0,0 +1,174 @@ +// 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 Bluebird from 'bluebird'; +import { mount, shallow } from 'enzyme'; + +import ListView from './index'; +import { polyfill as polyfillAnimationFrame } from '../../../../utils/test/requestAnimationFrame'; + +describe('', () => { + // polyfill window.requestAnimationFrame (and cancel) into jsDom's window + polyfillAnimationFrame(window); + + const DATA_LENGTH = 40; + + function getHeight(index) { + return index * 2 + 2; + } + + function Item(props) { + // eslint-disable-next-line react/prop-types + const { children, ...rest } = props; + return ( +
+ {children} +
+ ); + } + + function renderItem(itemKey, styles, itemIndex, attrs) { + return ( + + {itemIndex} + + ); + } + + let wrapper; + let instance; + + const props = { + dataLength: DATA_LENGTH, + initialDraw: 5, + itemHeightGetter: getHeight, + itemRenderer: renderItem, + itemsWrapperClassName: 'SomeClassName', + viewBuffer: 10, + viewBufferMin: 5, + windowScroller: false, + // eslint-disable-next-line no-return-assign + ref: c => (instance = c), + }; + + describe('shallow tests', () => { + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + }); + + it('matches a snapshot', () => { + expect(wrapper).toMatchSnapshot(); + }); + + it('initialDraw sets the number of items initially drawn', () => { + expect(wrapper.find(Item).length).toBe(props.initialDraw); + }); + + it('sets the height of the items according to the height func', () => { + const items = wrapper.find(Item); + const expectedHeights = []; + const heights = items.map((node, i) => { + expectedHeights.push(getHeight(i)); + return node.prop('style').height; + }); + expect(heights.length).toBe(props.initialDraw); + expect(heights).toEqual(expectedHeights); + }); + + it('saves the currently drawn indexes to _startIndexDrawn and _endIndexDrawn', () => { + const inst = wrapper.instance(); + expect(inst._startIndexDrawn).toBe(0); + expect(inst._endIndexDrawn).toBe(props.initialDraw - 1); + }); + }); + + describe('mount tests', () => { + beforeEach(() => { + wrapper = mount( +
+ +
+ ); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + }); + + describe('windowScroller', () => { + let windowAddListenerSpy; + + beforeEach(() => { + windowAddListenerSpy = jest.spyOn(window, 'addEventListener'); + const wsProps = { ...props, windowScroller: true }; + wrapper = mount( +
+ +
+ ); + }); + + afterEach(() => { + windowAddListenerSpy.mockRestore(); + }); + + it('adds the onScroll listener to the window element after the component mounts', () => { + expect(windowAddListenerSpy).toHaveBeenCalled(); + expect(windowAddListenerSpy).toHaveBeenLastCalledWith('scroll', instance._onScroll); + }); + + it('calls _positionList when the document is scrolled', async () => { + const event = new Event('scroll'); + const fn = jest.spyOn(instance, '_positionList'); + expect(instance._isScrolledOrResized).toBe(false); + window.dispatchEvent(event); + expect(instance._isScrolledOrResized).toBe(true); + await Bluebird.resolve().delay(0); + expect(fn).toHaveBeenCalled(); + }); + + it('uses the root HTML element to determine if the view has changed', () => { + const htmlElm = instance._htmlElm; + expect(htmlElm).toBeTruthy(); + const spyFns = { + clientHeight: jest.fn(() => instance._viewHeight + 1), + scrollTop: jest.fn(() => instance._scrollTop + 1), + }; + Object.defineProperties(htmlElm, { + clientHeight: { + get: spyFns.clientHeight, + }, + scrollTop: { + get: spyFns.scrollTop, + }, + }); + const hasChanged = instance._isViewChanged(); + expect(spyFns.clientHeight).toHaveBeenCalled(); + expect(spyFns.scrollTop).toHaveBeenCalled(); + expect(hasChanged).toBe(true); + }); + }); + }); +}); diff --git a/src/components/TracePage/TraceTimelineViewer/SpanBar.css b/src/components/TracePage/TraceTimelineViewer/SpanBar.css index 083b6fb27f..1c88ab01d1 100644 --- a/src/components/TracePage/TraceTimelineViewer/SpanBar.css +++ b/src/components/TracePage/TraceTimelineViewer/SpanBar.css @@ -21,7 +21,7 @@ THE SOFTWARE. */ -.span-bar-wrapper { +.SpanBar--wrapper { bottom: 0; left: 0; position: absolute; @@ -31,22 +31,40 @@ THE SOFTWARE. opacity: 0.5; } -.span-row.is-expanded .span-bar-wrapper, -.span-row:hover .span-bar-wrapper { +.span-row.is-expanded .SpanBar--wrapper, +.span-row:hover .SpanBar--wrapper { opacity: 1; } /* Add the hint related selector to override the hint styling (via specificity) */ -[class*="hint--"].span-bar { +.SpanBar--bar { border-radius: 3px; + min-width: 2px; position: absolute; height: 50%; top: 25%; } -.span-bar-rpc { +.SpanBar--rpc { position: absolute; top: 35%; bottom: 35%; z-index: 1; } + +.SpanBar--label { + font-size: 12px; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + line-height: 1em; + white-space: nowrap; + padding: 0 0.5em; + position: absolute; +} + +.SpanBar--label.is-right { + left: 100%; +} + +.SpanBar--label.is-left { + right: 100%; +} diff --git a/src/components/TracePage/TraceTimelineViewer/SpanBar.js b/src/components/TracePage/TraceTimelineViewer/SpanBar.js index e46aba1e99..49847ad63b 100644 --- a/src/components/TracePage/TraceTimelineViewer/SpanBar.js +++ b/src/components/TracePage/TraceTimelineViewer/SpanBar.js @@ -29,22 +29,26 @@ function toPercent(value) { } function SpanBar(props) { - const { viewEnd, viewStart, color, label, hintSide, onClick, onMouseOver, onMouseOut, rpc } = props; + const { viewEnd, viewStart, color, label, hintSide, onClick, setLongLabel, setShortLabel, rpc } = props; return ( -
+
+ > +
+ {label} +
+
{rpc &&
props.shortLabel), withProps(({ setLabel, shortLabel, longLabel }) => ({ - onMouseOver: () => setLabel(longLabel), - onMouseOut: () => setLabel(shortLabel), + setLongLabel: () => setLabel(longLabel), + setShortLabel: () => setLabel(shortLabel), })), - onlyUpdateForKeys(['viewStart', 'viewEnd', 'label', 'rpc']) + onlyUpdateForKeys(['label', 'rpc', 'viewStart', 'viewEnd']) )(SpanBar); diff --git a/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js b/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js index 9a20113a19..7a2bd5b091 100644 --- a/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js +++ b/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js @@ -18,8 +18,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import PropTypes from 'prop-types'; import React from 'react'; +import PropTypes from 'prop-types'; import TimelineRow from './TimelineRow'; import SpanTreeOffset from './SpanTreeOffset'; diff --git a/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.css b/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.css index 25de81043e..9f4f73bb15 100644 --- a/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.css +++ b/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.css @@ -38,14 +38,18 @@ THE SOFTWARE. white-space: nowrap; } -.AccordianKeyValues--header:hover { +.AccordianKeyValues--header:not(.is-empty):hover { background: #eee; } -.AccordianKeyValues--header.is-high-contrast:hover { +.AccordianKeyValues--header.is-high-contrast:not(.is-empty):hover { background: #ddd; } +.AccordianKeyValues--emptyArtifact { + color: #aaa; +} + .AccordianKeyValues--summary { display: inline; list-style: none; diff --git a/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js b/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js index cb970414cc..6bb6e6d43b 100644 --- a/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js +++ b/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js @@ -1,4 +1,5 @@ // @flow + // Copyright (c) 2017 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy @@ -20,18 +21,26 @@ // THE SOFTWARE. import React from 'react'; +import cx from 'classnames'; import KeyValuesTable from './KeyValuesTable'; -import toggleEnhancer from './toggle-enhancer'; import './AccordianKeyValues.css'; -type KeyValuesSummaryProps = { +type AccordianKeyValuesProps = { + compact?: boolean, data: { key: string, value: any }[], + highContrast?: boolean, + isOpen: boolean, + label: string, + onToggle: () => void, }; -function KeyValuesSummary(props: KeyValuesSummaryProps) { +function KeyValuesSummary(props: { data?: { key: string, value: any }[] }) { const { data } = props; + if (!Array.isArray(data) || !data.length) { + return null; + } return (