diff --git a/jest.config.js b/jest.config.js index 5b192159435..6bac8515183 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { verbose: false, transform: { - '^.+\\.(ts|tsx)$': 'ts-jest', + '^.+\\.(ts|tsx|js|jsx)$': 'ts-jest', }, moduleDirectories: ['node_modules', 'public'], roots: ['/public/app', '/public/test', '/packages', '/scripts'], diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index 8d120f5df95..7fa46f6a3d6 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -13,6 +13,8 @@ export enum FieldType { number = 'number', string = 'string', boolean = 'boolean', + // Used to detect that the value is some kind of trace data to help with the visualisation and processing. + trace = 'trace', other = 'other', // Object, Array, etc } diff --git a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx index c64de1613dc..02fc50ac5dd 100644 --- a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx +++ b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { Icon } from '../Icon/Icon'; +import { css } from 'emotion'; // @ts-ignore import RCCascader from 'rc-cascader'; import { CascaderOption } from '../Cascader/Cascader'; import { onChangeCascader, onLoadDataCascader } from '../Cascader/optionMappings'; +import { stylesFactory } from '../../themes'; export interface ButtonCascaderProps { options: CascaderOption[]; @@ -18,12 +20,22 @@ export interface ButtonCascaderProps { onPopupVisibleChange?: (visible: boolean) => void; } +const getStyles = stylesFactory(() => { + return { + popup: css` + label: popup; + z-index: 100; + `, + }; +}); + export const ButtonCascader: React.FC = props => { const { onChange, loadData, ...rest } = props; return ( diff --git a/packages/jaeger-ui-components/.eslintrc b/packages/jaeger-ui-components/.eslintrc new file mode 100644 index 00000000000..bd11a76bc35 --- /dev/null +++ b/packages/jaeger-ui-components/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": ["@grafana/eslint-config"], + "rules": { + "no-restricted-imports": [2, "^@grafana/runtime.*", "^@grafana/ui.*"] + } +} diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json new file mode 100644 index 00000000000..1de05245d61 --- /dev/null +++ b/packages/jaeger-ui-components/package.json @@ -0,0 +1,41 @@ +{ + "name": "@jaegertracing/jaeger-ui-components", + "version": "0.0.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "enzyme": "^3.8.0", + "enzyme-adapter-react-16": "^1.2.0", + "typescript": "3.5.3" + }, + "dependencies": { + "@types/classnames": "^2.2.7", + "@types/deep-freeze": "^0.1.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/lodash": "^4.14.123", + "@types/moment": "^2.13.0", + "@types/react-icons": "2.2.7", + "@types/recompose": "^0.30.7", + "chance": "^1.0.10", + "classnames": "^2.2.5", + "combokeys": "^3.0.0", + "copy-to-clipboard": "^3.1.0", + "deep-freeze": "^0.0.1", + "emotion": "^10.0.27", + "fuzzy": "^0.1.3", + "hoist-non-react-statics": "^3.3.2", + "json-markup": "^1.1.0", + "lodash": "^4.17.4", + "lru-memoize": "^1.1.0", + "memoize-one": "^5.0.0", + "moment": "^2.18.1", + "react": "^16.3.2", + "react-icons": "2.2.7", + "recompose": "^0.25.0", + "tween-functions": "^1.2.0" + } +} diff --git a/packages/jaeger-ui-components/src/ScrollManager.test.js b/packages/jaeger-ui-components/src/ScrollManager.test.js new file mode 100644 index 00000000000..34e6b37241a --- /dev/null +++ b/packages/jaeger-ui-components/src/ScrollManager.test.js @@ -0,0 +1,286 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* eslint-disable import/first */ +jest.mock('./scroll-page'); + +import { scrollBy, scrollTo } from './scroll-page'; +import ScrollManager from './ScrollManager'; + +const SPAN_HEIGHT = 2; + +function getTrace() { + const spans = []; + const trace = { + spans, + duration: 2000, + startTime: 1000, + }; + for (let i = 0; i < 10; i++) { + spans.push({ duration: 1, startTime: 1000, spanID: i + 1 }); + } + 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('is a noop if an invalid rowPosition is returned by the accessors', () => { + // eslint-disable-next-line no-console + const oldWarn = console.warn; + // eslint-disable-next-line no-console + console.warn = () => {}; + manager._scrollPast(null, null); + expect(accessors.getRowPosition.mock.calls.length).toBe(1); + expect(accessors.getViewHeight.mock.calls.length).toBe(0); + expect(scrollTo.mock.calls.length).toBe(0); + // eslint-disable-next-line no-console + console.warn = oldWarn; + }); + + 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()', () => { + function getRefs(spanID) { + return [{ refType: 'CHILD_OF', spanID }]; + } + 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 = () => [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 = () => new Set([trace.spans[4].spanID]); + manager._scrollToVisibleSpan(1); + expect(scrollPastMock).lastCalledWith(4, 1); + manager._scrollToVisibleSpan(-1); + expect(scrollPastMock).lastCalledWith(4, -1); + }); + + it('scrolls to boundary when scrolling away from closest spanID in findMatches', () => { + const closetFindMatchesSpanID = 4; + accessors.getTopRowIndexVisible.mockReturnValue(closetFindMatchesSpanID - 1); + accessors.getBottomRowIndexVisible.mockReturnValue(closetFindMatchesSpanID + 1); + accessors.getSearchedSpanIDs = () => new Set([trace.spans[closetFindMatchesSpanID].spanID]); + + manager._scrollToVisibleSpan(1); + expect(scrollPastMock).lastCalledWith(trace.spans.length - 1, 1); + + manager._scrollToVisibleSpan(-1); + expect(scrollPastMock).lastCalledWith(0, -1); + }); + + it('scrolls to last visible row when boundary is hidden', () => { + const parentOfLastRowWithHiddenChildrenIndex = trace.spans.length - 2; + accessors.getBottomRowIndexVisible.mockReturnValue(0); + accessors.getCollapsedChildren = () => + new Set([trace.spans[parentOfLastRowWithHiddenChildrenIndex].spanID]); + accessors.getSearchedSpanIDs = () => new Set([trace.spans[0].spanID]); + trace.spans[trace.spans.length - 1].references = getRefs( + trace.spans[parentOfLastRowWithHiddenChildrenIndex].spanID + ); + + manager._scrollToVisibleSpan(1); + expect(scrollPastMock).lastCalledWith(parentOfLastRowWithHiddenChildrenIndex, 1); + }); + + describe('scrollToNextVisibleSpan() and scrollToPrevVisibleSpan()', () => { + beforeEach(() => { + // 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); + } + } + // set which spans are "in-view" and which have collapsed children + accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1); + accessors.getBottomRowIndexVisible.mockReturnValue(0); + accessors.getCollapsedChildren.mockReturnValue(new Set([spans[0].spanID, spans[4].spanID])); + }); + + it('skips spans that are hidden because their parent is collapsed', () => { + manager.scrollToNextVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, 1); + manager.scrollToPrevVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, -1); + }); + + it('ignores references with unknown types', () => { + // modify spans[2] so that it has an unknown refType + const spans = trace.spans; + spans[2].references = [{ refType: 'OTHER' }]; + manager.scrollToNextVisibleSpan(); + expect(scrollPastMock).lastCalledWith(2, 1); + manager.scrollToPrevVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, -1); + }); + + it('handles more than one level of ancestry', () => { + // modify spans[2] so that it has an unknown refType + const spans = trace.spans; + spans[2].references = getRefs(spans[1].spanID); + manager.scrollToNextVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, 1); + manager.scrollToPrevVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, -1); + }); + }); + + describe('scrollToFirstVisibleSpan', () => { + beforeEach(() => { + jest.spyOn(manager, '_scrollToVisibleSpan').mockImplementationOnce(); + }); + + it('calls _scrollToVisibleSpan searching downwards from first span', () => { + manager.scrollToFirstVisibleSpan(); + expect(manager._scrollToVisibleSpan).toHaveBeenCalledWith(1, 0); + }); + }); + }); + + describe('scrollPageDown() and scrollPageUp()', () => { + it('scrolls by +/~ viewHeight when invoked', () => { + manager.scrollPageDown(); + expect(scrollBy).lastCalledWith(0.95 * accessors.getViewHeight(), true); + manager.scrollPageUp(); + expect(scrollBy).lastCalledWith(-0.95 * accessors.getViewHeight(), true); + }); + + it('is a no-op if _accessors or _scroller is not defined', () => { + manager._accessors = null; + manager.scrollPageDown(); + manager.scrollPageUp(); + expect(scrollBy.mock.calls.length).toBe(0); + manager._accessors = accessors; + manager._scroller = null; + manager.scrollPageDown(); + manager.scrollPageUp(); + expect(scrollBy.mock.calls.length).toBe(0); + }); + }); + + describe('destroy()', () => { + it('disposes', () => { + expect(manager._trace).toBeDefined(); + expect(manager._accessors).toBeDefined(); + expect(manager._scroller).toBeDefined(); + manager.destroy(); + expect(manager._trace).not.toBeDefined(); + expect(manager._accessors).not.toBeDefined(); + expect(manager._scroller).not.toBeDefined(); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/ScrollManager.tsx b/packages/jaeger-ui-components/src/ScrollManager.tsx new file mode 100644 index 00000000000..83c1842a59c --- /dev/null +++ b/packages/jaeger-ui-components/src/ScrollManager.tsx @@ -0,0 +1,274 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TNil } from './types'; +import { Span, SpanReference, Trace } from './types/trace'; + +/** + * `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 | TNil; + getCollapsedChildren: () => Set | TNil; + getViewHeight: () => number; + getBottomRowIndexVisible: () => number; + getTopRowIndexVisible: () => number; + getRowPosition: (rowIndex: number) => { height: number; y: number }; + mapRowIndexToSpanIndex: (rowIndex: number) => number; + mapSpanIndexToRowIndex: (spanIndex: number) => number; +}; + +interface IScroller { + scrollTo: (rowIndex: number) => void; + // TODO arg names throughout + scrollBy: (rowIndex: number, opt?: 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 }: { references: SpanReference[] | TNil } = span; + let parentID: undefined | string; + const checkRef = (ref: SpanReference) => { + if (ref.refType === 'CHILD_OF' || ref.refType === 'FOLLOWS_FROM') { + 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 | TNil; + _scroller: IScroller; + _accessors: Accessors | TNil; + + constructor(trace: Trace | TNil, scroller: IScroller) { + this._trace = trace; + this._scroller = scroller; + this._accessors = undefined; + } + + _scrollPast(rowIndex: number, direction: 1 | -1) { + const xrs = this._accessors; + /* istanbul ignore next */ + if (!xrs) { + throw new Error('Accessors not set'); + } + const isUp = direction < 0; + const position = xrs.getRowPosition(rowIndex); + if (!position) { + // eslint-disable-next-line no-console + 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, startRow?: number) { + const xrs = this._accessors; + /* istanbul ignore next */ + if (!xrs) { + throw new Error('Accessors not set'); + } + if (!this._trace) { + return; + } + const { duration, spans, startTime: traceStartTime } = this._trace; + const isUp = direction < 0; + let boundaryRow: number; + if (startRow != null) { + boundaryRow = startRow; + } else if (isUp) { + boundaryRow = xrs.getTopRowIndexVisible(); + } else { + boundaryRow = 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: Map = childrenAreHidden + ? new Map(spans.map(s => [s.spanID, s] as [string, Span])) + : new Map(); + const boundary = direction < 0 ? -1 : spans.length; + let nextSpanIndex: number | undefined; + 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) { + parentIDs.forEach(id => childrenAreHidden.add(id)); + continue; + } + } + nextSpanIndex = i; + break; + } + if (!nextSpanIndex || nextSpanIndex === boundary) { + // might as well scroll to the top or bottom + nextSpanIndex = boundary - direction; + + // If there are hidden children, scroll to the last visible span + if (childrenAreHidden) { + let isFallbackHidden: boolean; + do { + const { isHidden, parentIDs } = isSpanHidden(spans[nextSpanIndex], childrenAreHidden, spansMap); + if (isHidden) { + parentIDs.forEach(id => childrenAreHidden.add(id)); + nextSpanIndex--; + } + isFallbackHidden = isHidden; + } while (isFallbackHidden); + } + } + 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 | TNil) { + 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); + }; + + scrollToFirstVisibleSpan = () => { + this._scrollToVisibleSpan(1, 0); + }; + + destroy() { + this._trace = undefined; + this._scroller = undefined as any; + this._accessors = undefined; + } +} diff --git a/packages/jaeger-ui-components/src/Theme.tsx b/packages/jaeger-ui-components/src/Theme.tsx new file mode 100644 index 00000000000..b3428014001 --- /dev/null +++ b/packages/jaeger-ui-components/src/Theme.tsx @@ -0,0 +1,86 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import hoistNonReactStatics from 'hoist-non-react-statics'; +import memoizeOne from 'memoize-one'; + +export type ThemeOptions = Partial; + +export type Theme = { + borderStyle: string; +}; + +export const defaultTheme: Theme = { + borderStyle: '1px solid #bbb', +}; + +const ThemeContext = React.createContext(undefined); +ThemeContext.displayName = 'ThemeContext'; + +export const ThemeProvider = ThemeContext.Provider; + +type ThemeConsumerProps = { + children: (theme: Theme) => React.ReactNode; +}; +export function ThemeConsumer(props: ThemeConsumerProps) { + return ( + + {(value: ThemeOptions | undefined) => { + const mergedTheme: Theme = value + ? { + ...defaultTheme, + ...value, + } + : defaultTheme; + return props.children(mergedTheme); + }} + + ); +} + +type WrappedWithThemeComponent = React.ComponentType> & { + wrapped: React.ComponentType; +}; + +export const withTheme = ( + Component: React.ComponentType +): WrappedWithThemeComponent => { + let WithTheme: React.ComponentType> = props => { + return ( + + {(theme: Theme) => ( + + )} + + ); + }; + + WithTheme.displayName = `WithTheme(${Component.displayName})`; + WithTheme = hoistNonReactStatics>, React.ComponentType>( + WithTheme, + Component + ); + (WithTheme as WrappedWithThemeComponent).wrapped = Component; + return WithTheme as WrappedWithThemeComponent; +}; + +export const createStyle = ReturnType>(fn: Fn) => { + return memoizeOne(fn); +}; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.js new file mode 100644 index 00000000000..08ffdd7c30d --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.js @@ -0,0 +1,244 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +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/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.tsx new file mode 100644 index 00000000000..9614b8877fd --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.tsx @@ -0,0 +1,197 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +type THeightGetter = (index: number) => number; + +/** + * 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: THeightGetter, 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: THeightGetter) { + while ((this.ys[this.lastI] == null || yValue > this.ys[this.lastI]) && this.lastI < this.dataLen - 1) { + this.calcHeights(this.lastI, heightGetter); + } + } + + /** + * 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: THeightGetter) { + 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 + * `.ys`. + */ + findFloorIndex(yValue: number, heightGetter: THeightGetter): number { + this.calcYs(yValue, heightGetter); + + let imin = 0; + let imax = this.lastI; + + if (this.ys.length < 2 || yValue < this.ys[1]) { + return 0; + } + 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 `y` and `height` for a given row. + * + * @returns {{ height: number, y: number }} + */ + getRowPosition(index: number, heightGetter: THeightGetter) { + 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. + */ + 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; + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap new file mode 100644 index 00000000000..baf54ebe67e --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` shallow tests matches a snapshot 1`] = ` +
+
+
+ + 0 + + + 1 + + + 2 + + + 3 + + + 4 + +
+
+
+`; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.js new file mode 100644 index 00000000000..e4db14fdb2f --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.js @@ -0,0 +1,243 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { mount, shallow } from 'enzyme'; + +import ListView from './index'; +import { polyfill as polyfillAnimationFrame } from '../../utils/test/requestAnimationFrame'; + +// Util to get list of all callbacks added to an event emitter by event type. +// jest adds "error" event listeners to window, this util makes it easier to +// ignore those calls. +function getListenersByType(mockFn) { + const rv = {}; + mockFn.calls.forEach(([eventType, callback]) => { + if (!rv[eventType]) { + rv[eventType] = [callback]; + } else { + rv[eventType].push(callback); + } + }); + return rv; +} + +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, + getIndexFromKey: Number, + getKeyFromIndex: String, + initialDraw: 5, + itemHeightGetter: getHeight, + itemRenderer: renderItem, + itemsWrapperClassName: 'SomeClassName', + viewBuffer: 10, + viewBufferMin: 5, + windowScroller: false, + }; + + 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', () => { + describe('accessor functions', () => { + const clientHeight = 2; + const scrollTop = 3; + + let oldRender; + let oldInitWrapper; + const initWrapperMock = jest.fn(elm => { + if (elm != null) { + // jsDom requires `defineProperties` instead of just setting the props + Object.defineProperties(elm, { + clientHeight: { + get: () => clientHeight, + }, + scrollTop: { + get: () => scrollTop, + }, + }); + } + oldInitWrapper.call(this, elm); + }); + + beforeAll(() => { + oldRender = ListView.prototype.render; + // `_initWrapper` is not on the prototype, so it needs to be mocked + // on each instance, use `render()` as a hook to do that + ListView.prototype.render = function altRender() { + if (this._initWrapper !== initWrapperMock) { + oldInitWrapper = this._initWrapper; + this._initWrapper = initWrapperMock; + } + return oldRender.call(this); + }; + }); + + afterAll(() => { + ListView.prototype.render = oldRender; + }); + + beforeEach(() => { + initWrapperMock.mockClear(); + wrapper = mount(); + instance = wrapper.instance(); + }); + + it('getViewHeight() returns the viewHeight', () => { + expect(instance.getViewHeight()).toBe(clientHeight); + }); + + it('getBottomVisibleIndex() returns a number', () => { + const n = instance.getBottomVisibleIndex(); + expect(Number.isNaN(n)).toBe(false); + expect(n).toEqual(expect.any(Number)); + }); + + it('getTopVisibleIndex() returns a number', () => { + const n = instance.getTopVisibleIndex(); + expect(Number.isNaN(n)).toBe(false); + expect(n).toEqual(expect.any(Number)); + }); + + it('getRowPosition() returns a number', () => { + const { height, y } = instance.getRowPosition(2); + expect(height).toEqual(expect.any(Number)); + expect(y).toEqual(expect.any(Number)); + }); + }); + + describe('windowScroller', () => { + let windowAddListenerSpy; + let windowRmListenerSpy; + + beforeEach(() => { + windowAddListenerSpy = jest.spyOn(window, 'addEventListener'); + windowRmListenerSpy = jest.spyOn(window, 'removeEventListener'); + const wsProps = { ...props, windowScroller: true }; + wrapper = mount(); + instance = wrapper.instance(); + }); + + afterEach(() => { + windowAddListenerSpy.mockRestore(); + }); + + it('adds the onScroll listener to the window element after the component mounts', () => { + const eventListeners = getListenersByType(windowAddListenerSpy.mock); + expect(eventListeners.scroll).toEqual([instance._onScroll]); + }); + + it('removes the onScroll listener from window when unmounting', () => { + // jest adds "error" event listeners to window, ignore those calls + let eventListeners = getListenersByType(windowRmListenerSpy.mock); + expect(eventListeners.scroll).not.toBeDefined(); + wrapper.unmount(); + eventListeners = getListenersByType(windowRmListenerSpy.mock); + expect(eventListeners.scroll).toEqual([instance._onScroll]); + }); + + it('calls _positionList when the document is scrolled', done => { + const event = new Event('scroll'); + const fn = jest.spyOn(instance, '_positionList'); + expect(instance._isScrolledOrResized).toBe(false); + window.dispatchEvent(event); + expect(instance._isScrolledOrResized).toBe(true); + window.requestAnimationFrame(() => { + expect(fn).toHaveBeenCalled(); + done(); + }); + }); + + 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/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx new file mode 100644 index 00000000000..bf1ba9154c8 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx @@ -0,0 +1,476 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; + +import Positions from './Positions'; +import { TNil } from '../../types'; + +type TWrapperProps = { + style: React.CSSProperties; + ref: (elm: HTMLDivElement) => void; + onScroll?: () => void; +}; + +/** + * @typedef + */ +type TListViewProps = { + /** + * 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: (key: string) => number; + /** + * Convert item key (string) to the index (number). ListView uses both indexes + * and keys to handle the addtion of new rows. + */ + getKeyFromIndex: (index: 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: (index: number, key: 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(itemKey, style, i, attrs) + itemRenderer: ( + itemKey: string, + style: Record, + index: number, + attributes: Record + ) => React.ReactNode; + /** + * `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; +}; + +const DEFAULT_INITIAL_DRAW = 300; + +/** + * 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 { + /** + * 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 | TNil; + /** + * HTMLElement holding the rendered items. + */ + _itemHolderElm: HTMLElement | TNil; + + static defaultProps = { + initialDraw: DEFAULT_INITIAL_DRAW, + itemsWrapperClassName: '', + windowScroller: false, + }; + + constructor(props: TListViewProps) { + super(props); + + this._yPositions = new Positions(200); + // _knownHeights is (item-key -> observed height) of list items + this._knownHeights = new Map(); + + this._startIndexDrawn = 2 ** 20; + this._endIndexDrawn = -(2 ** 20); + this._startIndex = 0; + this._endIndex = 0; + this._viewHeight = -1; + this._scrollTop = -1; + this._isScrolledOrResized = false; + + this._htmlTopOffset = -1; + this._windowScrollListenerAdded = false; + // _htmlElm is only relevant if props.windowScroller is true + this._htmlElm = document.documentElement as any; + this._wrapperElm = undefined; + this._itemHolderElm = undefined; + } + + 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); + } + } + + 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 = () => { + 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) { + /* istanbul ignore next */ + 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 = 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); + } + + /** + * Checked to see if the currently rendered items are sufficient, if not, + * force an update to trigger more items to be rendered. + */ + _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) { + this.forceUpdate(); + } + }; + + _initWrapper = (elm: HTMLElement | TNil) => { + this._wrapperElm = elm; + if (!this.props.windowScroller && elm) { + this._viewHeight = elm.clientHeight; + } + }; + + _initItemHolder = (elm: HTMLElement | TNil) => { + 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 = () => { + 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] as 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.clientHeight; + 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 = (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 = DEFAULT_INITIAL_DRAW, + itemRenderer, + viewBuffer, + viewBufferMin, + } = this.props; + const heightGetter = this._getHeight; + const items = []; + let start; + let end; + + this._yPositions.profileData(dataLength); + + 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.calcHeights(end, heightGetter, start || -1); + this._startIndexDrawn = start; + this._endIndexDrawn = end; + + items.length = end - start + 1; + for (let i = start; i <= end; i++) { + const { y: top, height } = this._yPositions.getRowPosition(i, heightGetter); + const style = { + height, + top, + position: 'absolute', + }; + const itemKey = getKeyFromIndex(i); + const attrs = { 'data-item-key': itemKey }; + items.push(itemRenderer(itemKey, style, i, attrs)); + } + const wrapperProps: TWrapperProps = { + style: { position: 'relative' }, + ref: this._initWrapper, + }; + if (!this.props.windowScroller) { + wrapperProps.onScroll = this._onScroll; + wrapperProps.style.height = '100%'; + wrapperProps.style.overflowY = 'auto'; + } + const scrollerStyle = { + position: 'relative' as 'relative', + height: this._yPositions.getEstimatedHeight(), + }; + return ( +
+
+
+ {items} +
+
+
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.test.js new file mode 100644 index 00000000000..c62408f8c95 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.test.js @@ -0,0 +1,91 @@ +// Copyright (c) 2019 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import ReferencesButton, { getStyles } from './ReferencesButton'; +import transformTraceData from '../model/transform-trace-data'; +import traceGenerator from '../demo/trace-generators'; +import ReferenceLink from '../url/ReferenceLink'; +import { UIDropdown, UIMenuItem, UITooltip } from '../uiElementsContext'; + +describe(ReferencesButton, () => { + const trace = transformTraceData(traceGenerator.trace({ numberOfSpans: 10 })); + const oneReference = trace.spans[1].references; + + const moreReferences = oneReference.slice(); + const externalSpanID = 'extSpan'; + + moreReferences.push( + { + refType: 'CHILD_OF', + traceID: trace.traceID, + spanID: trace.spans[2].spanID, + span: trace.spans[2], + }, + { + refType: 'CHILD_OF', + traceID: 'otherTrace', + spanID: externalSpanID, + } + ); + + const baseProps = { + focusSpan: () => {}, + }; + + it('renders single reference', () => { + const props = { ...baseProps, references: oneReference }; + const wrapper = shallow(); + const dropdown = wrapper.find(UIDropdown); + const refLink = wrapper.find(ReferenceLink); + const tooltip = wrapper.find(UITooltip); + const styles = getStyles(); + + expect(dropdown.length).toBe(0); + expect(refLink.length).toBe(1); + expect(refLink.prop('reference')).toBe(oneReference[0]); + expect(refLink.first().props().className).toBe(styles.MultiParent); + expect(tooltip.length).toBe(1); + expect(tooltip.prop('title')).toBe(props.tooltipText); + }); + + it('renders multiple references', () => { + const props = { ...baseProps, references: moreReferences }; + const wrapper = shallow(); + const dropdown = wrapper.find(UIDropdown); + expect(dropdown.length).toBe(1); + // We have some wrappers here that dynamically inject specific component so we need to traverse a bit + // here + const menuInstance = shallow( + shallow(dropdown.first().props().overlay).prop('children')({ + // eslint-disable-next-line react/prop-types + Menu: ({ children }) =>
{children}
, + }) + ); + const submenuItems = menuInstance.find(UIMenuItem); + expect(submenuItems.length).toBe(3); + submenuItems.forEach((submenuItem, i) => { + expect(submenuItem.find(ReferenceLink).prop('reference')).toBe(moreReferences[i]); + }); + expect( + submenuItems + .at(2) + .find(ReferenceLink) + .childAt(0) + .text() + ).toBe(`(another trace) - ${moreReferences[2].spanID}`); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.tsx new file mode 100644 index 00000000000..b3d09ba0aee --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.tsx @@ -0,0 +1,105 @@ +// Copyright (c) 2019 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { css } from 'emotion'; +import NewWindowIcon from '../common/NewWindowIcon'; +import { SpanReference } from '../types/trace'; +import { UITooltip, UIDropdown, UIMenuItem, UIMenu, TooltipPlacement } from '../uiElementsContext'; + +import ReferenceLink from '../url/ReferenceLink'; +import { createStyle } from '../Theme'; + +export const getStyles = createStyle(() => { + return { + MultiParent: css` + padding: 0 5px; + color: #000; + & ~ & { + margin-left: 5px; + } + `, + TraceRefLink: css` + display: flex; + justify-content: space-between; + `, + NewWindowIcon: css` + margin: 0.2em 0 0; + `, + tooltip: css` + max-width: none; + `, + }; +}); + +type TReferencesButtonProps = { + references: SpanReference[]; + children: React.ReactNode; + tooltipText: string; + focusSpan: (spanID: string) => void; +}; + +export default class ReferencesButton extends React.PureComponent { + referencesList = (references: SpanReference[]) => { + const styles = getStyles(); + return ( + + {references.map(ref => { + const { span, spanID } = ref; + return ( + + + {span + ? `${span.process.serviceName}:${span.operationName} - ${ref.spanID}` + : `(another trace) - ${ref.spanID}`} + {!span && } + + + ); + })} + + ); + }; + + render() { + const { references, children, tooltipText, focusSpan } = this.props; + const styles = getStyles(); + + const tooltipProps = { + arrowPointAtCenter: true, + mouseLeaveDelay: 0.5, + placement: 'bottom' as TooltipPlacement, + title: tooltipText, + overlayClassName: styles.tooltip, + }; + + if (references.length > 1) { + return ( + + + {children} + + + ); + } + const ref = references[0]; + return ( + + + {children} + + + ); + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js new file mode 100644 index 00000000000..1223d387a07 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js @@ -0,0 +1,94 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { mount } from 'enzyme'; +import UIElementsContext, { UIPopover } from '../uiElementsContext'; + +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, + getViewedBounds: s => { + // Log entries + if (s === 10) { + return { start: 0.1, end: 0.1 }; + } + if (s === 20) { + return { start: 0.2, end: 0.2 }; + } + return { error: 'error' }; + }, + rpc: { + viewStart: 0.25, + viewEnd: 0.75, + color: '#000', + }, + tracestartTime: 0, + span: { + logs: [ + { + timestamp: 10, + fields: [{ key: 'message', value: 'oh the log message' }, { key: 'something', value: 'else' }], + }, + { + timestamp: 10, + fields: [ + { key: 'message', value: 'oh the second log message' }, + { key: 'something', value: 'different' }, + ], + }, + { + timestamp: 20, + fields: [{ key: 'message', value: 'oh the next log message' }, { key: 'more', value: 'stuff' }], + }, + ], + }, + }; + + it('renders without exploding', () => { + const wrapper = mount( + '' }}> + + + ); + expect(wrapper).toBeDefined(); + const { onMouseOver, onMouseOut } = wrapper.find('[data-test-id="SpanBar--wrapper"]').props(); + const labelElm = wrapper.find('[data-test-id="SpanBar--label"]'); + expect(labelElm.text()).toBe(shortLabel); + onMouseOver(); + expect(labelElm.text()).toBe(longLabel); + onMouseOut(); + expect(labelElm.text()).toBe(shortLabel); + }); + + it('log markers count', () => { + // 3 log entries, two grouped together with the same timestamp + const wrapper = mount( + '' }}> + + + ); + expect(wrapper.find(UIPopover).length).toEqual(2); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx new file mode 100644 index 00000000000..1d58cc22957 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx @@ -0,0 +1,228 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import _groupBy from 'lodash/groupBy'; +import { onlyUpdateForKeys, compose, withState, withProps } from 'recompose'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import AccordianLogs from './SpanDetail/AccordianLogs'; + +import { ViewedBoundsFunctionType } from './utils'; +import { TNil } from '../types'; +import { Span } from '../types/trace'; +import { UIPopover } from '../uiElementsContext'; +import { createStyle } from '../Theme'; + +const getStyles = createStyle(() => { + return { + wrapper: css` + label: wrapper; + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + overflow: hidden; + z-index: 0; + `, + bar: css` + label: bar; + border-radius: 3px; + min-width: 2px; + position: absolute; + height: 36%; + top: 32%; + `, + rpc: css` + label: rpc; + position: absolute; + top: 35%; + bottom: 35%; + z-index: 1; + `, + label: css` + label: label; + color: #aaa; + font-size: 12px; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1em; + white-space: nowrap; + padding: 0 0.5em; + position: absolute; + `, + logMarker: css` + label: logMarker; + background-color: rgba(0, 0, 0, 0.5); + cursor: pointer; + height: 60%; + min-width: 1px; + position: absolute; + top: 20%; + &:hover { + background-color: #000; + } + &::before, + &::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: 0; + border: 1px solid transparent; + } + &::after { + left: 0; + } + `, + logHint: css` + label: logHint; + pointer-events: none; + // TODO won't work with different UI elements injected + & .ant-popover-inner-content { + padding: 0.25rem; + } + `, + }; +}); + +type TCommonProps = { + color: string; + // onClick: (evt: React.MouseEvent) => void; + onClick?: (evt: React.MouseEvent) => void; + viewEnd: number; + viewStart: number; + getViewedBounds: ViewedBoundsFunctionType; + rpc: + | { + viewStart: number; + viewEnd: number; + color: string; + } + | TNil; + traceStartTime: number; + span: Span; + className?: string; + labelClassName?: string; +}; + +type TInnerProps = { + label: string; + setLongLabel: () => void; + setShortLabel: () => void; +} & TCommonProps; + +type TOuterProps = { + longLabel: string; + shortLabel: string; +} & TCommonProps; + +function toPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; +} + +function SpanBar(props: TInnerProps) { + const { + viewEnd, + viewStart, + getViewedBounds, + color, + label, + onClick, + setLongLabel, + setShortLabel, + rpc, + traceStartTime, + span, + className, + labelClassName, + } = props; + // group logs based on timestamps + const logGroups = _groupBy(span.logs, log => { + const posPercent = getViewedBounds(log.timestamp, log.timestamp).start; + // round to the nearest 0.2% + return toPercent(Math.round(posPercent * 500) / 500); + }); + const styles = getStyles(); + + return ( +
+
+
+ {label} +
+
+
+ {Object.keys(logGroups).map(positionKey => ( + + } + > +
+ + ))} +
+ {rpc && ( +
+ )} +
+ ); +} + +export default compose( + withState('label', 'setLabel', (props: { shortLabel: string }) => props.shortLabel), + withProps( + ({ + setLabel, + shortLabel, + longLabel, + }: { + setLabel: (label: string) => void; + shortLabel: string; + longLabel: string; + }) => ({ + setLongLabel: () => setLabel(longLabel), + setShortLabel: () => setLabel(shortLabel), + }) + ), + onlyUpdateForKeys(['label', 'rpc', 'viewStart', 'viewEnd']) +)(SpanBar); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js new file mode 100644 index 00000000000..64bc3807f35 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js @@ -0,0 +1,165 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { mount, shallow } from 'enzyme'; + +import SpanBarRow from './SpanBarRow'; +import SpanTreeOffset from './SpanTreeOffset'; +import ReferencesButton from './ReferencesButton'; + +jest.mock('./SpanTreeOffset'); + +describe('', () => { + const spanID = 'some-id'; + const props = { + className: 'a-class-name', + color: 'color-a', + columnDivision: '0.5', + isChildrenExpanded: true, + isDetailExpanded: false, + isFilteredOut: false, + onDetailToggled: jest.fn(), + onChildrenToggled: jest.fn(), + operationName: 'op-name', + numTicks: 5, + rpc: { + viewStart: 0.25, + viewEnd: 0.75, + color: 'color-b', + operationName: 'rpc-op-name', + serviceName: 'rpc-service-name', + }, + showErrorIcon: false, + getViewedBounds: () => ({ start: 0, end: 1 }), + span: { + duration: 'test-duration', + hasChildren: true, + process: { + serviceName: 'service-name', + }, + spanID, + logs: [], + }, + }; + + let wrapper; + + beforeEach(() => { + props.onDetailToggled.mockReset(); + props.onChildrenToggled.mockReset(); + wrapper = mount(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + }); + + it('escalates detail toggling', () => { + const { onDetailToggled } = props; + expect(onDetailToggled.mock.calls.length).toBe(0); + wrapper.find('div[data-test-id="span-view"]').prop('onClick')(); + expect(onDetailToggled.mock.calls).toEqual([[spanID]]); + }); + + it('escalates children toggling', () => { + const { onChildrenToggled } = props; + expect(onChildrenToggled.mock.calls.length).toBe(0); + wrapper.find(SpanTreeOffset).prop('onClick')(); + expect(onChildrenToggled.mock.calls).toEqual([[spanID]]); + }); + + it('render references button', () => { + const span = Object.assign( + { + references: [ + { + refType: 'CHILD_OF', + traceID: 'trace1', + spanID: 'span0', + span: { + spanID: 'span0', + }, + }, + { + refType: 'CHILD_OF', + traceID: 'otherTrace', + spanID: 'span1', + span: { + spanID: 'span1', + }, + }, + ], + }, + props.span + ); + + const spanRow = shallow(); + const refButton = spanRow.find(ReferencesButton); + expect(refButton.length).toEqual(1); + expect(refButton.at(0).props().tooltipText).toEqual('Contains multiple references'); + }); + + it('render referenced to by single span', () => { + const span = Object.assign( + { + subsidiarilyReferencedBy: [ + { + refType: 'CHILD_OF', + traceID: 'trace1', + spanID: 'span0', + span: { + spanID: 'span0', + }, + }, + ], + }, + props.span + ); + const spanRow = shallow(); + const refButton = spanRow.find(ReferencesButton); + expect(refButton.length).toEqual(1); + expect(refButton.at(0).props().tooltipText).toEqual('This span is referenced by another span'); + }); + + it('render referenced to by multiple span', () => { + const span = Object.assign( + { + subsidiarilyReferencedBy: [ + { + refType: 'CHILD_OF', + traceID: 'trace1', + spanID: 'span0', + span: { + spanID: 'span0', + }, + }, + { + refType: 'CHILD_OF', + traceID: 'trace1', + spanID: 'span1', + span: { + spanID: 'span1', + }, + }, + ], + }, + props.span + ); + const spanRow = shallow(); + const refButton = spanRow.find(ReferencesButton); + expect(refButton.length).toEqual(1); + expect(refButton.at(0).props().tooltipText).toEqual('This span is referenced by multiple other spans'); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx new file mode 100644 index 00000000000..c1e936ddc1b --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx @@ -0,0 +1,461 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import IoAlert from 'react-icons/lib/io/alert'; +import IoArrowRightA from 'react-icons/lib/io/arrow-right-a'; +import IoNetwork from 'react-icons/lib/io/network'; +import MdFileUpload from 'react-icons/lib/md/file-upload'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import ReferencesButton from './ReferencesButton'; +import TimelineRow from './TimelineRow'; +import { formatDuration, ViewedBoundsFunctionType } from './utils'; +import SpanTreeOffset from './SpanTreeOffset'; +import SpanBar from './SpanBar'; +import Ticks from './Ticks'; + +import { TNil } from '../types'; +import { Span } from '../types/trace'; +import { createStyle } from '../Theme'; + +const getStyles = createStyle(() => { + const spanBar = css` + label: spanBar; + `; + const spanBarLabel = css` + label: spanBarLabel; + `; + const nameWrapper = css` + label: nameWrapper; + background: #f8f8f8; + line-height: 27px; + overflow: hidden; + display: flex; + &:hover { + border-right: 1px solid #bbb; + float: left; + min-width: calc(100% + 1px); + overflow: visible; + } + `; + + const nameWrapperMatchingFilter = css` + label: nameWrapperMatchingFilter; + background-color: #fffce4; + `; + + const endpointName = css` + label: endpointName; + color: #808080; + `; + + const view = css` + label: view; + position: relative; + `; + + const viewExpanded = css` + label: viewExpanded; + background: #f8f8f8; + outline: 1px solid #ddd; + `; + + const viewExpandedAndMatchingFilter = css` + label: viewExpandedAndMatchingFilter; + background: #fff3d7; + outline: 1px solid #ddd; + `; + + const nameColumn = css` + label: nameColumn; + position: relative; + white-space: nowrap; + z-index: 1; + &:hover { + z-index: 1; + } + `; + + return { + spanBar, + spanBarLabel, + nameWrapper, + nameWrapperMatchingFilter, + nameColumn, + endpointName, + view, + viewExpanded, + viewExpandedAndMatchingFilter, + row: css` + label: row; + &:hover .${spanBar} { + opacity: 1; + } + &:hover .${spanBarLabel} { + color: #000; + } + &:hover .${nameWrapper} { + background: #f8f8f8; + background: linear-gradient(90deg, #fafafa, #f8f8f8 75%, #eee); + } + &:hover .${view} { + background-color: #f5f5f5; + outline: 1px solid #ddd; + } + `, + rowClippingLeft: css` + label: rowClippingLeft; + & .${nameColumn}::before { + content: ' '; + height: 100%; + position: absolute; + width: 6px; + background-image: linear-gradient(to right, rgba(25, 25, 25, 0.25), rgba(32, 32, 32, 0)); + left: 100%; + z-index: -1; + } + `, + rowClippingRight: css` + label: rowClippingRight; + & .${view}::before { + content: ' '; + height: 100%; + position: absolute; + width: 6px; + background-image: linear-gradient(to left, rgba(25, 25, 25, 0.25), rgba(32, 32, 32, 0)); + right: 0%; + z-index: 1; + } + `, + rowExpanded: css` + label: rowExpanded; + & .${spanBar} { + opacity: 1; + } + & .${spanBarLabel} { + color: #000; + } + & .${nameWrapper}, &:hover .${nameWrapper} { + background: #f0f0f0; + box-shadow: 0 1px 0 #ddd; + } + & .${nameWrapperMatchingFilter} { + background: #fff3d7; + } + &:hover .${view} { + background: #eee; + } + `, + rowMatchingFilter: css` + label: rowMatchingFilter; + background-color: #fffce4; + &:hover .${nameWrapper} { + background: linear-gradient(90deg, #fff5e1, #fff5e1 75%, #ffe6c9); + } + &:hover .${view} { + background-color: #fff3d7; + outline: 1px solid #ddd; + } + `, + + rowExpandedAndMatchingFilter: css` + label: rowExpandedAndMatchingFilter; + &:hover .${view} { + background: #ffeccf; + } + `, + + name: css` + label: name; + color: #000; + cursor: pointer; + flex: 1 1 auto; + outline: none; + overflow: hidden; + padding-left: 4px; + padding-right: 0.25em; + position: relative; + text-overflow: ellipsis; + &::before { + content: ' '; + position: absolute; + top: 4px; + bottom: 4px; + left: 0; + border-left: 4px solid; + border-left-color: inherit; + } + + /* This is so the hit area of the span-name extends the rest of the width of the span-name column */ + &::after { + background: transparent; + bottom: 0; + content: ' '; + left: 0; + position: absolute; + top: 0; + width: 1000px; + } + &:focus { + text-decoration: none; + } + &:hover > .${endpointName} { + color: #000; + } + `, + nameDetailExpanded: css` + label: nameDetailExpanded; + &::before { + bottom: 0; + } + `, + svcName: css` + label: svcName; + padding: 0 0.25rem 0 0.5rem; + font-size: 1.05em; + `, + svcNameChildrenCollapsed: css` + label: svcNameChildrenCollapsed; + font-weight: bold; + font-style: italic; + `, + errorIcon: css` + label: errorIcon; + background: #db2828; + border-radius: 6.5px; + color: #fff; + font-size: 0.85em; + margin-right: 0.25rem; + padding: 1px; + `, + rpcColorMarker: css` + label: rpcColorMarker; + border-radius: 6.5px; + display: inline-block; + font-size: 0.85em; + height: 1em; + margin-right: 0.25rem; + padding: 1px; + width: 1em; + vertical-align: middle; + `, + labelRight: css` + label: labelRight; + left: 100%; + `, + labelLeft: css` + label: labelLeft; + right: 100%; + `, + }; +}); + +type SpanBarRowProps = { + className?: string; + color: string; + columnDivision: number; + isChildrenExpanded: boolean; + isDetailExpanded: boolean; + isMatchingFilter: boolean; + onDetailToggled: (spanID: string) => void; + onChildrenToggled: (spanID: string) => void; + numTicks: number; + rpc?: + | { + viewStart: number; + viewEnd: number; + color: string; + operationName: string; + serviceName: string; + } + | TNil; + showErrorIcon: boolean; + getViewedBounds: ViewedBoundsFunctionType; + traceStartTime: number; + span: Span; + focusSpan: (spanID: string) => void; + hoverIndentGuideIds: Set; + addHoverIndentGuideId: (spanID: string) => void; + removeHoverIndentGuideId: (spanID: string) => void; + clippingLeft?: boolean; + clippingRight?: boolean; +}; + +/** + * 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 { + static defaultProps: Partial = { + className: '', + rpc: null, + }; + + _detailToggle = () => { + this.props.onDetailToggled(this.props.span.spanID); + }; + + _childrenToggle = () => { + this.props.onChildrenToggled(this.props.span.spanID); + }; + + render() { + const { + className, + color, + columnDivision, + isChildrenExpanded, + isDetailExpanded, + isMatchingFilter, + numTicks, + rpc, + showErrorIcon, + getViewedBounds, + traceStartTime, + span, + focusSpan, + hoverIndentGuideIds, + addHoverIndentGuideId, + removeHoverIndentGuideId, + clippingLeft, + clippingRight, + } = this.props; + const { + duration, + hasChildren: isParent, + operationName, + process: { serviceName }, + } = span; + const label = formatDuration(duration); + const viewBounds = getViewedBounds(span.startTime, span.startTime + span.duration); + const viewStart = viewBounds.start; + const viewEnd = viewBounds.end; + const styles = getStyles(); + + const labelDetail = `${serviceName}::${operationName}`; + let longLabel; + let hintClassName; + if (viewStart > 1 - viewEnd) { + longLabel = `${labelDetail} | ${label}`; + hintClassName = styles.labelLeft; + } else { + longLabel = `${label} | ${labelDetail}`; + hintClassName = styles.labelRight; + } + + return ( + + +
+ + + + {showErrorIcon && } + {serviceName}{' '} + {rpc && ( + + + {rpc.serviceName} + + )} + + {rpc ? rpc.operationName : operationName} + + {span.references && span.references.length > 1 && ( + + + + )} + {span.subsidiarilyReferencedBy && span.subsidiarilyReferencedBy.length > 0 && ( + + + + )} +
+
+ + + + +
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.markers.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.markers.tsx new file mode 100644 index 00000000000..b74c456a6c0 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.markers.tsx @@ -0,0 +1,15 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const LABEL = 'label'; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.js new file mode 100644 index 00000000000..77aa9739415 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.js @@ -0,0 +1,94 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import AccordianKeyValues, { KeyValuesSummary } from './AccordianKeyValues'; +import * as markers from './AccordianKeyValues.markers'; +import KeyValuesTable from './KeyValuesTable'; + +const tags = [{ key: 'span.kind', value: 'client' }, { key: 'omg', value: 'mos-def' }]; + +describe('', () => { + let wrapper; + + const props = { data: tags }; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + }); + + it('returns `null` when props.data is empty', () => { + wrapper.setProps({ data: null }); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('generates a list from `data`', () => { + expect(wrapper.find('li').length).toBe(tags.length); + }); + + it('renders the data as text', () => { + const texts = wrapper.find('li').map(node => node.text()); + const expectedTexts = tags.map(tag => `${tag.key}=${tag.value}`); + expect(texts).toEqual(expectedTexts); + }); +}); + +describe('', () => { + let wrapper; + + const props = { + compact: false, + data: tags, + highContrast: false, + isOpen: false, + label: 'le-label', + onToggle: jest.fn(), + }; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + expect(wrapper.exists()).toBe(true); + }); + + it('renders the label', () => { + const header = wrapper.find(`[data-test="${markers.LABEL}"]`); + expect(header.length).toBe(1); + expect(header.text()).toBe(`${props.label}:`); + }); + + it('renders the summary instead of the table when it is not expanded', () => { + const summary = wrapper.find('[data-test-id="AccordianKeyValues--header"]').find(KeyValuesSummary); + expect(summary.length).toBe(1); + expect(summary.prop('data')).toBe(tags); + expect(wrapper.find(KeyValuesTable).length).toBe(0); + }); + + it('renders the table instead of the summarywhen it is expanded', () => { + wrapper.setProps({ isOpen: true }); + expect(wrapper.find(KeyValuesSummary).length).toBe(0); + const table = wrapper.find(KeyValuesTable); + expect(table.length).toBe(1); + expect(table.prop('data')).toBe(tags); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx new file mode 100644 index 00000000000..02208ad9201 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx @@ -0,0 +1,156 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; +import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import * as markers from './AccordianKeyValues.markers'; +import KeyValuesTable from './KeyValuesTable'; +import { TNil } from '../../types'; +import { KeyValuePair, Link } from '../../types/trace'; +import { createStyle } from '../../Theme'; +import { uAlignIcon, uTxEllipsis } from '../../uberUtilityStyles'; + +export const getStyles = createStyle(() => { + return { + header: css` + cursor: pointer; + overflow: hidden; + padding: 0.25em 0.1em; + text-overflow: ellipsis; + white-space: nowrap; + &:hover { + background: #e8e8e8; + } + `, + headerEmpty: css` + background: none; + cursor: initial; + `, + headerHighContrast: css` + &:hover { + background: #ddd; + } + `, + emptyIcon: css` + color: #aaa; + `, + summary: css` + display: inline; + list-style: none; + padding: 0; + `, + summaryItem: css` + display: inline; + margin-left: 0.7em; + padding-right: 0.5rem; + border-right: 1px solid #ddd; + &:last-child { + padding-right: 0; + border-right: none; + } + `, + summaryLabel: css` + color: #777; + `, + summaryDelim: css` + color: #bbb; + padding: 0 0.2em; + `, + }; +}); + +type AccordianKeyValuesProps = { + className?: string | TNil; + data: KeyValuePair[]; + highContrast?: boolean; + interactive?: boolean; + isOpen: boolean; + label: string; + linksGetter: ((pairs: KeyValuePair[], index: number) => Link[]) | TNil; + onToggle?: null | (() => void); +}; + +// export for tests +export function KeyValuesSummary(props: { data?: KeyValuePair[] }) { + const { data } = props; + if (!Array.isArray(data) || !data.length) { + return null; + } + const styles = getStyles(); + return ( +
    + {data.map((item, i) => ( + // `i` is necessary in the key because item.key can repeat +
  • + {item.key} + = + {String(item.value)} +
  • + ))} +
+ ); +} + +KeyValuesSummary.defaultProps = { + data: null, +}; + +export default function AccordianKeyValues(props: AccordianKeyValuesProps) { + const { className, data, highContrast, interactive, isOpen, label, linksGetter, onToggle } = props; + const isEmpty = !Array.isArray(data) || !data.length; + const styles = getStyles(); + const iconCls = cx(uAlignIcon, { [styles.emptyIcon]: isEmpty }); + let arrow: React.ReactNode | null = null; + let headerProps: {} | null = null; + if (interactive) { + arrow = isOpen ? : ; + headerProps = { + 'aria-checked': isOpen, + onClick: isEmpty ? null : onToggle, + role: 'switch', + }; + } + + return ( +
+
+ {arrow} + + {label} + {isOpen || ':'} + + {!isOpen && } +
+ {isOpen && } +
+ ); +} + +AccordianKeyValues.defaultProps = { + className: null, + highContrast: false, + interactive: true, + onToggle: null, +}; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.test.js new file mode 100644 index 00000000000..8140f68d06b --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.test.js @@ -0,0 +1,82 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import AccordianKeyValues from './AccordianKeyValues'; +import AccordianLogs from './AccordianLogs'; + +describe('', () => { + let wrapper; + + const logs = [ + { + timestamp: 10, + fields: [{ key: 'message', value: 'oh the log message' }, { key: 'something', value: 'else' }], + }, + { + timestamp: 20, + fields: [{ key: 'message', value: 'oh the next log message' }, { key: 'more', value: 'stuff' }], + }, + ]; + const props = { + logs, + isOpen: false, + onItemToggle: jest.fn(), + onToggle: () => {}, + openedItems: new Set([logs[1]]), + timestamp: 5, + }; + + beforeEach(() => { + props.onItemToggle.mockReset(); + wrapper = shallow(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + }); + + it('shows the number of log entries', () => { + const regex = new RegExp(`Logs \\(${logs.length}\\)`); + expect(wrapper.find('a').text()).toMatch(regex); + }); + + it('hides log entries when not expanded', () => { + expect(wrapper.find(AccordianKeyValues).exists()).toBe(false); + }); + + it('shows log entries when expanded', () => { + expect(wrapper.find(AccordianKeyValues).exists()).toBe(false); + wrapper.setProps({ isOpen: true }); + const logViews = wrapper.find(AccordianKeyValues); + expect(logViews.length).toBe(logs.length); + + logViews.forEach((node, i) => { + const log = logs[i]; + expect(node.prop('data')).toBe(log.fields); + node.simulate('toggle'); + expect(props.onItemToggle).toHaveBeenLastCalledWith(log); + }); + }); + + it('propagates isOpen to log items correctly', () => { + wrapper.setProps({ isOpen: true }); + const logViews = wrapper.find(AccordianKeyValues); + logViews.forEach((node, i) => { + expect(node.prop('isOpen')).toBe(props.openedItems.has(logs[i])); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx new file mode 100644 index 00000000000..01141201849 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx @@ -0,0 +1,116 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import _sortBy from 'lodash/sortBy'; +import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; +import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; +import { css } from 'emotion'; + +import AccordianKeyValues from './AccordianKeyValues'; +import { formatDuration } from '../utils'; +import { TNil } from '../../types'; +import { Log, KeyValuePair, Link } from '../../types/trace'; +import { createStyle } from '../../Theme'; +import { uAlignIcon, ubMb1 } from '../../uberUtilityStyles'; + +const getStyles = createStyle(() => { + return { + AccordianLogs: css` + border: 1px solid #d8d8d8; + position: relative; + margin-bottom: 0.25rem; + `, + header: css` + background: #e4e4e4; + color: inherit; + display: block; + padding: 0.25rem 0.5rem; + &:hover { + background: #dadada; + } + `, + content: css` + background: #f0f0f0; + border-top: 1px solid #d8d8d8; + padding: 0.5rem 0.5rem 0.25rem 0.5rem; + `, + footer: css` + color: #999; + `, + }; +}); + +type AccordianLogsProps = { + interactive?: boolean; + isOpen: boolean; + linksGetter: ((pairs: KeyValuePair[], index: number) => Link[]) | TNil; + logs: Log[]; + onItemToggle?: (log: Log) => void; + onToggle?: () => void; + openedItems?: Set; + timestamp: number; +}; + +export default function AccordianLogs(props: AccordianLogsProps) { + const { interactive, isOpen, linksGetter, logs, openedItems, onItemToggle, onToggle, timestamp } = props; + let arrow: React.ReactNode | null = null; + let HeaderComponent: 'span' | 'a' = 'span'; + let headerProps: {} | null = null; + if (interactive) { + arrow = isOpen ? : ; + HeaderComponent = 'a'; + headerProps = { + 'aria-checked': isOpen, + onClick: onToggle, + role: 'switch', + }; + } + + const styles = getStyles(); + return ( +
+ + {arrow} Logs ({logs.length}) + + {isOpen && ( +
+ {_sortBy(logs, 'timestamp').map((log, i) => ( + onItemToggle(log) : null} + /> + ))} + Log timestamps are relative to the start time of the full trace. +
+ )} +
+ ); +} + +AccordianLogs.defaultProps = { + interactive: true, + linksGetter: undefined, + onItemToggle: undefined, + onToggle: undefined, + openedItems: undefined, +}; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.js new file mode 100644 index 00000000000..774b0d57a0d --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.js @@ -0,0 +1,111 @@ +// Copyright (c) 2019 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; +import AccordianReferences, { References } from './AccordianReferences'; +import ReferenceLink from '../../url/ReferenceLink'; + +const traceID = 'trace1'; +const references = [ + { + refType: 'CHILD_OF', + span: { + spanID: 'span1', + traceID, + operationName: 'op1', + process: { + serviceName: 'service1', + }, + }, + spanID: 'span1', + traceID, + }, + { + refType: 'CHILD_OF', + span: { + spanID: 'span3', + traceID, + operationName: 'op2', + process: { + serviceName: 'service2', + }, + }, + spanID: 'span3', + traceID, + }, + { + refType: 'CHILD_OF', + spanID: 'span5', + traceID: 'trace2', + }, +]; + +describe('', () => { + let wrapper; + + const props = { + compact: false, + data: references, + highContrast: false, + isOpen: false, + onToggle: jest.fn(), + focusSpan: jest.fn(), + }; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + expect(wrapper.exists()).toBe(true); + }); + + it('renders the content when it is expanded', () => { + wrapper.setProps({ isOpen: true }); + const content = wrapper.find(References); + expect(content.length).toBe(1); + expect(content.prop('data')).toBe(references); + }); +}); + +describe('', () => { + let wrapper; + + const props = { + data: references, + focusSpan: jest.fn(), + }; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('render references list', () => { + const refLinks = wrapper.find(ReferenceLink); + expect(refLinks.length).toBe(references.length); + refLinks.forEach((refLink, i) => { + const span = references[i].span; + const serviceName = refLink.find('span.span-svc-name').text(); + if (span && span.traceID === traceID) { + const endpointName = refLink.find('small.endpoint-name').text(); + expect(serviceName).toBe(span.process.serviceName); + expect(endpointName).toBe(span.operationName); + } else { + expect(serviceName).toBe('< span in another trace >'); + } + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx new file mode 100644 index 00000000000..12c8ba91652 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx @@ -0,0 +1,155 @@ +// Copyright (c) 2019 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; +import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; +import { SpanReference } from '../../types/trace'; +import ReferenceLink from '../../url/ReferenceLink'; + +import { createStyle } from '../../Theme'; +import { uAlignIcon } from '../../uberUtilityStyles'; + +const getStyles = createStyle(() => { + return { + ReferencesList: css` + background: #fff; + border: 1px solid #ddd; + margin-bottom: 0.7em; + max-height: 450px; + overflow: auto; + `, + list: css` + width: 100%; + list-style: none; + padding: 0; + margin: 0; + background: #fff; + `, + itemContent: css` + padding: 0.25rem 0.5rem; + display: flex; + width: 100%; + justify-content: space-between; + `, + item: css` + &:nth-child(2n) { + background: #f5f5f5; + } + `, + debugInfo: css` + letter-spacing: 0.25px; + margin: 0.5em 0 0; + `, + debugLabel: css` + margin: 0 5px 0 5px; + &::before { + color: #bbb; + content: attr(data-label); + } + `, + }; +}); + +type AccordianReferencesProps = { + data: SpanReference[]; + highContrast?: boolean; + interactive?: boolean; + isOpen: boolean; + onToggle?: null | (() => void); + focusSpan: (uiFind: string) => void; +}; + +type ReferenceItemProps = { + data: SpanReference[]; + focusSpan: (uiFind: string) => void; +}; + +// export for test +export function References(props: ReferenceItemProps) { + const { data, focusSpan } = props; + const styles = getStyles(); + + return ( +
+
    + {data.map(reference => { + return ( +
  • + + + {reference.span ? ( + + {reference.span.process.serviceName} + {reference.span.operationName} + + ) : ( + < span in another trace > + )} + + + {reference.refType} + + + {reference.spanID} + + + + +
  • + ); + })} +
+
+ ); +} + +export default class AccordianReferences extends React.PureComponent { + static defaultProps: Partial = { + highContrast: false, + interactive: true, + onToggle: null, + }; + + render() { + const { data, interactive, isOpen, onToggle, focusSpan } = this.props; + const isEmpty = !Array.isArray(data) || !data.length; + const iconCls = uAlignIcon; + let arrow: React.ReactNode | null = null; + let headerProps: {} | null = null; + if (interactive) { + arrow = isOpen ? : ; + headerProps = { + 'aria-checked': isOpen, + onClick: isEmpty ? null : onToggle, + role: 'switch', + }; + } + return ( +
+
+ {arrow} + + References + {' '} + ({data.length}) +
+ {isOpen && } +
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.test.js new file mode 100644 index 00000000000..fccb6c4988d --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.test.js @@ -0,0 +1,55 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; +import AccordianText from './AccordianText'; +import TextList from './TextList'; + +const warnings = ['Duplicated tag', 'Duplicated spanId']; + +describe('', () => { + let wrapper; + + const props = { + compact: false, + data: warnings, + highContrast: false, + isOpen: false, + label: 'le-label', + onToggle: jest.fn(), + }; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + expect(wrapper.exists()).toBe(true); + }); + + it('renders the label', () => { + const header = wrapper.find(`[data-test-id="AccordianText--header"] > strong`); + expect(header.length).toBe(1); + expect(header.text()).toBe(props.label); + }); + + it('renders the content when it is expanded', () => { + wrapper.setProps({ isOpen: true }); + const content = wrapper.find(TextList); + expect(content.length).toBe(1); + expect(content.prop('data')).toBe(warnings); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.tsx new file mode 100644 index 00000000000..c5413dbaebd --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; +import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; +import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; +import TextList from './TextList'; +import { TNil } from '../../types'; +import { getStyles as getAccordianKeyValuesStyles } from './AccordianKeyValues'; +import { createStyle } from '../../Theme'; +import { uAlignIcon } from '../../uberUtilityStyles'; + +const getStyles = createStyle(() => { + return { + header: css` + cursor: pointer; + overflow: hidden; + padding: 0.25em 0.1em; + text-overflow: ellipsis; + white-space: nowrap; + &:hover { + background: #e8e8e8; + } + `, + }; +}); + +type AccordianTextProps = { + className?: string | TNil; + data: string[]; + headerClassName?: string | TNil; + highContrast?: boolean; + interactive?: boolean; + isOpen: boolean; + label: React.ReactNode; + onToggle?: null | (() => void); +}; + +export default function AccordianText(props: AccordianTextProps) { + const { className, data, headerClassName, interactive, isOpen, label, onToggle } = props; + const isEmpty = !Array.isArray(data) || !data.length; + const accordianKeyValuesStyles = getAccordianKeyValuesStyles(); + const iconCls = cx(uAlignIcon, { [accordianKeyValuesStyles.emptyIcon]: isEmpty }); + let arrow: React.ReactNode | null = null; + let headerProps: {} | null = null; + if (interactive) { + arrow = isOpen ? : ; + headerProps = { + 'aria-checked': isOpen, + onClick: isEmpty ? null : onToggle, + role: 'switch', + }; + } + const styles = getStyles(); + return ( +
+
+ {arrow} {label} ({data.length}) +
+ {isOpen && } +
+ ); +} + +AccordianText.defaultProps = { + className: null, + highContrast: false, + interactive: true, + onToggle: null, +}; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/DetailState.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/DetailState.tsx new file mode 100644 index 00000000000..76e64a39166 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/DetailState.tsx @@ -0,0 +1,84 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Log } from '../../types/trace'; + +/** + * Which items of a {@link SpanDetail} component are expanded. + */ +export default class DetailState { + isTagsOpen: boolean; + isProcessOpen: boolean; + logs: { isOpen: boolean; openedItems: Set }; + isWarningsOpen: boolean; + isReferencesOpen: boolean; + + constructor(oldState?: DetailState) { + const { + isTagsOpen, + isProcessOpen, + isReferencesOpen, + isWarningsOpen, + logs, + }: DetailState | Record = oldState || {}; + this.isTagsOpen = Boolean(isTagsOpen); + this.isProcessOpen = Boolean(isProcessOpen); + this.isReferencesOpen = Boolean(isReferencesOpen); + this.isWarningsOpen = Boolean(isWarningsOpen); + this.logs = { + isOpen: Boolean(logs && logs.isOpen), + openedItems: logs && logs.openedItems ? new Set(logs.openedItems) : new Set(), + }; + } + + toggleTags() { + const next = new DetailState(this); + next.isTagsOpen = !this.isTagsOpen; + return next; + } + + toggleProcess() { + const next = new DetailState(this); + next.isProcessOpen = !this.isProcessOpen; + return next; + } + + toggleReferences() { + const next = new DetailState(this); + next.isReferencesOpen = !this.isReferencesOpen; + return next; + } + + toggleWarnings() { + const next = new DetailState(this); + next.isWarningsOpen = !this.isWarningsOpen; + return next; + } + + toggleLogs() { + const next = new DetailState(this); + next.logs.isOpen = !this.logs.isOpen; + return next; + } + + toggleLogItem(logItem: Log) { + const next = new DetailState(this); + if (next.logs.openedItems.has(logItem)) { + next.logs.openedItems.delete(logItem); + } else { + next.logs.openedItems.add(logItem); + } + return next; + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js new file mode 100644 index 00000000000..a5950b5aee7 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js @@ -0,0 +1,151 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import CopyIcon from '../../common/CopyIcon'; + +import KeyValuesTable, { LinkValue, getStyles } from './KeyValuesTable'; +import { UIDropdown, UIIcon } from '../../uiElementsContext'; +import {ubInlineBlock} from "../../uberUtilityStyles"; + +describe('LinkValue', () => { + const title = 'titleValue'; + const href = 'hrefValue'; + const childrenText = 'childrenTextValue'; + const wrapper = shallow( + + {childrenText} + + ); + + it('renders as expected', () => { + expect(wrapper.find('a').prop('href')).toBe(href); + expect(wrapper.find('a').prop('title')).toBe(title); + expect(wrapper.find('a').text()).toMatch(/childrenText/); + }); + + it('renders correct Icon', () => { + const styles = getStyles(); + expect(wrapper.find(UIIcon).hasClass(styles.linkIcon)).toBe(true); + expect(wrapper.find(UIIcon).prop('type')).toBe('export'); + }); +}); + +describe('', () => { + let wrapper; + + const data = [ + { key: 'span.kind', value: 'client' }, + { key: 'omg', value: 'mos-def' }, + { key: 'numericString', value: '12345678901234567890' }, + { key: 'jsonkey', value: JSON.stringify({ hello: 'world' }) }, + ]; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + expect(wrapper.find('[data-test-id="KeyValueTable"]').length).toBe(1); + }); + + it('renders a table row for each data element', () => { + const trs = wrapper.find('tr'); + expect(trs.length).toBe(data.length); + trs.forEach((tr, i) => { + expect(tr.find('[data-test-id="KeyValueTable--keyColumn"]').text()).toMatch(data[i].key); + }); + }); + + it('renders a single link correctly', () => { + wrapper.setProps({ + linksGetter: (array, i) => + array[i].key === 'span.kind' + ? [ + { + url: `http://example.com/?kind=${encodeURIComponent(array[i].value)}`, + text: `More info about ${array[i].value}`, + }, + ] + : [], + }); + + const anchor = wrapper.find(LinkValue); + expect(anchor).toHaveLength(1); + expect(anchor.prop('href')).toBe('http://example.com/?kind=client'); + expect(anchor.prop('title')).toBe('More info about client'); + expect( + anchor + .closest('tr') + .find('td') + .first() + .text() + ).toBe('span.kind'); + }); + + it('renders multiple links correctly', () => { + wrapper.setProps({ + linksGetter: (array, i) => + array[i].key === 'span.kind' + ? [ + { url: `http://example.com/1?kind=${encodeURIComponent(array[i].value)}`, text: 'Example 1' }, + { url: `http://example.com/2?kind=${encodeURIComponent(array[i].value)}`, text: 'Example 2' }, + ] + : [], + }); + const dropdown = wrapper.find(UIDropdown); + const overlay = shallow(dropdown.prop('overlay')); + // We have some wrappers here that dynamically inject specific component so we need to traverse a bit + // here + // eslint-disable-next-line react/prop-types + const menu = shallow(overlay.prop('children')({ Menu: ({ children }) =>
{children}
})); + const anchors = menu.find(LinkValue); + expect(anchors).toHaveLength(2); + const firstAnchor = anchors.first(); + expect(firstAnchor.prop('href')).toBe('http://example.com/1?kind=client'); + expect(firstAnchor.children().text()).toBe('Example 1'); + const secondAnchor = anchors.last(); + expect(secondAnchor.prop('href')).toBe('http://example.com/2?kind=client'); + expect(secondAnchor.children().text()).toBe('Example 2'); + expect( + dropdown + .closest('tr') + .find('td') + .first() + .text() + ).toBe('span.kind'); + }); + + it('renders a with correct copyText for each data element', () => { + const copyIcons = wrapper.find(CopyIcon); + expect(copyIcons.length).toBe(data.length); + copyIcons.forEach((copyIcon, i) => { + expect(copyIcon.prop('copyText')).toBe(JSON.stringify(data[i], null, 2)); + expect(copyIcon.prop('tooltipTitle')).toBe('Copy JSON'); + }); + }); + + it('renders a span value containing numeric string correctly', () => { + const el = wrapper.find(`.${ubInlineBlock}`); + expect(el.length).toBe(data.length); + el.forEach((valueDiv, i) => { + if (data[i].key !== 'jsonkey') { + expect(valueDiv.html()).toMatch(`"${data[i].value}"`); + } + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx new file mode 100644 index 00000000000..d2d32f74925 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx @@ -0,0 +1,177 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import jsonMarkup from 'json-markup'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import CopyIcon from '../../common/CopyIcon'; + +import { TNil } from '../../types'; +import { KeyValuePair, Link } from '../../types/trace'; +import { UIDropdown, UIIcon, UIMenu, UIMenuItem } from '../../uiElementsContext'; +import { createStyle } from '../../Theme'; +import { ubInlineBlock, uWidth100 } from '../../uberUtilityStyles'; + +export const getStyles = createStyle(() => { + const copyIcon = css` + label: copyIcon; + `; + return { + KeyValueTable: css` + label: KeyValueTable; + background: #fff; + border: 1px solid #ddd; + margin-bottom: 0.7em; + max-height: 450px; + overflow: auto; + `, + body: css` + label: body; + vertical-align: baseline; + `, + row: css` + label: row; + & > td { + padding: 0.25rem 0.5rem; + padding: 0.25rem 0.5rem; + vertical-align: top; + } + &:nth-child(2n) > td { + background: #f5f5f5; + } + &:not(:hover) .${copyIcon} { + display: none; + } + `, + keyColumn: css` + label: keyColumn; + color: #888; + white-space: pre; + width: 125px; + `, + copyColumn: css` + label: copyColumn; + text-align: right; + `, + linkIcon: css` + label: linkIcon; + vertical-align: middle; + font-weight: bold; + `, + copyIcon, + }; +}); + +const jsonObjectOrArrayStartRegex = /^(\[|\{)/; + +function parseIfComplexJson(value: any) { + // if the value is a string representing actual json object or array, then use json-markup + if (typeof value === 'string' && jsonObjectOrArrayStartRegex.test(value)) { + // otherwise just return as is + try { + return JSON.parse(value); + // eslint-disable-next-line no-empty + } catch (_) {} + } + return value; +} + +export const LinkValue = (props: { href: string; title?: string; children: React.ReactNode }) => { + const styles = getStyles(); + return ( + + {props.children} + + ); +}; + +LinkValue.defaultProps = { + title: '', +}; + +const linkValueList = (links: Link[]) => ( + + {links.map(({ text, url }, index) => ( + // `index` is necessary in the key because url can repeat + + {text} + + ))} + +); + +type KeyValuesTableProps = { + data: KeyValuePair[]; + linksGetter: ((pairs: KeyValuePair[], index: number) => Link[]) | TNil; +}; + +export default function KeyValuesTable(props: KeyValuesTableProps) { + const { data, linksGetter } = props; + const styles = getStyles(); + return ( +
+ + + {data.map((row, i) => { + const markup = { + __html: jsonMarkup(parseIfComplexJson(row.value)), + }; + const jsonTable =
; + const links = linksGetter ? linksGetter(data, i) : null; + let valueMarkup; + if (links && links.length === 1) { + valueMarkup = ( +
+ + {jsonTable} + +
+ ); + } else if (links && links.length > 1) { + valueMarkup = ( + + ); + } else { + valueMarkup = jsonTable; + } + return ( + // `i` is necessary in the key because row.key can repeat +
+ + + + + ); + })} + +
+ {row.key} + {valueMarkup} + +
+
+ ); +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.test.js new file mode 100644 index 00000000000..811c6f5a3cb --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.test.js @@ -0,0 +1,37 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; +import TextList from './TextList'; + +describe('', () => { + let wrapper; + + const data = [{ key: 'span.kind', value: 'client' }, { key: 'omg', value: 'mos-def' }]; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + expect(wrapper.find('[data-test-id="TextList"]').length).toBe(1); + }); + + it('renders a table row for each data element', () => { + const trs = wrapper.find('li'); + expect(trs.length).toBe(data.length); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.tsx new file mode 100644 index 00000000000..20aefca15c8 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import { createStyle } from '../../Theme'; + +const getStyles = createStyle(() => { + return { + TextList: css` + max-height: 450px; + overflow: auto; + `, + List: css` + width: 100%; + list-style: none; + padding: 0; + margin: 0; + `, + item: css` + padding: 0.25rem 0.5rem; + vertical-align: top; + &:nth-child(2n) { + background: #f5f5f5; + } + `, + }; +}); + +type TextListProps = { + data: string[]; +}; + +export default function TextList(props: TextListProps) { + const { data } = props; + const styles = getStyles(); + return ( +
+
    + {data.map((row, i) => { + return ( + // `i` is necessary in the key because row.key can repeat +
  • + {row} +
  • + ); + })} +
+
+ ); +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js new file mode 100644 index 00000000000..7d73f00e6ae --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js @@ -0,0 +1,192 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* eslint-disable import/first */ +jest.mock('../utils'); + +import React from 'react'; +import { shallow } from 'enzyme'; + +import AccordianKeyValues from './AccordianKeyValues'; +import AccordianLogs from './AccordianLogs'; +import DetailState from './DetailState'; +import SpanDetail from './index'; +import { formatDuration } from '../utils'; +import CopyIcon from '../../common/CopyIcon'; +import LabeledList from '../../common/LabeledList'; +import traceGenerator from '../../demo/trace-generators'; +import transformTraceData from '../../model/transform-trace-data'; + +describe('', () => { + let wrapper; + + // use `transformTraceData` on a fake trace to get a fully processed span + const span = transformTraceData(traceGenerator.trace({ numberOfSpans: 1 })).spans[0]; + const detailState = new DetailState() + .toggleLogs() + .toggleProcess() + .toggleReferences() + .toggleTags(); + const traceStartTime = 5; + const props = { + detailState, + span, + traceStartTime, + logItemToggle: jest.fn(), + logsToggle: jest.fn(), + processToggle: jest.fn(), + tagsToggle: jest.fn(), + warningsToggle: jest.fn(), + referencesToggle: jest.fn(), + }; + span.logs = [ + { + timestamp: 10, + fields: [{ key: 'message', value: 'oh the log message' }, { key: 'something', value: 'else' }], + }, + { + timestamp: 20, + fields: [{ key: 'message', value: 'oh the next log message' }, { key: 'more', value: 'stuff' }], + }, + ]; + + span.warnings = ['Warning 1', 'Warning 2']; + + span.references = [ + { + refType: 'CHILD_OF', + span: { + spanID: 'span2', + traceID: 'trace1', + operationName: 'op1', + process: { + serviceName: 'service1', + }, + }, + spanID: 'span1', + traceID: 'trace1', + }, + { + refType: 'CHILD_OF', + span: { + spanID: 'span3', + traceID: 'trace1', + operationName: 'op2', + process: { + serviceName: 'service2', + }, + }, + spanID: 'span4', + traceID: 'trace1', + }, + { + refType: 'CHILD_OF', + span: { + spanID: 'span6', + traceID: 'trace2', + operationName: 'op2', + process: { + serviceName: 'service2', + }, + }, + spanID: 'span5', + traceID: 'trace2', + }, + ]; + + beforeEach(() => { + formatDuration.mockReset(); + props.tagsToggle.mockReset(); + props.processToggle.mockReset(); + props.logsToggle.mockReset(); + props.logItemToggle.mockReset(); + wrapper = shallow(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + }); + + it('shows the operation name', () => { + expect(wrapper.find('h2').text()).toBe(span.operationName); + }); + + it('lists the service name, duration and start time', () => { + const words = ['Duration:', 'Service:', 'Start Time:']; + const overview = wrapper.find(LabeledList); + expect( + overview + .prop('items') + .map(item => item.label) + .sort() + ).toEqual(words); + }); + + it('renders the span tags', () => { + const target = ; + expect(wrapper.containsMatchingElement(target)).toBe(true); + wrapper.find({ data: span.tags }).simulate('toggle'); + expect(props.tagsToggle).toHaveBeenLastCalledWith(span.spanID); + }); + + it('renders the process tags', () => { + const target = ( + + ); + expect(wrapper.containsMatchingElement(target)).toBe(true); + wrapper.find({ data: span.process.tags }).simulate('toggle'); + expect(props.processToggle).toHaveBeenLastCalledWith(span.spanID); + }); + + it('renders the logs', () => { + const somethingUniq = {}; + const target = ( + + ); + expect(wrapper.containsMatchingElement(target)).toBe(true); + const accordianLogs = wrapper.find(AccordianLogs); + accordianLogs.simulate('toggle'); + accordianLogs.simulate('itemToggle', somethingUniq); + expect(props.logsToggle).toHaveBeenLastCalledWith(span.spanID); + expect(props.logItemToggle).toHaveBeenLastCalledWith(span.spanID, somethingUniq); + }); + + it('renders the warnings', () => { + const warningElm = wrapper.find({ data: span.warnings }); + expect(warningElm.length).toBe(1); + warningElm.simulate('toggle'); + expect(props.warningsToggle).toHaveBeenLastCalledWith(span.spanID); + }); + + it('renders the references', () => { + const refElem = wrapper.find({ data: span.references }); + expect(refElem.length).toBe(1); + refElem.simulate('toggle'); + expect(props.referencesToggle).toHaveBeenLastCalledWith(span.spanID); + }); + + it('renders CopyIcon with deep link URL', () => { + expect( + wrapper + .find(CopyIcon) + .prop('copyText') + .includes(`?uiFind=${props.span.spanID}`) + ).toBe(true); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx new file mode 100644 index 00000000000..4020ec53577 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx @@ -0,0 +1,199 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import AccordianKeyValues from './AccordianKeyValues'; +import AccordianLogs from './AccordianLogs'; +import AccordianText from './AccordianText'; +import DetailState from './DetailState'; +import { formatDuration } from '../utils'; +import CopyIcon from '../../common/CopyIcon'; +import LabeledList from '../../common/LabeledList'; + +import { TNil } from '../../types'; +import { KeyValuePair, Link, Log, Span } from '../../types/trace'; +import AccordianReferences from './AccordianReferences'; +import { createStyle } from '../../Theme'; +import { UIDivider } from '../../uiElementsContext'; +import { ubFlex, ubFlexAuto, ubItemsCenter, ubM0, ubMb1, ubMy1, ubTxRightAlign } from '../../uberUtilityStyles'; + +const getStyles = createStyle(() => { + return { + divider: css` + background: #ddd; + `, + debugInfo: css` + display: block; + letter-spacing: 0.25px; + margin: 0.5em 0 -0.75em; + text-align: right; + `, + debugLabel: css` + &::before { + color: #bbb; + content: attr(data-label); + } + `, + debugValue: css` + background-color: inherit; + border: none; + color: #888; + cursor: pointer; + &:hover { + color: #333; + } + `, + AccordianWarnings: css` + background: #fafafa; + border: 1px solid #e4e4e4; + margin-bottom: 0.25rem; + `, + AccordianWarningsHeader: css` + background: #fff7e6; + padding: 0.25rem 0.5rem; + &:hover { + background: #ffe7ba; + } + `, + AccordianWarningsHeaderOpen: css` + border-bottom: 1px solid #e8e8e8; + `, + AccordianWarningsLabel: css` + color: #d36c08; + `, + }; +}); + +type SpanDetailProps = { + detailState: DetailState; + linksGetter: ((links: KeyValuePair[], index: number) => Link[]) | TNil; + logItemToggle: (spanID: string, log: Log) => void; + logsToggle: (spanID: string) => void; + processToggle: (spanID: string) => void; + span: Span; + tagsToggle: (spanID: string) => void; + traceStartTime: number; + warningsToggle: (spanID: string) => void; + referencesToggle: (spanID: string) => void; + focusSpan: (uiFind: string) => void; +}; + +export default function SpanDetail(props: SpanDetailProps) { + const { + detailState, + linksGetter, + logItemToggle, + logsToggle, + processToggle, + span, + tagsToggle, + traceStartTime, + warningsToggle, + referencesToggle, + focusSpan, + } = props; + const { isTagsOpen, isProcessOpen, logs: logsState, isWarningsOpen, isReferencesOpen } = detailState; + const { operationName, process, duration, relativeStartTime, spanID, logs, tags, warnings, references } = span; + const overviewItems = [ + { + key: 'svc', + label: 'Service:', + value: process.serviceName, + }, + { + key: 'duration', + label: 'Duration:', + value: formatDuration(duration), + }, + { + key: 'start', + label: 'Start Time:', + value: formatDuration(relativeStartTime), + }, + ]; + const deepLinkCopyText = `${window.location.origin}${window.location.pathname}?uiFind=${spanID}`; + const styles = getStyles(); + + return ( +
+
+

{operationName}

+ +
+ +
+
+ tagsToggle(spanID)} + /> + {process.tags && ( + processToggle(spanID)} + /> + )} +
+ {logs && logs.length > 0 && ( + logsToggle(spanID)} + onItemToggle={logItem => logItemToggle(spanID, logItem)} + timestamp={traceStartTime} + /> + )} + {warnings && warnings.length > 0 && ( + Warnings} + data={warnings} + isOpen={isWarningsOpen} + onToggle={() => warningsToggle(spanID)} + /> + )} + {references && references.length > 1 && ( + referencesToggle(spanID)} + focusSpan={focusSpan} + /> + )} + + {spanID} + + +
+
+ ); +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.test.js new file mode 100644 index 00000000000..b073c9e34b7 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.test.js @@ -0,0 +1,97 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import SpanDetailRow from './SpanDetailRow'; +import SpanDetail from './SpanDetail'; +import DetailState from './SpanDetail/DetailState'; +import SpanTreeOffset from './SpanTreeOffset'; + +jest.mock('./SpanTreeOffset'); + +describe('', () => { + const spanID = 'some-id'; + const props = { + color: 'some-color', + columnDivision: 0.5, + detailState: new DetailState(), + onDetailToggled: jest.fn(), + linksGetter: jest.fn(), + isFilteredOut: false, + logItemToggle: jest.fn(), + logsToggle: jest.fn(), + processToggle: jest.fn(), + span: { spanID, depth: 3 }, + tagsToggle: jest.fn(), + traceStartTime: 1000, + }; + + let wrapper; + + beforeEach(() => { + props.onDetailToggled.mockReset(); + props.linksGetter.mockReset(); + props.logItemToggle.mockReset(); + props.logsToggle.mockReset(); + props.processToggle.mockReset(); + props.tagsToggle.mockReset(); + wrapper = shallow(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + }); + + it('escalates toggle detail', () => { + const calls = props.onDetailToggled.mock.calls; + expect(calls.length).toBe(0); + wrapper.find('[data-test-id="detail-row-expanded-accent"]').prop('onClick')(); + expect(calls).toEqual([[spanID]]); + }); + + it('renders the span tree offset', () => { + const spanTreeOffset = ; + expect(wrapper.contains(spanTreeOffset)).toBe(true); + }); + + it('renders the SpanDetail', () => { + const spanDetail = ( + + ); + expect(wrapper.contains(spanDetail)).toBe(true); + }); + + it('adds span when calling linksGetter', () => { + const spanDetail = wrapper.find(SpanDetail); + const linksGetter = spanDetail.prop('linksGetter'); + const tags = [{ key: 'myKey', value: 'myValue' }]; + const linksGetterResponse = {}; + props.linksGetter.mockReturnValueOnce(linksGetterResponse); + const result = linksGetter(tags, 0); + expect(result).toBe(linksGetterResponse); + expect(props.linksGetter).toHaveBeenCalledTimes(1); + expect(props.linksGetter).toHaveBeenCalledWith(props.span, tags, 0); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx new file mode 100644 index 00000000000..35db2eae304 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx @@ -0,0 +1,158 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { css } from 'emotion'; + +import SpanDetail from './SpanDetail'; +import DetailState from './SpanDetail/DetailState'; +import SpanTreeOffset from './SpanTreeOffset'; +import TimelineRow from './TimelineRow'; +import { createStyle } from '../Theme'; + +import { Log, Span, KeyValuePair, Link } from '../types/trace'; + +const getStyles = createStyle(() => { + return { + expandedAccent: css` + cursor: pointer; + height: 100%; + overflow: hidden; + position: absolute; + width: 100%; + &::before { + border-left: 4px solid; + pointer-events: none; + width: 1000px; + } + &::after { + border-right: 1000px solid; + border-color: inherit; + cursor: pointer; + opacity: 0.2; + } + + /* border-color inherit must come AFTER other border declarations for accent */ + &::before, + &::after { + border-color: inherit; + content: ' '; + position: absolute; + height: 100%; + } + + &:hover::after { + opacity: 0.35; + } + `, + infoWrapper: css` + background: #f5f5f5; + border: 1px solid #d3d3d3; + border-top: 3px solid; + padding: 0.75rem; + `, + }; +}); + +type SpanDetailRowProps = { + color: string; + columnDivision: number; + detailState: DetailState; + onDetailToggled: (spanID: string) => void; + linksGetter: (span: Span, links: KeyValuePair[], index: number) => Link[]; + logItemToggle: (spanID: string, log: Log) => void; + logsToggle: (spanID: string) => void; + processToggle: (spanID: string) => void; + referencesToggle: (spanID: string) => void; + warningsToggle: (spanID: string) => void; + span: Span; + tagsToggle: (spanID: string) => void; + traceStartTime: number; + focusSpan: (uiFind: string) => void; + hoverIndentGuideIds: Set; + addHoverIndentGuideId: (spanID: string) => void; + removeHoverIndentGuideId: (spanID: string) => void; +}; + +export default class SpanDetailRow extends React.PureComponent { + _detailToggle = () => { + this.props.onDetailToggled(this.props.span.spanID); + }; + + _linksGetter = (items: KeyValuePair[], itemIndex: number) => { + const { linksGetter, span } = this.props; + return linksGetter(span, items, itemIndex); + }; + + render() { + const { + color, + columnDivision, + detailState, + logItemToggle, + logsToggle, + processToggle, + referencesToggle, + warningsToggle, + span, + tagsToggle, + traceStartTime, + focusSpan, + hoverIndentGuideIds, + addHoverIndentGuideId, + removeHoverIndentGuideId, + } = this.props; + const styles = getStyles(); + return ( + + + + + + + + +
+ +
+
+
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.test.js new file mode 100644 index 00000000000..5fcdc5e1859 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.test.js @@ -0,0 +1,145 @@ +// Copyright (c) 2018 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { shallow } from 'enzyme'; +import React from 'react'; +import IoChevronRight from 'react-icons/lib/io/chevron-right'; +import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; + +import SpanTreeOffset, { getStyles } from './SpanTreeOffset'; +import spanAncestorIdsSpy from '../utils/span-ancestor-ids'; + +jest.mock('../utils/span-ancestor-ids'); + +describe('SpanTreeOffset', () => { + const ownSpanID = 'ownSpanID'; + const parentSpanID = 'parentSpanID'; + const rootSpanID = 'rootSpanID'; + const specialRootID = 'root'; + let props; + let wrapper; + + beforeEach(() => { + // Mock implementation instead of Mock return value so that each call returns a new array (like normal) + spanAncestorIdsSpy.mockImplementation(() => [parentSpanID, rootSpanID]); + props = { + addHoverIndentGuideId: jest.fn(), + hoverIndentGuideIds: new Set(), + removeHoverIndentGuideId: jest.fn(), + span: { + hasChildren: false, + spanID: ownSpanID, + }, + }; + wrapper = shallow(); + }); + + describe('.SpanTreeOffset--indentGuide', () => { + it('renders only one .SpanTreeOffset--indentGuide for entire trace if span has no ancestors', () => { + spanAncestorIdsSpy.mockReturnValue([]); + wrapper = shallow(); + const indentGuides = wrapper.find('[data-test-id="SpanTreeOffset--indentGuide"]'); + expect(indentGuides.length).toBe(1); + expect(indentGuides.prop('data-ancestor-id')).toBe(specialRootID); + }); + + it('renders one .SpanTreeOffset--indentGuide per ancestor span, plus one for entire trace', () => { + const indentGuides = wrapper.find('[data-test-id="SpanTreeOffset--indentGuide"]'); + expect(indentGuides.length).toBe(3); + expect(indentGuides.at(0).prop('data-ancestor-id')).toBe(specialRootID); + expect(indentGuides.at(1).prop('data-ancestor-id')).toBe(rootSpanID); + expect(indentGuides.at(2).prop('data-ancestor-id')).toBe(parentSpanID); + }); + + it('adds .is-active to correct indentGuide', () => { + props.hoverIndentGuideIds = new Set([parentSpanID]); + wrapper = shallow(); + const styles = getStyles(); + const activeIndentGuide = wrapper.find(`.${styles.indentGuideActive}`); + expect(activeIndentGuide.length).toBe(1); + expect(activeIndentGuide.prop('data-ancestor-id')).toBe(parentSpanID); + }); + + it('calls props.addHoverIndentGuideId on mouse enter', () => { + wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseenter', {}); + expect(props.addHoverIndentGuideId).toHaveBeenCalledTimes(1); + expect(props.addHoverIndentGuideId).toHaveBeenCalledWith(parentSpanID); + }); + + it('does not call props.addHoverIndentGuideId on mouse enter if mouse came from a indentGuide with the same ancestorId', () => { + const relatedTarget = document.createElement('span'); + relatedTarget.dataset.ancestorId = parentSpanID; + wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseenter', { + relatedTarget, + }); + expect(props.addHoverIndentGuideId).not.toHaveBeenCalled(); + }); + + it('calls props.removeHoverIndentGuideId on mouse leave', () => { + wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseleave', {}); + expect(props.removeHoverIndentGuideId).toHaveBeenCalledTimes(1); + expect(props.removeHoverIndentGuideId).toHaveBeenCalledWith(parentSpanID); + }); + + it('does not call props.removeHoverIndentGuideId on mouse leave if mouse leaves to a indentGuide with the same ancestorId', () => { + const relatedTarget = document.createElement('span'); + relatedTarget.dataset.ancestorId = parentSpanID; + wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseleave', { + relatedTarget, + }); + expect(props.removeHoverIndentGuideId).not.toHaveBeenCalled(); + }); + }); + + describe('icon', () => { + beforeEach(() => { + wrapper.setProps({ span: { ...props.span, hasChildren: true } }); + }); + + it('does not render icon if props.span.hasChildren is false', () => { + wrapper.setProps({ span: { ...props.span, hasChildren: false } }); + expect(wrapper.find(IoChevronRight).length).toBe(0); + expect(wrapper.find(IoIosArrowDown).length).toBe(0); + }); + + it('does not render icon if props.span.hasChildren is true and showChildrenIcon is false', () => { + wrapper.setProps({ showChildrenIcon: false }); + expect(wrapper.find(IoChevronRight).length).toBe(0); + expect(wrapper.find(IoIosArrowDown).length).toBe(0); + }); + + it('renders IoChevronRight if props.span.hasChildren is true and props.childrenVisible is false', () => { + expect(wrapper.find(IoChevronRight).length).toBe(1); + expect(wrapper.find(IoIosArrowDown).length).toBe(0); + }); + + it('renders IoIosArrowDown if props.span.hasChildren is true and props.childrenVisible is true', () => { + wrapper.setProps({ childrenVisible: true }); + expect(wrapper.find(IoChevronRight).length).toBe(0); + expect(wrapper.find(IoIosArrowDown).length).toBe(1); + }); + + it('calls props.addHoverIndentGuideId on mouse enter', () => { + wrapper.find('[data-test-id="icon-wrapper"]').simulate('mouseenter', {}); + expect(props.addHoverIndentGuideId).toHaveBeenCalledTimes(1); + expect(props.addHoverIndentGuideId).toHaveBeenCalledWith(ownSpanID); + }); + + it('calls props.removeHoverIndentGuideId on mouse leave', () => { + wrapper.find('[data-test-id="icon-wrapper"]').simulate('mouseleave', {}); + expect(props.removeHoverIndentGuideId).toHaveBeenCalledTimes(1); + expect(props.removeHoverIndentGuideId).toHaveBeenCalledWith(ownSpanID); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.tsx new file mode 100644 index 00000000000..fd28f9d901a --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.tsx @@ -0,0 +1,169 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import _get from 'lodash/get'; +import IoChevronRight from 'react-icons/lib/io/chevron-right'; +import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import { Span } from '../types/trace'; +import spanAncestorIds from '../utils/span-ancestor-ids'; + +import { createStyle } from '../Theme'; + +export const getStyles = createStyle(() => { + return { + SpanTreeOffset: css` + label: SpanTreeOffset; + color: #000; + position: relative; + `, + SpanTreeOffsetParent: css` + label: SpanTreeOffsetParent; + &:hover { + background-color: #e8e8e8; + cursor: pointer; + } + `, + indentGuide: css` + label: indentGuide; + /* The size of the indentGuide is based off of the iconWrapper */ + padding-right: calc(0.5rem + 12px); + height: 100%; + border-left: 1px solid transparent; + display: inline-flex; + &::before { + content: ''; + padding-left: 1px; + background-color: lightgrey; + } + `, + indentGuideActive: css` + label: indentGuideActive; + padding-right: calc(0.5rem + 11px); + border-left: 0px; + &::before { + content: ''; + padding-left: 3px; + background-color: darkgrey; + } + `, + iconWrapper: css` + label: iconWrapper; + position: absolute; + right: 0.25rem; + `, + }; +}); + +type TProps = { + childrenVisible?: boolean; + onClick?: () => void; + span: Span; + showChildrenIcon?: boolean; + + hoverIndentGuideIds: Set; + addHoverIndentGuideId: (spanID: string) => void; + removeHoverIndentGuideId: (spanID: string) => void; +}; + +export default class SpanTreeOffset extends React.PureComponent { + ancestorIds: string[]; + + static defaultProps = { + childrenVisible: false, + showChildrenIcon: true, + }; + + constructor(props: TProps) { + super(props); + + this.ancestorIds = spanAncestorIds(props.span); + // Some traces have multiple root-level spans, this connects them all under one guideline and adds the + // necessary padding for the collapse icon on root-level spans. + this.ancestorIds.push('root'); + + this.ancestorIds.reverse(); + } + + /** + * If the mouse leaves to anywhere except another span with the same ancestor id, this span's ancestor id is + * removed from the set of hoverIndentGuideIds. + * + * @param {Object} event - React Synthetic event tied to mouseleave. Includes the related target which is + * the element the user is now hovering. + * @param {string} ancestorId - The span id that the user was hovering over. + */ + handleMouseLeave = (event: React.MouseEvent, ancestorId: string) => { + if ( + !(event.relatedTarget instanceof HTMLSpanElement) || + _get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId + ) { + this.props.removeHoverIndentGuideId(ancestorId); + } + }; + + /** + * If the mouse entered this span from anywhere except another span with the same ancestor id, this span's + * ancestorId is added to the set of hoverIndentGuideIds. + * + * @param {Object} event - React Synthetic event tied to mouseenter. Includes the related target which is + * the last element the user was hovering. + * @param {string} ancestorId - The span id that the user is now hovering over. + */ + handleMouseEnter = (event: React.MouseEvent, ancestorId: string) => { + if ( + !(event.relatedTarget instanceof HTMLSpanElement) || + _get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId + ) { + this.props.addHoverIndentGuideId(ancestorId); + } + }; + + render() { + const { childrenVisible, onClick, showChildrenIcon, span } = this.props; + const { hasChildren, spanID } = span; + const wrapperProps = hasChildren ? { onClick, role: 'switch', 'aria-checked': childrenVisible } : null; + const icon = showChildrenIcon && hasChildren && (childrenVisible ? : ); + const styles = getStyles(); + return ( + + {this.ancestorIds.map(ancestorId => ( + this.handleMouseEnter(event, ancestorId)} + onMouseLeave={event => this.handleMouseLeave(event, ancestorId)} + /> + ))} + {icon && ( + this.handleMouseEnter(event, spanID)} + onMouseLeave={event => this.handleMouseLeave(event, spanID)} + data-test-id="icon-wrapper" + > + {icon} + + )} + + ); + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.test.js new file mode 100644 index 00000000000..7a2bf3d267d --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.test.js @@ -0,0 +1,25 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import Ticks from './Ticks'; + +describe('', () => { + it('renders without exploding', () => { + const wrapper = shallow(); + expect(wrapper).toBeDefined(); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.tsx new file mode 100644 index 00000000000..78857625be4 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.tsx @@ -0,0 +1,92 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import { formatDuration } from './utils'; +import { TNil } from '../types'; +import { createStyle } from '../Theme'; + +const getStyles = createStyle(() => { + return { + Ticks: css` + pointer-events: none; + `, + tick: css` + position: absolute; + height: 100%; + width: 1px; + background: #d8d8d8; + &:last-child { + width: 0; + } + `, + tickLabel: css` + left: 0.25rem; + position: absolute; + `, + tickLabelEndAnchor: css` + left: initial; + right: 0.25rem; + `, + }; +}); + +type TicksProps = { + endTime?: number | TNil; + numTicks: number; + showLabels?: boolean | TNil; + startTime?: number | TNil; +}; + +export default function Ticks(props: TicksProps) { + const { endTime, numTicks, showLabels, startTime } = props; + + let labels: undefined | string[]; + if (showLabels) { + labels = []; + const viewingDuration = (endTime || 0) - (startTime || 0); + for (let i = 0; i < numTicks; i++) { + const durationAtTick = (startTime || 0) + (i / (numTicks - 1)) * viewingDuration; + labels.push(formatDuration(durationAtTick)); + } + } + const styles = getStyles(); + const ticks: React.ReactNode[] = []; + for (let i = 0; i < numTicks; i++) { + const portion = i / (numTicks - 1); + ticks.push( +
+ {labels && ( + = 1 })}>{labels[i]} + )} +
+ ); + } + return
{ticks}
; +} + +Ticks.defaultProps = { + endTime: null, + showLabels: null, + startTime: null, +}; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.js new file mode 100644 index 00000000000..02a447e323f --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.js @@ -0,0 +1,32 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import TimelineCollapser from './TimelineCollapser'; + +describe('', () => { + it('renders without exploding', () => { + const props = { + onCollapseAll: () => {}, + onCollapseOne: () => {}, + onExpandAll: () => {}, + onExpandOne: () => {}, + }; + const wrapper = shallow(); + expect(wrapper).toBeDefined(); + expect(wrapper.find('[data-test-id="TimelineCollapser"]').length).toBe(1); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx new file mode 100644 index 00000000000..c2e355facee --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx @@ -0,0 +1,95 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import { UITooltip, UIIcon } from '../../uiElementsContext'; +import { createStyle } from '../../Theme'; + +const getStyles = createStyle(() => { + return { + TraceTimelineViewer: css` + border-bottom: 1px solid #bbb; + `, + TimelineCollapser: css` + align-items: center; + display: flex; + flex: none; + justify-content: center; + margin-right: 0.5rem; + `, + tooltipTitle: css` + white-space: pre; + `, + btn: css` + color: rgba(0, 0, 0, 0.5); + cursor: pointer; + margin-right: 0.3rem; + padding: 0.1rem; + &:hover { + color: rgba(0, 0, 0, 0.85); + } + `, + btnExpanded: css` + transform: rotate(90deg); + `, + }; +}); + +type CollapserProps = { + onCollapseAll: () => void; + onCollapseOne: () => void; + onExpandOne: () => void; + onExpandAll: () => void; +}; + +function getTitle(value: string) { + const styles = getStyles(); + return {value}; +} + +export default class TimelineCollapser extends React.PureComponent { + containerRef: React.RefObject; + + constructor(props: CollapserProps) { + super(props); + this.containerRef = React.createRef(); + } + + // TODO: Something less hacky than createElement to help TypeScript / AntD + getContainer = () => this.containerRef.current || document.createElement('div'); + + render() { + const { onExpandAll, onExpandOne, onCollapseAll, onCollapseOne } = this.props; + const styles = getStyles(); + return ( +
+ + + + + + + + + + + + +
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js new file mode 100644 index 00000000000..c069188306a --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js @@ -0,0 +1,109 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { mount } from 'enzyme'; +import cx from 'classnames'; + +import TimelineColumnResizer, { getStyles } from './TimelineColumnResizer'; + +describe('', () => { + let wrapper; + let instance; + + const props = { + min: 0.1, + max: 0.9, + onChange: jest.fn(), + position: 0.5, + }; + + beforeEach(() => { + props.onChange.mockReset(); + wrapper = mount(); + instance = wrapper.instance(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + expect(wrapper.find('[data-test-id="TimelineColumnResizer"]').length).toBe(1); + expect(wrapper.find('[data-test-id="TimelineColumnResizer--gripIcon"]').length).toBe(1); + expect(wrapper.find('[data-test-id="TimelineColumnResizer--dragger"]').length).toBe(1); + }); + + it('sets the root elm', () => { + const rootWrapper = wrapper.find('[data-test-id="TimelineColumnResizer"]'); + expect(rootWrapper.getDOMNode()).toBe(instance._rootElm); + }); + + describe('uses DraggableManager', () => { + it('handles mouse down on the dragger', () => { + const dragger = wrapper.find({ onMouseDown: instance._dragManager.handleMouseDown }); + expect(dragger.length).toBe(1); + expect(dragger.is('[data-test-id="TimelineColumnResizer--dragger"]')).toBe(true); + }); + + it('returns the draggable bounds via _getDraggingBounds()', () => { + const left = 10; + const width = 100; + instance._rootElm.getBoundingClientRect = () => ({ left, width }); + expect(instance._getDraggingBounds()).toEqual({ + width, + clientXLeft: left, + maxValue: props.max, + minValue: props.min, + }); + }); + + it('handles drag start', () => { + const value = Math.random(); + expect(wrapper.state('dragPosition')).toBe(null); + instance._handleDragUpdate({ value }); + expect(wrapper.state('dragPosition')).toBe(value); + }); + + it('handles drag end', () => { + const manager = { resetBounds: jest.fn() }; + const value = Math.random(); + wrapper.setState({ dragPosition: 2 * value }); + instance._handleDragEnd({ manager, value }); + expect(manager.resetBounds.mock.calls).toEqual([[]]); + expect(wrapper.state('dragPosition')).toBe(null); + expect(props.onChange.mock.calls).toEqual([[value]]); + }); + }); + + it('does not render a dragging indicator when not dragging', () => { + const styles = getStyles(); + expect(wrapper.find('[data-test-id="TimelineColumnResizer--dragger"]').prop('style').right).toBe( + undefined + ); + expect(wrapper.find('[data-test-id="TimelineColumnResizer--dragger"]').prop('className')).toBe( + styles.dragger + ); + }); + + it('renders a dragging indicator when dragging', () => { + instance._dragManager.isDragging = () => true; + instance._handleDragUpdate({ value: props.min }); + instance.forceUpdate(); + wrapper.update(); + expect(wrapper.find('[data-test-id="TimelineColumnResizer--dragger"]').prop('style').right).toBeDefined(); + + const styles = getStyles(); + expect(wrapper.find('[data-test-id="TimelineColumnResizer--dragger"]').prop('className')).toBe( + cx(styles.dragger, styles.draggerDragging, styles.draggerDraggingLeft) + ); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx new file mode 100644 index 00000000000..f536c8a8d3d --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx @@ -0,0 +1,215 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import { TNil } from '../../types'; +import DraggableManager, { DraggableBounds, DraggingUpdate } from '../../utils/DraggableManager'; +import { createStyle } from '../../Theme'; + +export const getStyles = createStyle(() => { + return { + TimelineColumnResizer: css` + left: 0; + position: absolute; + right: 0; + top: 0; + `, + wrapper: css` + bottom: 0; + position: absolute; + top: 0; + `, + dragger: css` + border-left: 2px solid transparent; + cursor: col-resize; + height: 5000px; + margin-left: -1px; + position: absolute; + top: 0; + width: 1px; + z-index: 10; + &:hover { + border-left: 2px solid rgba(0, 0, 0, 0.3); + } + &::before { + position: absolute; + top: 0; + bottom: 0; + left: -8px; + right: 0; + content: ' '; + } + `, + draggerDragging: css` + background: rgba(136, 0, 136, 0.05); + width: unset; + &::before { + left: -2000px; + right: -2000px; + } + `, + draggerDraggingLeft: css` + border-left: 2px solid #808; + border-right: 1px solid #999; + `, + draggerDraggingRight: css` + border-left: 1px solid #999; + border-right: 2px solid #808; + `, + gripIcon: css` + position: absolute; + top: 0; + bottom: 0; + &::before, + &::after { + border-right: 1px solid #ccc; + content: ' '; + height: 9px; + position: absolute; + right: 9px; + top: 25px; + } + &::after { + right: 5px; + } + `, + gripIconDragging: css` + &::before, + &::after { + border-right: 1px solid rgba(136, 0, 136, 0.5); + } + `, + }; +}); + +type TimelineColumnResizerProps = { + min: number; + max: number; + onChange: (newSize: number) => void; + position: number; +}; + +type TimelineColumnResizerState = { + dragPosition: number | TNil; +}; + +export default class TimelineColumnResizer extends React.PureComponent< + TimelineColumnResizerProps, + TimelineColumnResizerState +> { + state: TimelineColumnResizerState; + + _dragManager: DraggableManager; + _rootElm: Element | TNil; + + constructor(props: TimelineColumnResizerProps) { + super(props); + this._dragManager = new DraggableManager({ + getBounds: this._getDraggingBounds, + onDragEnd: this._handleDragEnd, + onDragMove: this._handleDragUpdate, + onDragStart: this._handleDragUpdate, + }); + this._rootElm = undefined; + this.state = { + dragPosition: null, + }; + } + + componentWillUnmount() { + this._dragManager.dispose(); + } + + _setRootElm = (elm: Element | TNil) => { + this._rootElm = elm; + }; + + _getDraggingBounds = (): DraggableBounds => { + if (!this._rootElm) { + throw new Error('invalid state'); + } + const { left: clientXLeft, width } = this._rootElm.getBoundingClientRect(); + const { min, max } = this.props; + return { + clientXLeft, + width, + maxValue: max, + minValue: min, + }; + }; + + _handleDragUpdate = ({ value }: DraggingUpdate) => { + this.setState({ dragPosition: value }); + }; + + _handleDragEnd = ({ manager, value }: DraggingUpdate) => { + manager.resetBounds(); + this.setState({ dragPosition: null }); + this.props.onChange(value); + }; + + render() { + let left; + let draggerStyle; + const { position } = this.props; + const { dragPosition } = this.state; + left = `${position * 100}%`; + const gripStyle = { left }; + let isDraggingLeft = false; + let isDraggingRight = false; + const styles = getStyles(); + + if (this._dragManager.isDragging() && this._rootElm && dragPosition != null) { + isDraggingLeft = dragPosition < position; + isDraggingRight = dragPosition > position; + left = `${dragPosition * 100}%`; + // Draw a highlight from the current dragged position back to the original + // position, e.g. highlight the change. Draw the highlight via `left` and + // `right` css styles (simpler than using `width`). + const draggerLeft = `${Math.min(position, dragPosition) * 100}%`; + // subtract 1px for draggerRight to deal with the right border being off + // by 1px when dragging left + const draggerRight = `calc(${(1 - Math.max(position, dragPosition)) * 100}% - 1px)`; + draggerStyle = { left: draggerLeft, right: draggerRight }; + } else { + draggerStyle = gripStyle; + } + + const isDragging = isDraggingLeft || isDraggingRight; + return ( +
+
+
+
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.js new file mode 100644 index 00000000000..0628086a9d1 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.js @@ -0,0 +1,112 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import TimelineHeaderRow from './TimelineHeaderRow'; +import TimelineColumnResizer from './TimelineColumnResizer'; +import TimelineViewingLayer from './TimelineViewingLayer'; +import Ticks from '../Ticks'; +import TimelineCollapser from './TimelineCollapser'; + +describe('', () => { + let wrapper; + + const nameColumnWidth = 0.25; + const props = { + nameColumnWidth, + duration: 1234, + numTicks: 5, + onCollapseAll: () => {}, + onCollapseOne: () => {}, + onColummWidthChange: () => {}, + onExpandAll: () => {}, + onExpandOne: () => {}, + updateNextViewRangeTime: () => {}, + updateViewRangeTime: () => {}, + viewRangeTime: { + current: [0.1, 0.9], + }, + }; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + expect(wrapper.find('[data-test-id="TimelineHeaderRow"]').length).toBe(1); + }); + + it('propagates the name column width', () => { + const nameCol = wrapper.find({ width: nameColumnWidth }); + const timelineCol = wrapper.find({ width: 1 - nameColumnWidth }); + expect(nameCol.length).toBe(1); + expect(timelineCol.length).toBe(1); + }); + + it('renders the title', () => { + expect(wrapper.find('h3').text()).toMatch(/Service.*?Operation/); + }); + + it('renders the TimelineViewingLayer', () => { + const elm = ( + + ); + expect(wrapper.containsMatchingElement(elm)).toBe(true); + }); + + it('renders the Ticks', () => { + const [viewStart, viewEnd] = props.viewRangeTime.current; + const elm = ( + + ); + expect(wrapper.containsMatchingElement(elm)).toBe(true); + }); + + it('renders the TimelineColumnResizer', () => { + const elm = ( + + ); + expect(wrapper.containsMatchingElement(elm)).toBe(true); + }); + + it('renders the TimelineCollapser', () => { + const elm = ( + + ); + expect(wrapper.containsMatchingElement(elm)).toBe(true); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx new file mode 100644 index 00000000000..eac3bf7a5cb --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import TimelineCollapser from './TimelineCollapser'; +import TimelineColumnResizer from './TimelineColumnResizer'; +import TimelineViewingLayer from './TimelineViewingLayer'; +import Ticks from '../Ticks'; +import TimelineRow from '../TimelineRow'; +import { TUpdateViewRangeTimeFunction, ViewRangeTime, ViewRangeTimeUpdate } from '../types'; +import { createStyle } from '../../Theme'; +import { ubFlex, ubPx2 } from '../../uberUtilityStyles'; + +const getStyles = createStyle(() => { + return { + TimelineHeaderRow: css` + background: #ececec; + border-bottom: 1px solid #ccc; + height: 38px; + line-height: 38px; + width: 100%; + z-index: 4; + `, + title: css` + flex: 1; + overflow: hidden; + margin: 0; + text-overflow: ellipsis; + white-space: nowrap; + `, + }; +}); + +type TimelineHeaderRowProps = { + duration: number; + nameColumnWidth: number; + numTicks: number; + onCollapseAll: () => void; + onCollapseOne: () => void; + onColummWidthChange: (width: number) => void; + onExpandAll: () => void; + onExpandOne: () => void; + updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void; + updateViewRangeTime: TUpdateViewRangeTimeFunction; + viewRangeTime: ViewRangeTime; +}; + +export default function TimelineHeaderRow(props: TimelineHeaderRowProps) { + const { + duration, + nameColumnWidth, + numTicks, + onCollapseAll, + onCollapseOne, + onColummWidthChange, + onExpandAll, + onExpandOne, + updateViewRangeTime, + updateNextViewRangeTime, + viewRangeTime, + } = props; + const [viewStart, viewEnd] = viewRangeTime.current; + const styles = getStyles(); + return ( + + +

Service & Operation

+ +
+ + + + + +
+ ); +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.test.js new file mode 100644 index 00000000000..b2629b936de --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.test.js @@ -0,0 +1,204 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { mount } from 'enzyme'; +import { cx } from 'emotion'; + +import TimelineViewingLayer, { getStyles } from './TimelineViewingLayer'; + +function mapFromSubRange(viewStart, viewEnd, value) { + return viewStart + value * (viewEnd - viewStart); +} + +describe('', () => { + let wrapper; + let instance; + + const viewStart = 0.25; + const viewEnd = 0.9; + const props = { + boundsInvalidator: Math.random(), + updateNextViewRangeTime: jest.fn(), + updateViewRangeTime: jest.fn(), + viewRangeTime: { + current: [viewStart, viewEnd], + }, + }; + + beforeEach(() => { + props.updateNextViewRangeTime.mockReset(); + props.updateViewRangeTime.mockReset(); + wrapper = mount(); + instance = wrapper.instance(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + expect(wrapper.find('[data-test-id="TimelineViewingLayer"]').length).toBe(1); + }); + + it('sets _root to the root DOM node', () => { + expect(instance._root).toBeDefined(); + expect(wrapper.find('[data-test-id="TimelineViewingLayer"]').getDOMNode()).toBe(instance._root); + }); + + describe('uses DraggableManager', () => { + it('initializes the DraggableManager', () => { + const dm = instance._draggerReframe; + expect(dm).toBeDefined(); + expect(dm._onMouseMove).toBe(instance._handleReframeMouseMove); + expect(dm._onMouseLeave).toBe(instance._handleReframeMouseLeave); + expect(dm._onDragStart).toBe(instance._handleReframeDragUpdate); + expect(dm._onDragMove).toBe(instance._handleReframeDragUpdate); + expect(dm._onDragEnd).toBe(instance._handleReframeDragEnd); + }); + + it('provides the DraggableManager handlers as callbacks', () => { + const { handleMouseDown, handleMouseLeave, handleMouseMove } = instance._draggerReframe; + const rootWrapper = wrapper.find('[data-test-id="TimelineViewingLayer"]'); + expect(rootWrapper.prop('onMouseDown')).toBe(handleMouseDown); + expect(rootWrapper.prop('onMouseLeave')).toBe(handleMouseLeave); + expect(rootWrapper.prop('onMouseMove')).toBe(handleMouseMove); + }); + + it('returns the dragging bounds from _getDraggingBounds()', () => { + const left = 10; + const width = 100; + instance._root.getBoundingClientRect = () => ({ left, width }); + expect(instance._getDraggingBounds()).toEqual({ width, clientXLeft: left }); + }); + + it('updates viewRange.time.cursor via _draggerReframe._onMouseMove', () => { + const value = 0.5; + const cursor = mapFromSubRange(viewStart, viewEnd, value); + instance._draggerReframe._onMouseMove({ value }); + expect(props.updateNextViewRangeTime.mock.calls).toEqual([[{ cursor }]]); + }); + + it('resets viewRange.time.cursor via _draggerReframe._onMouseLeave', () => { + instance._draggerReframe._onMouseLeave(); + expect(props.updateNextViewRangeTime.mock.calls).toEqual([[{ cursor: undefined }]]); + }); + + it('handles drag start via _draggerReframe._onDragStart', () => { + const value = 0.5; + const shift = mapFromSubRange(viewStart, viewEnd, value); + const update = { reframe: { shift, anchor: shift } }; + instance._draggerReframe._onDragStart({ value }); + expect(props.updateNextViewRangeTime.mock.calls).toEqual([[update]]); + }); + + it('handles drag move via _draggerReframe._onDragMove', () => { + const anchor = 0.25; + const viewRangeTime = { ...props.viewRangeTime, reframe: { anchor, shift: Math.random() } }; + const value = 0.5; + const shift = mapFromSubRange(viewStart, viewEnd, value); + // make sure `anchor` is already present on the props + wrapper.setProps({ viewRangeTime }); + expect(wrapper.prop('viewRangeTime').reframe.anchor).toBe(anchor); + // the next update should integrate `value` and use the existing anchor + instance._draggerReframe._onDragStart({ value }); + const update = { reframe: { anchor, shift } }; + expect(props.updateNextViewRangeTime.mock.calls).toEqual([[update]]); + }); + + it('handles drag end via _draggerReframe._onDragEnd', () => { + const manager = { resetBounds: jest.fn() }; + const value = 0.5; + const shift = mapFromSubRange(viewStart, viewEnd, value); + const anchor = 0.25; + const viewRangeTime = { ...props.viewRangeTime, reframe: { anchor, shift: Math.random() } }; + wrapper.setProps({ viewRangeTime }); + instance._draggerReframe._onDragEnd({ manager, value }); + expect(manager.resetBounds.mock.calls).toEqual([[]]); + expect(props.updateViewRangeTime.mock.calls).toEqual([[anchor, shift, 'timeline-header']]); + }); + }); + + describe('render()', () => { + it('renders nothing without a nextViewRangeTime', () => { + expect(wrapper.find('div').length).toBe(1); + }); + + it('renders the cursor when it is the only non-current value set', () => { + const cursor = viewStart + 0.5 * (viewEnd - viewStart); + const baseViewRangeTime = { ...props.viewRangeTime, cursor }; + wrapper.setProps({ viewRangeTime: baseViewRangeTime }); + // cursor is rendered when solo + expect(wrapper.find('[data-test-id="TimelineViewingLayer--cursorGuide"]').length).toBe(1); + // cursor is skipped when shiftStart, shiftEnd, or reframe are present + let viewRangeTime = { ...baseViewRangeTime, shiftStart: cursor }; + wrapper.setProps({ viewRangeTime }); + expect(wrapper.find('[data-test-id="TimelineViewingLayer--cursorGuide"]').length).toBe(0); + viewRangeTime = { ...baseViewRangeTime, shiftEnd: cursor }; + wrapper.setProps({ viewRangeTime }); + expect(wrapper.find('[data-test-id="TimelineViewingLayer--cursorGuide"]').length).toBe(0); + viewRangeTime = { ...baseViewRangeTime, reframe: { anchor: cursor, shift: cursor } }; + wrapper.setProps({ viewRangeTime }); + expect(wrapper.find('[data-test-id="TimelineViewingLayer--cursorGuide"]').length).toBe(0); + }); + + it('renders the reframe dragging', () => { + const viewRangeTime = { ...props.viewRangeTime, reframe: { anchor: viewStart, shift: viewEnd } }; + wrapper.setProps({ viewRangeTime }); + const styles = getStyles(); + expect( + wrapper + .find('[data-test-id="Dragged"]') + .prop('className') + .indexOf( + cx( + styles.dragged, + styles.draggedDraggingLeft, + styles.draggedDraggingRight, + styles.draggedReframeDrag + ) + ) >= 0 + ).toBe(true); + }); + + it('renders the shiftStart dragging', () => { + const viewRangeTime = { ...props.viewRangeTime, shiftStart: viewEnd }; + wrapper.setProps({ viewRangeTime }); + const styles = getStyles(); + expect( + wrapper + .find('[data-test-id="Dragged"]') + .prop('className') + .indexOf( + cx( + styles.dragged, + styles.draggedDraggingLeft, + styles.draggedDraggingRight, + styles.draggedShiftDrag + ) + ) >= 0 + ).toBe(true); + }); + + it('renders the shiftEnd dragging', () => { + const viewRangeTime = { ...props.viewRangeTime, shiftEnd: viewStart }; + wrapper.setProps({ viewRangeTime }); + // expect(wrapper.find('.isDraggingLeft.isShiftDrag').length).toBe(1); + const styles = getStyles(); + expect( + wrapper + .find('[data-test-id="Dragged"]') + .prop('className') + .indexOf(cx(styles.dragged, styles.draggedDraggingLeft, styles.draggedShiftDrag)) >= 0 + ).toBe(true); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx new file mode 100644 index 00000000000..0f98abf593f --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx @@ -0,0 +1,274 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { css, cx } from 'emotion'; + +import { TUpdateViewRangeTimeFunction, ViewRangeTime, ViewRangeTimeUpdate } from '../types'; +import { TNil } from '../../types'; +import DraggableManager, { DraggableBounds, DraggingUpdate } from '../../utils/DraggableManager'; +import { createStyle } from '../../Theme'; + +// exported for testing +export const getStyles = createStyle(() => { + return { + TimelineViewingLayer: css` + bottom: 0; + cursor: vertical-text; + left: 0; + position: absolute; + right: 0; + top: 0; + `, + cursorGuide: css` + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 1px; + background-color: red; + `, + dragged: css` + position: absolute; + top: 0; + bottom: 0; + `, + draggedDraggingLeft: css` + border-left: 1px solid; + `, + draggedDraggingRight: css` + border-right: 1px solid; + `, + draggedShiftDrag: css` + background-color: rgba(68, 68, 255, 0.2); + border-color: #44f; + `, + draggedReframeDrag: css` + background-color: rgba(255, 68, 68, 0.2); + border-color: #f44; + `, + fullOverlay: css` + bottom: 0; + cursor: col-resize; + left: 0; + position: fixed; + right: 0; + top: 0; + user-select: none; + `, + }; +}); + +type TimelineViewingLayerProps = { + /** + * `boundsInvalidator` is an arbitrary prop that lets the component know the + * bounds for dragging need to be recalculated. In practice, the name column + * width serves fine for this. + */ + boundsInvalidator: any | null | undefined; + updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void; + updateViewRangeTime: TUpdateViewRangeTimeFunction; + viewRangeTime: ViewRangeTime; +}; + +type TDraggingLeftLayout = { + isDraggingLeft: boolean; + left: string; + width: string; +}; + +type TOutOfViewLayout = { + isOutOfView: true; +}; + +function isOutOfView(layout: TDraggingLeftLayout | TOutOfViewLayout): layout is TOutOfViewLayout { + return Reflect.has(layout, 'isOutOfView'); +} + +/** + * Map from a sub range to the greater view range, e.g, when the view range is + * the middle half ([0.25, 0.75]), a value of 0.25 befomes 3/8. + * @returns {number} + */ +function mapFromViewSubRange(viewStart: number, viewEnd: number, value: number) { + return viewStart + value * (viewEnd - viewStart); +} + +/** + * Map a value from the view ([0, 1]) to a sub-range, e.g, when the view range is + * the middle half ([0.25, 0.75]), a value of 3/8 becomes 1/4. + * @returns {number} + */ +function mapToViewSubRange(viewStart: number, viewEnd: number, value: number) { + return (value - viewStart) / (viewEnd - viewStart); +} + +/** + * Get the layout for the "next" view range time, e.g. the difference from the + * drag start and the drag end. This is driven by `shiftStart`, `shiftEnd` or + * `reframe` on `props.viewRangeTime`, not by the current state of the + * component. So, it reflects in-progress dragging from the span minimap. + */ +function getNextViewLayout(start: number, position: number): TDraggingLeftLayout | TOutOfViewLayout { + let [left, right] = start < position ? [start, position] : [position, start]; + if (left >= 1 || right <= 0) { + return { isOutOfView: true }; + } + if (left < 0) { + left = 0; + } + if (right > 1) { + right = 1; + } + return { + isDraggingLeft: start > position, + left: `${left * 100}%`, + width: `${(right - left) * 100}%`, + }; +} + +/** + * Render the visual indication of the "next" view range. + */ +function getMarkers(viewStart: number, viewEnd: number, from: number, to: number, isShift: boolean): React.ReactNode { + const mappedFrom = mapToViewSubRange(viewStart, viewEnd, from); + const mappedTo = mapToViewSubRange(viewStart, viewEnd, to); + const layout = getNextViewLayout(mappedFrom, mappedTo); + if (isOutOfView(layout)) { + return null; + } + const { isDraggingLeft, left, width } = layout; + const styles = getStyles(); + const cls = cx({ + [styles.draggedDraggingRight]: !isDraggingLeft, + [styles.draggedReframeDrag]: !isShift, + [styles.draggedShiftDrag]: isShift, + }); + return ( +
+ ); +} + +/** + * `TimelineViewingLayer` is rendered on top of the TimelineHeaderRow time + * labels; it handles showing the current view range and handles mouse UX for + * modifying it. + */ +export default class TimelineViewingLayer extends React.PureComponent { + _draggerReframe: DraggableManager; + _root: Element | TNil; + + constructor(props: TimelineViewingLayerProps) { + super(props); + this._draggerReframe = new DraggableManager({ + getBounds: this._getDraggingBounds, + onDragEnd: this._handleReframeDragEnd, + onDragMove: this._handleReframeDragUpdate, + onDragStart: this._handleReframeDragUpdate, + onMouseLeave: this._handleReframeMouseLeave, + onMouseMove: this._handleReframeMouseMove, + }); + this._root = undefined; + } + + componentWillReceiveProps(nextProps: TimelineViewingLayerProps) { + const { boundsInvalidator } = this.props; + if (boundsInvalidator !== nextProps.boundsInvalidator) { + this._draggerReframe.resetBounds(); + } + } + + componentWillUnmount() { + this._draggerReframe.dispose(); + } + + _setRoot = (elm: Element | TNil) => { + this._root = elm; + }; + + _getDraggingBounds = (): DraggableBounds => { + if (!this._root) { + throw new Error('invalid state'); + } + const { left: clientXLeft, width } = this._root.getBoundingClientRect(); + return { clientXLeft, width }; + }; + + _handleReframeMouseMove = ({ value }: DraggingUpdate) => { + const [viewStart, viewEnd] = this.props.viewRangeTime.current; + const cursor = mapFromViewSubRange(viewStart, viewEnd, value); + this.props.updateNextViewRangeTime({ cursor }); + }; + + _handleReframeMouseLeave = () => { + this.props.updateNextViewRangeTime({ cursor: undefined }); + }; + + _handleReframeDragUpdate = ({ value }: DraggingUpdate) => { + const { current, reframe } = this.props.viewRangeTime; + const [viewStart, viewEnd] = current; + const shift = mapFromViewSubRange(viewStart, viewEnd, value); + const anchor = reframe ? reframe.anchor : shift; + const update = { reframe: { anchor, shift } }; + this.props.updateNextViewRangeTime(update); + }; + + _handleReframeDragEnd = ({ manager, value }: DraggingUpdate) => { + const { current, reframe } = this.props.viewRangeTime; + const [viewStart, viewEnd] = current; + const shift = mapFromViewSubRange(viewStart, viewEnd, value); + const anchor = reframe ? reframe.anchor : shift; + const [start, end] = shift < anchor ? [shift, anchor] : [anchor, shift]; + manager.resetBounds(); + this.props.updateViewRangeTime(start, end, 'timeline-header'); + }; + + render() { + const { viewRangeTime } = this.props; + const { current, cursor, reframe, shiftEnd, shiftStart } = viewRangeTime; + const [viewStart, viewEnd] = current; + const haveNextTimeRange = reframe != null || shiftEnd != null || shiftStart != null; + let cusrorPosition: string | TNil; + if (!haveNextTimeRange && cursor != null && cursor >= viewStart && cursor <= viewEnd) { + cusrorPosition = `${mapToViewSubRange(viewStart, viewEnd, cursor) * 100}%`; + } + const styles = getStyles(); + return ( +
+ {cusrorPosition != null && ( +
+ )} + {reframe != null && getMarkers(viewStart, viewEnd, reframe.anchor, reframe.shift, false)} + {shiftEnd != null && getMarkers(viewStart, viewEnd, viewEnd, shiftEnd, true)} + {shiftStart != null && getMarkers(viewStart, viewEnd, viewStart, shiftStart, true)} +
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/index.tsx new file mode 100644 index 00000000000..5d134f5e9ac --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/index.tsx @@ -0,0 +1,15 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export { default } from './TimelineHeaderRow'; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineRow.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineRow.tsx new file mode 100644 index 00000000000..21a703bd859 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineRow.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; +import { createStyle } from '../Theme'; +import { ubRelative } from '../uberUtilityStyles'; + +const getStyles = createStyle(() => { + return { + flexRow: css` + display: flex; + flex: 0 1 auto; + flex-direction: row; + `, + }; +}); + +type TTimelineRowProps = { + children: React.ReactNode; + className?: string; +}; + +interface TimelineRowCellProps extends React.HTMLAttributes { + children: React.ReactNode; + className?: string; + width: number; + style?: {}; +} + +export default function TimelineRow(props: TTimelineRowProps) { + const { children, className = '', ...rest } = props; + const styles = getStyles(); + return ( +
+ {children} +
+ ); +} + +TimelineRow.defaultProps = { + className: '', +}; + +export function TimelineRowCell(props: TimelineRowCellProps) { + const { children, className = '', width, style, ...rest } = props; + const widthPercent = `${width * 100}%`; + const mergedStyle = { ...style, flexBasis: widthPercent, maxWidth: widthPercent }; + return ( +
+ {children} +
+ ); +} + +TimelineRowCell.defaultProps = { className: '', style: {} }; + +TimelineRow.Cell = TimelineRowCell; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.js new file mode 100644 index 00000000000..ef79fc92fe9 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.js @@ -0,0 +1,393 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import React from 'react'; +import { shallow, mount } from 'enzyme'; + +import ListView from './ListView'; +import SpanBarRow from './SpanBarRow'; +import DetailState from './SpanDetail/DetailState'; +import SpanDetailRow from './SpanDetailRow'; +import VirtualizedTraceView, { DEFAULT_HEIGHTS } from './VirtualizedTraceView'; +import traceGenerator from '../demo/trace-generators'; +import transformTraceData from '../model/transform-trace-data'; + +jest.mock('./SpanTreeOffset'); + +describe('', () => { + let wrapper; + let instance; + + const trace = transformTraceData(traceGenerator.trace({ numberOfSpans: 10 })); + const props = { + childrenHiddenIDs: new Set(), + childrenToggle: jest.fn(), + clearShouldScrollToFirstUiFindMatch: jest.fn(), + currentViewRangeTime: [0.25, 0.75], + detailLogItemToggle: jest.fn(), + detailLogsToggle: jest.fn(), + detailProcessToggle: jest.fn(), + detailStates: new Map(), + detailTagsToggle: jest.fn(), + detailToggle: jest.fn(), + findMatchesIDs: null, + registerAccessors: jest.fn(), + scrollToFirstVisibleSpan: jest.fn(), + setSpanNameColumnWidth: jest.fn(), + setTrace: jest.fn(), + shouldScrollToFirstUiFindMatch: false, + spanNameColumnWidth: 0.5, + trace, + uiFind: 'uiFind', + }; + + function expandRow(rowIndex) { + const detailStates = new Map(); + const detailState = new DetailState(); + detailStates.set(trace.spans[rowIndex].spanID, detailState); + wrapper.setProps({ detailStates }); + return detailState; + } + + function addSpansAndCollapseTheirParent(newSpanID = 'some-id') { + const childrenHiddenIDs = new Set([newSpanID]); + const spans = [ + trace.spans[0], + // this span is condidered to have collapsed children + { spanID: newSpanID, depth: 1 }, + // these two "spans" are children and should be hidden + { depth: 2 }, + { depth: 3 }, + ...trace.spans.slice(1), + ]; + const _trace = { ...trace, spans }; + wrapper.setProps({ childrenHiddenIDs, trace: _trace }); + return spans; + } + + function updateSpan(srcTrace, spanIndex, update) { + const span = { ...srcTrace.spans[spanIndex], ...update }; + const spans = [...srcTrace.spans.slice(0, spanIndex), span, ...srcTrace.spans.slice(spanIndex + 1)]; + return { ...srcTrace, spans }; + } + + beforeEach(() => { + Object.keys(props).forEach(key => { + if (typeof props[key] === 'function') { + props[key].mockReset(); + } + }); + wrapper = shallow(); + instance = wrapper.instance(); + }); + + it('renders without exploding', () => { + expect(wrapper).toBeDefined(); + }); + + it('renders when a trace is not set', () => { + wrapper.setProps({ trace: null }); + expect(wrapper).toBeDefined(); + }); + + it('renders a ListView', () => { + expect(wrapper.find(ListView)).toBeDefined(); + }); + + it('sets the trace for global state.traceTimeline', () => { + expect(props.setTrace.mock.calls).toEqual([[trace, props.uiFind]]); + props.setTrace.mockReset(); + const traceID = 'some-other-id'; + const _trace = { ...trace, traceID }; + wrapper.setProps({ trace: _trace }); + expect(props.setTrace.mock.calls).toEqual([[_trace, props.uiFind]]); + }); + + describe('props.registerAccessors', () => { + let lv; + let expectedArg; + + beforeEach(() => { + const getBottomRowIndexVisible = () => {}; + const getTopRowIndexVisible = () => {}; + lv = { + getViewHeight: () => {}, + getBottomVisibleIndex: getBottomRowIndexVisible, + getTopVisibleIndex: getTopRowIndexVisible, + getRowPosition: () => {}, + }; + expectedArg = { + getBottomRowIndexVisible, + getTopRowIndexVisible, + getViewHeight: lv.getViewHeight, + getRowPosition: lv.getRowPosition, + getViewRange: instance.getViewRange, + getSearchedSpanIDs: instance.getSearchedSpanIDs, + getCollapsedChildren: instance.getCollapsedChildren, + mapRowIndexToSpanIndex: instance.mapRowIndexToSpanIndex, + mapSpanIndexToRowIndex: instance.mapSpanIndexToRowIndex, + }; + }); + + it('invokes when the listView is set', () => { + expect(props.registerAccessors.mock.calls.length).toBe(0); + instance.setListView(lv); + expect(props.registerAccessors.mock.calls).toEqual([[expectedArg]]); + }); + + it('invokes when registerAccessors changes', () => { + const registerAccessors = jest.fn(); + instance.setListView(lv); + wrapper.setProps({ registerAccessors }); + expect(registerAccessors.mock.calls).toEqual([[expectedArg]]); + }); + }); + + it('returns the current view range via getViewRange()', () => { + expect(instance.getViewRange()).toBe(props.currentViewRangeTime); + }); + + it('returns findMatchesIDs via getSearchedSpanIDs()', () => { + const findMatchesIDs = new Set(); + wrapper.setProps({ findMatchesIDs }); + expect(instance.getSearchedSpanIDs()).toBe(findMatchesIDs); + }); + + it('returns childrenHiddenIDs via getCollapsedChildren()', () => { + const childrenHiddenIDs = new Set(); + wrapper.setProps({ childrenHiddenIDs }); + expect(instance.getCollapsedChildren()).toBe(childrenHiddenIDs); + }); + + describe('mapRowIndexToSpanIndex() maps row index to span index', () => { + it('works when nothing is collapsed or expanded', () => { + const i = trace.spans.length - 1; + expect(instance.mapRowIndexToSpanIndex(i)).toBe(i); + }); + + it('works when a span is expanded', () => { + expandRow(1); + expect(instance.mapRowIndexToSpanIndex(0)).toBe(0); + expect(instance.mapRowIndexToSpanIndex(1)).toBe(1); + expect(instance.mapRowIndexToSpanIndex(2)).toBe(1); + expect(instance.mapRowIndexToSpanIndex(3)).toBe(2); + }); + + it('works when a parent span is collapsed', () => { + addSpansAndCollapseTheirParent(); + expect(instance.mapRowIndexToSpanIndex(0)).toBe(0); + expect(instance.mapRowIndexToSpanIndex(1)).toBe(1); + expect(instance.mapRowIndexToSpanIndex(2)).toBe(4); + expect(instance.mapRowIndexToSpanIndex(3)).toBe(5); + }); + }); + + describe('mapSpanIndexToRowIndex() maps span index to row index', () => { + it('works when nothing is collapsed or expanded', () => { + const i = trace.spans.length - 1; + expect(instance.mapSpanIndexToRowIndex(i)).toBe(i); + }); + + it('works when a span is expanded', () => { + expandRow(1); + expect(instance.mapSpanIndexToRowIndex(0)).toBe(0); + expect(instance.mapSpanIndexToRowIndex(1)).toBe(1); + expect(instance.mapSpanIndexToRowIndex(2)).toBe(3); + expect(instance.mapSpanIndexToRowIndex(3)).toBe(4); + }); + + it('works when a parent span is collapsed', () => { + addSpansAndCollapseTheirParent(); + expect(instance.mapSpanIndexToRowIndex(0)).toBe(0); + expect(instance.mapSpanIndexToRowIndex(1)).toBe(1); + expect(() => instance.mapSpanIndexToRowIndex(2)).toThrow(); + expect(() => instance.mapSpanIndexToRowIndex(3)).toThrow(); + expect(instance.mapSpanIndexToRowIndex(4)).toBe(2); + }); + }); + + describe('getKeyFromIndex() generates a "key" from a row index', () => { + function verify(input, output) { + expect(instance.getKeyFromIndex(input)).toBe(output); + } + + it('works when nothing is expanded or collapsed', () => { + verify(0, `${trace.spans[0].spanID}--bar`); + }); + + it('works when rows are expanded', () => { + expandRow(1); + verify(1, `${trace.spans[1].spanID}--bar`); + verify(2, `${trace.spans[1].spanID}--detail`); + verify(3, `${trace.spans[2].spanID}--bar`); + }); + + it('works when a parent span is collapsed', () => { + const spans = addSpansAndCollapseTheirParent(); + verify(1, `${spans[1].spanID}--bar`); + verify(2, `${spans[4].spanID}--bar`); + }); + }); + + describe('getIndexFromKey() converts a "key" to the corresponding row index', () => { + function verify(input, output) { + expect(instance.getIndexFromKey(input)).toBe(output); + } + + it('works when nothing is expanded or collapsed', () => { + verify(`${trace.spans[0].spanID}--bar`, 0); + }); + + it('works when rows are expanded', () => { + expandRow(1); + verify(`${trace.spans[1].spanID}--bar`, 1); + verify(`${trace.spans[1].spanID}--detail`, 2); + verify(`${trace.spans[2].spanID}--bar`, 3); + }); + + it('works when a parent span is collapsed', () => { + const spans = addSpansAndCollapseTheirParent(); + verify(`${spans[1].spanID}--bar`, 1); + verify(`${spans[4].spanID}--bar`, 2); + }); + }); + + describe('getRowHeight()', () => { + it('returns the expected height for non-detail rows', () => { + expect(instance.getRowHeight(0)).toBe(DEFAULT_HEIGHTS.bar); + }); + + it('returns the expected height for detail rows that do not have logs', () => { + expandRow(0); + expect(instance.getRowHeight(1)).toBe(DEFAULT_HEIGHTS.detail); + }); + + it('returns the expected height for detail rows that do have logs', () => { + const logs = [ + { + timestamp: Date.now(), + fields: traceGenerator.tags(), + }, + ]; + const altTrace = updateSpan(trace, 0, { logs }); + expandRow(0); + wrapper.setProps({ trace: altTrace }); + expect(instance.getRowHeight(1)).toBe(DEFAULT_HEIGHTS.detailWithLogs); + }); + }); + + describe('renderRow()', () => { + it('renders a SpanBarRow when it is not a detail', () => { + const span = trace.spans[1]; + const row = instance.renderRow('some-key', {}, 1, {}); + const rowWrapper = shallow(row); + + expect( + rowWrapper.containsMatchingElement( + + ) + ).toBe(true); + }); + + it('renders a SpanBarRow with a RPC span if the row is collapsed and a client span', () => { + const clientTags = [{ key: 'span.kind', value: 'client' }, ...trace.spans[0].tags]; + const serverTags = [{ key: 'span.kind', value: 'server' }, ...trace.spans[1].tags]; + let altTrace = updateSpan(trace, 0, { tags: clientTags }); + altTrace = updateSpan(altTrace, 1, { tags: serverTags }); + const childrenHiddenIDs = new Set([altTrace.spans[0].spanID]); + wrapper.setProps({ childrenHiddenIDs, trace: altTrace }); + + const rowWrapper = mount(instance.renderRow('some-key', {}, 0, {})); + const spanBarRow = rowWrapper.find(SpanBarRow); + expect(spanBarRow.length).toBe(1); + expect(spanBarRow.prop('rpc')).toBeDefined(); + }); + + it('renders a SpanDetailRow when it is a detail', () => { + const detailState = expandRow(1); + const span = trace.spans[1]; + const row = instance.renderRow('some-key', {}, 2, {}); + const rowWrapper = shallow(row); + expect( + rowWrapper.containsMatchingElement( + + ) + ).toBe(true); + }); + }); + + describe('shouldScrollToFirstUiFindMatch', () => { + const propsWithTrueShouldScrollToFirstUiFindMatch = { ...props, shouldScrollToFirstUiFindMatch: true }; + + beforeEach(() => { + props.scrollToFirstVisibleSpan.mockReset(); + props.clearShouldScrollToFirstUiFindMatch.mockReset(); + }); + + it('calls props.scrollToFirstVisibleSpan if shouldScrollToFirstUiFindMatch is true', () => { + expect(props.scrollToFirstVisibleSpan).not.toHaveBeenCalled(); + expect(props.clearShouldScrollToFirstUiFindMatch).not.toHaveBeenCalled(); + + wrapper.setProps(propsWithTrueShouldScrollToFirstUiFindMatch); + expect(props.scrollToFirstVisibleSpan).toHaveBeenCalledTimes(1); + expect(props.clearShouldScrollToFirstUiFindMatch).toHaveBeenCalledTimes(1); + }); + + describe('shouldComponentUpdate', () => { + it('returns true if props.shouldScrollToFirstUiFindMatch changes to true', () => { + expect(wrapper.instance().shouldComponentUpdate(propsWithTrueShouldScrollToFirstUiFindMatch)).toBe( + true + ); + }); + + it('returns true if props.shouldScrollToFirstUiFindMatch changes to false and another props change', () => { + const propsWithOtherDifferenceAndTrueshouldScrollToFirstUiFindMatch = { + ...propsWithTrueShouldScrollToFirstUiFindMatch, + clearShouldScrollToFirstUiFindMatch: () => {}, + }; + wrapper.setProps(propsWithOtherDifferenceAndTrueshouldScrollToFirstUiFindMatch); + expect(wrapper.instance().shouldComponentUpdate(props)).toBe(true); + }); + + it('returns false if props.shouldScrollToFirstUiFindMatch changes to false and no other props change', () => { + wrapper.setProps(propsWithTrueShouldScrollToFirstUiFindMatch); + expect(wrapper.instance().shouldComponentUpdate(props)).toBe(false); + }); + + it('returns false if all props are unchanged', () => { + expect(wrapper.instance().shouldComponentUpdate(props)).toBe(false); + }); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx new file mode 100644 index 00000000000..6d659e6db38 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx @@ -0,0 +1,456 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { css } from 'emotion'; + +import ListView from './ListView'; +import SpanBarRow from './SpanBarRow'; +import DetailState from './SpanDetail/DetailState'; +import SpanDetailRow from './SpanDetailRow'; +import { + createViewedBoundsFunc, + findServerChildSpan, + isErrorSpan, + spanContainsErredSpan, + ViewedBoundsFunctionType, +} from './utils'; +import { Accessors } from '../ScrollManager'; +import colorGenerator from '../utils/color-generator'; +import { TNil } from '../types'; +import { Log, Span, Trace, KeyValuePair, Link } from '../types/trace'; +import TTraceTimeline from '../types/TTraceTimeline'; + +import { createStyle } from '../Theme'; + +type TExtractUiFindFromStateReturn = { + uiFind: string | undefined; +}; + +const getStyles = createStyle(() => { + return { + rowsWrapper: css` + width: 100%; + `, + row: css` + width: 100%; + `, + }; +}); + +type RowState = { + isDetail: boolean; + span: Span; + spanIndex: number; +}; + +type TVirtualizedTraceViewOwnProps = { + currentViewRangeTime: [number, number]; + findMatchesIDs: Set | TNil; + scrollToFirstVisibleSpan: () => void; + registerAccessors: (accesors: Accessors) => void; + trace: Trace; + focusSpan: (uiFind: string) => void; + linksGetter: (span: Span, items: KeyValuePair[], itemIndex: number) => Link[]; + + // was from redux + childrenToggle: (spanID: string) => void; + clearShouldScrollToFirstUiFindMatch: () => void; + detailLogItemToggle: (spanID: string, log: Log) => void; + detailLogsToggle: (spanID: string) => void; + detailWarningsToggle: (spanID: string) => void; + detailReferencesToggle: (spanID: string) => void; + detailProcessToggle: (spanID: string) => void; + detailTagsToggle: (spanID: string) => void; + detailToggle: (spanID: string) => void; + setSpanNameColumnWidth: (width: number) => void; + setTrace: (trace: Trace | TNil, uiFind: string | TNil) => void; + hoverIndentGuideIds: Set; + addHoverIndentGuideId: (spanID: string) => void; + removeHoverIndentGuideId: (spanID: string) => void; +}; + +type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TExtractUiFindFromStateReturn & TTraceTimeline; + +// export for tests +export const DEFAULT_HEIGHTS = { + bar: 28, + detail: 161, + detailWithLogs: 197, +}; + +const NUM_TICKS = 5; + +function generateRowStates( + spans: Span[] | TNil, + childrenHiddenIDs: Set, + detailStates: Map +): RowState[] { + if (!spans) { + return []; + } + let collapseDepth = null; + const rowStates = []; + for (let i = 0; i < spans.length; i++) { + const span = spans[i]; + const { spanID, depth } = span; + let hidden = false; + if (collapseDepth != null) { + if (depth >= collapseDepth) { + hidden = true; + } else { + collapseDepth = null; + } + } + if (hidden) { + continue; + } + if (childrenHiddenIDs.has(spanID)) { + collapseDepth = depth + 1; + } + rowStates.push({ + span, + isDetail: false, + spanIndex: i, + }); + if (detailStates.has(spanID)) { + rowStates.push({ + span, + isDetail: true, + spanIndex: i, + }); + } + } + return rowStates; +} + +function getClipping(currentViewRange: [number, number]) { + const [zoomStart, zoomEnd] = currentViewRange; + return { + left: zoomStart > 0, + right: zoomEnd < 1, + }; +} + +// export from tests +export default class VirtualizedTraceView extends React.Component { + clipping: { left: boolean; right: boolean }; + listView: ListView | TNil; + rowStates: RowState[]; + getViewedBounds: ViewedBoundsFunctionType; + + constructor(props: VirtualizedTraceViewProps) { + super(props); + // keep "prop derivations" on the instance instead of calculating in + // `.render()` to avoid recalculating in every invocation of `.renderRow()` + const { currentViewRangeTime, childrenHiddenIDs, detailStates, setTrace, trace, uiFind } = props; + this.clipping = getClipping(currentViewRangeTime); + const [zoomStart, zoomEnd] = currentViewRangeTime; + this.getViewedBounds = createViewedBoundsFunc({ + min: trace.startTime, + max: trace.endTime, + viewStart: zoomStart, + viewEnd: zoomEnd, + }); + this.rowStates = generateRowStates(trace.spans, childrenHiddenIDs, detailStates); + + setTrace(trace, uiFind); + } + + shouldComponentUpdate(nextProps: VirtualizedTraceViewProps) { + // If any prop updates, VirtualizedTraceViewImpl should update. + const nextPropKeys = Object.keys(nextProps) as Array; + for (let i = 0; i < nextPropKeys.length; i += 1) { + if (nextProps[nextPropKeys[i]] !== this.props[nextPropKeys[i]]) { + // Unless the only change was props.shouldScrollToFirstUiFindMatch changing to false. + if (nextPropKeys[i] === 'shouldScrollToFirstUiFindMatch') { + if (nextProps[nextPropKeys[i]]) { + return true; + } + } else { + return true; + } + } + } + return false; + } + + componentWillUpdate(nextProps: VirtualizedTraceViewProps) { + const { childrenHiddenIDs, detailStates, registerAccessors, trace, currentViewRangeTime } = this.props; + const { + currentViewRangeTime: nextViewRangeTime, + childrenHiddenIDs: nextHiddenIDs, + detailStates: nextDetailStates, + registerAccessors: nextRegisterAccessors, + setTrace, + trace: nextTrace, + uiFind, + } = nextProps; + if (trace !== nextTrace) { + setTrace(nextTrace, uiFind); + } + if (trace !== nextTrace || childrenHiddenIDs !== nextHiddenIDs || detailStates !== nextDetailStates) { + this.rowStates = nextTrace ? generateRowStates(nextTrace.spans, nextHiddenIDs, nextDetailStates) : []; + } + if (currentViewRangeTime !== nextViewRangeTime) { + this.clipping = getClipping(nextViewRangeTime); + const [zoomStart, zoomEnd] = nextViewRangeTime; + this.getViewedBounds = createViewedBoundsFunc({ + min: trace.startTime, + max: trace.endTime, + viewStart: zoomStart, + viewEnd: zoomEnd, + }); + } + if (this.listView && registerAccessors !== nextRegisterAccessors) { + nextRegisterAccessors(this.getAccessors()); + } + } + + componentDidUpdate() { + const { + shouldScrollToFirstUiFindMatch, + clearShouldScrollToFirstUiFindMatch, + scrollToFirstVisibleSpan, + } = this.props; + if (shouldScrollToFirstUiFindMatch) { + scrollToFirstVisibleSpan(); + clearShouldScrollToFirstUiFindMatch(); + } + } + + getAccessors() { + const lv = this.listView; + if (!lv) { + throw new Error('ListView unavailable'); + } + return { + getViewRange: this.getViewRange, + getSearchedSpanIDs: this.getSearchedSpanIDs, + getCollapsedChildren: this.getCollapsedChildren, + getViewHeight: lv.getViewHeight, + getBottomRowIndexVisible: lv.getBottomVisibleIndex, + getTopRowIndexVisible: lv.getTopVisibleIndex, + getRowPosition: lv.getRowPosition, + mapRowIndexToSpanIndex: this.mapRowIndexToSpanIndex, + mapSpanIndexToRowIndex: this.mapSpanIndexToRowIndex, + }; + } + + getViewRange = () => this.props.currentViewRangeTime; + + getSearchedSpanIDs = () => this.props.findMatchesIDs; + + getCollapsedChildren = () => this.props.childrenHiddenIDs; + + mapRowIndexToSpanIndex = (index: number) => this.rowStates[index].spanIndex; + + mapSpanIndexToRowIndex = (index: number) => { + const max = this.rowStates.length; + for (let i = 0; i < max; i++) { + const { spanIndex } = this.rowStates[i]; + if (spanIndex === index) { + return i; + } + } + throw new Error(`unable to find row for span index: ${index}`); + }; + + setListView = (listView: ListView | TNil) => { + const isChanged = this.listView !== listView; + this.listView = listView; + if (listView && isChanged) { + this.props.registerAccessors(this.getAccessors()); + } + }; + + // use long form syntax to avert flow error + // https://github.com/facebook/flow/issues/3076#issuecomment-290944051 + getKeyFromIndex = (index: number) => { + const { isDetail, span } = this.rowStates[index]; + return `${span.spanID}--${isDetail ? 'detail' : 'bar'}`; + }; + + getIndexFromKey = (key: string) => { + const parts = key.split('--'); + const _spanID = parts[0]; + const _isDetail = parts[1] === 'detail'; + const max = this.rowStates.length; + for (let i = 0; i < max; i++) { + const { span, isDetail } = this.rowStates[i]; + if (span.spanID === _spanID && isDetail === _isDetail) { + return i; + } + } + return -1; + }; + + getRowHeight = (index: number) => { + const { span, isDetail } = this.rowStates[index]; + if (!isDetail) { + return DEFAULT_HEIGHTS.bar; + } + if (Array.isArray(span.logs) && span.logs.length) { + return DEFAULT_HEIGHTS.detailWithLogs; + } + return DEFAULT_HEIGHTS.detail; + }; + + renderRow = (key: string, style: React.CSSProperties, index: number, attrs: {}) => { + const { isDetail, span, spanIndex } = this.rowStates[index]; + return isDetail + ? this.renderSpanDetailRow(span, key, style, attrs) + : this.renderSpanBarRow(span, spanIndex, key, style, attrs); + }; + + renderSpanBarRow(span: Span, spanIndex: number, key: string, style: React.CSSProperties, attrs: {}) { + const { spanID } = span; + const { serviceName } = span.process; + const { + childrenHiddenIDs, + childrenToggle, + detailStates, + detailToggle, + findMatchesIDs, + spanNameColumnWidth, + trace, + focusSpan, + hoverIndentGuideIds, + addHoverIndentGuideId, + removeHoverIndentGuideId, + } = this.props; + // to avert flow error + if (!trace) { + return null; + } + const color = colorGenerator.getColorByKey(serviceName); + const isCollapsed = childrenHiddenIDs.has(spanID); + const isDetailExpanded = detailStates.has(spanID); + const isMatchingFilter = findMatchesIDs ? findMatchesIDs.has(spanID) : false; + const showErrorIcon = isErrorSpan(span) || (isCollapsed && spanContainsErredSpan(trace.spans, spanIndex)); + + // Check for direct child "server" span if the span is a "client" span. + let rpc = null; + if (isCollapsed) { + const rpcSpan = findServerChildSpan(trace.spans.slice(spanIndex)); + if (rpcSpan) { + const rpcViewBounds = this.getViewedBounds(rpcSpan.startTime, rpcSpan.startTime + rpcSpan.duration); + rpc = { + color: colorGenerator.getColorByKey(rpcSpan.process.serviceName), + operationName: rpcSpan.operationName, + serviceName: rpcSpan.process.serviceName, + viewEnd: rpcViewBounds.end, + viewStart: rpcViewBounds.start, + }; + } + } + const styles = getStyles(); + return ( +
+ +
+ ); + } + + renderSpanDetailRow(span: Span, key: string, style: React.CSSProperties, attrs: {}) { + const { spanID } = span; + const { serviceName } = span.process; + const { + detailLogItemToggle, + detailLogsToggle, + detailProcessToggle, + detailReferencesToggle, + detailWarningsToggle, + detailStates, + detailTagsToggle, + detailToggle, + spanNameColumnWidth, + trace, + focusSpan, + hoverIndentGuideIds, + addHoverIndentGuideId, + removeHoverIndentGuideId, + linksGetter, + } = this.props; + const detailState = detailStates.get(spanID); + if (!trace || !detailState) { + return null; + } + const color = colorGenerator.getColorByKey(serviceName); + const styles = getStyles(); + return ( +
+ +
+ ); + } + + render() { + const styles = getStyles(); + return ( +
+ +
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/index.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/index.test.js new file mode 100644 index 00000000000..2b3894005bc --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/index.test.js @@ -0,0 +1,82 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import TraceTimelineViewer from './index'; +import traceGenerator from '../demo/trace-generators'; +import transformTraceData from '../model/transform-trace-data'; +import TimelineHeaderRow from './TimelineHeaderRow'; +import { defaultTheme } from '../Theme'; + +describe('', () => { + const trace = transformTraceData(traceGenerator.trace({})); + const props = { + trace, + textFilter: null, + viewRange: { + time: { + current: [0, 1], + }, + }, + traceTimeline: { + spanNameColumnWidth: 0.5, + }, + expandAll: jest.fn(), + collapseAll: jest.fn(), + expandOne: jest.fn(), + collapseOne: jest.fn(), + theme: defaultTheme, + history: { + replace: () => {}, + }, + location: { + search: null, + }, + }; + const options = { + context: { + store: { + getState() { + return { traceTimeline: { spanNameColumnWidth: 0.25 } }; + }, + subscribe() {}, + dispatch() {}, + }, + }, + }; + + let wrapper; + + beforeEach(() => { + wrapper = shallow(, options); + }); + + it('it does not explode', () => { + expect(wrapper).toBeDefined(); + }); + + it('it sets up actions', () => { + const headerRow = wrapper.find(TimelineHeaderRow); + headerRow.props().onCollapseAll(); + headerRow.props().onExpandAll(); + headerRow.props().onExpandOne(); + headerRow.props().onCollapseOne(); + expect(props.collapseAll.mock.calls.length).toBe(1); + expect(props.expandAll.mock.calls.length).toBe(1); + expect(props.expandOne.mock.calls.length).toBe(1); + expect(props.collapseOne.mock.calls.length).toBe(1); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx new file mode 100644 index 00000000000..26d04ba1dee --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx @@ -0,0 +1,174 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { css } from 'emotion'; + +import TimelineHeaderRow from './TimelineHeaderRow'; +import VirtualizedTraceView from './VirtualizedTraceView'; +import { merge as mergeShortcuts } from '../keyboard-shortcuts'; +import { Accessors } from '../ScrollManager'; +import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from './types'; +import { TNil } from '../types'; +import { Span, Trace, Log, KeyValuePair, Link } from '../types/trace'; +import TTraceTimeline from '../types/TTraceTimeline'; +import { createStyle } from '../Theme'; +import ExternalLinkContext from '../url/externalLinkContext'; + +type TExtractUiFindFromStateReturn = { + uiFind: string | undefined; +}; + +const getStyles = createStyle(() => { + return { + TraceTimelineViewer: css` + border-bottom: 1px solid #bbb; + + & .json-markup { + line-height: 17px; + font-size: 13px; + font-family: monospace; + white-space: pre-wrap; + } + + & .json-markup-key { + font-weight: bold; + } + + & .json-markup-bool { + color: firebrick; + } + + & .json-markup-string { + color: teal; + } + + & .json-markup-null { + color: teal; + } + + & .json-markup-number { + color: blue; + } + `, + }; +}); + +type TProps = TExtractUiFindFromStateReturn & { + registerAccessors: (accessors: Accessors) => void; + findMatchesIDs: Set | TNil; + scrollToFirstVisibleSpan: () => void; + traceTimeline: TTraceTimeline; + trace: Trace; + updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void; + updateViewRangeTime: TUpdateViewRangeTimeFunction; + viewRange: ViewRange; + focusSpan: (uiFind: string) => void; + createLinkToExternalSpan: (traceID: string, spanID: string) => string; + + setSpanNameColumnWidth: (width: number) => void; + collapseAll: (spans: Span[]) => void; + collapseOne: (spans: Span[]) => void; + expandAll: () => void; + expandOne: (spans: Span[]) => void; + + childrenToggle: (spanID: string) => void; + clearShouldScrollToFirstUiFindMatch: () => void; + detailLogItemToggle: (spanID: string, log: Log) => void; + detailLogsToggle: (spanID: string) => void; + detailWarningsToggle: (spanID: string) => void; + detailReferencesToggle: (spanID: string) => void; + detailProcessToggle: (spanID: string) => void; + detailTagsToggle: (spanID: string) => void; + detailToggle: (spanID: string) => void; + setTrace: (trace: Trace | TNil, uiFind: string | TNil) => void; + addHoverIndentGuideId: (spanID: string) => void; + removeHoverIndentGuideId: (spanID: string) => void; + linksGetter: (span: Span, items: KeyValuePair[], itemIndex: number) => Link[]; +}; + +const NUM_TICKS = 5; + +/** + * `TraceTimelineViewer` now renders the header row because it is sensitive to + * `props.viewRange.time.cursor`. If `VirtualizedTraceView` renders it, it will + * re-render the ListView every time the cursor is moved on the trace minimap + * or `TimelineHeaderRow`. + */ +export default class TraceTimelineViewer extends React.PureComponent { + componentDidMount() { + mergeShortcuts({ + collapseAll: this.collapseAll, + expandAll: this.expandAll, + collapseOne: this.collapseOne, + expandOne: this.expandOne, + }); + } + + collapseAll = () => { + this.props.collapseAll(this.props.trace.spans); + }; + + collapseOne = () => { + this.props.collapseOne(this.props.trace.spans); + }; + + expandAll = () => { + this.props.expandAll(); + }; + + expandOne = () => { + this.props.expandOne(this.props.trace.spans); + }; + + render() { + const { + setSpanNameColumnWidth, + updateNextViewRangeTime, + updateViewRangeTime, + viewRange, + createLinkToExternalSpan, + traceTimeline, + ...rest + } = this.props; + const { trace } = rest; + const styles = getStyles(); + + return ( + +
+ + +
+
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/types.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/types.tsx new file mode 100644 index 00000000000..c427b0ecd3d --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/types.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TNil } from '../types'; + +interface TimeCursorUpdate { + cursor: number | TNil; +} + +interface TimeReframeUpdate { + reframe: { + anchor: number; + shift: number; + }; +} + +interface TimeShiftEndUpdate { + shiftEnd: number; +} + +interface TimeShiftStartUpdate { + shiftStart: number; +} + +export type TUpdateViewRangeTimeFunction = (start: number, end: number, trackSrc?: string) => void; + +export type ViewRangeTimeUpdate = TimeCursorUpdate | TimeReframeUpdate | TimeShiftEndUpdate | TimeShiftStartUpdate; + +export interface ViewRangeTime { + current: [number, number]; + cursor?: number | TNil; + reframe?: { + anchor: number; + shift: number; + }; + shiftEnd?: number; + shiftStart?: number; +} + +export interface ViewRange { + time: ViewRangeTime; +} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/utils.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/utils.test.js new file mode 100644 index 00000000000..e4fe7cf76ba --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/utils.test.js @@ -0,0 +1,157 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + findServerChildSpan, + createViewedBoundsFunc, + isClientSpan, + isErrorSpan, + isServerSpan, + spanContainsErredSpan, + spanHasTag, +} from './utils'; + +import traceGenerator from '../demo/trace-generators'; + +describe('TraceTimelineViewer/utils', () => { + describe('getViewedBounds()', () => { + it('works for the full range', () => { + const args = { min: 1, max: 2, viewStart: 0, viewEnd: 1 }; + const { start, end } = createViewedBoundsFunc(args)(1, 2); + expect(start).toBe(0); + expect(end).toBe(1); + }); + + it('works for a sub-range with a full view', () => { + const args = { min: 1, max: 2, viewStart: 0, viewEnd: 1 }; + const { start, end } = createViewedBoundsFunc(args)(1.25, 1.75); + expect(start).toBe(0.25); + expect(end).toBe(0.75); + }); + + it('works for a sub-range that fills the view', () => { + const args = { min: 1, max: 2, viewStart: 0.25, viewEnd: 0.75 }; + const { start, end } = createViewedBoundsFunc(args)(1.25, 1.75); + expect(start).toBe(0); + expect(end).toBe(1); + }); + + it('works for a sub-range that within a sub-view', () => { + const args = { min: 100, max: 200, viewStart: 0.1, viewEnd: 0.9 }; + const { start, end } = createViewedBoundsFunc(args)(130, 170); + expect(start).toBe(0.25); + expect(end).toBe(0.75); + }); + }); + + describe('spanHasTag() and variants', () => { + it('returns true iff the key/value pair is found', () => { + const tags = traceGenerator.tags(); + tags.push({ key: 'span.kind', value: 'server' }); + expect(spanHasTag('span.kind', 'client', { tags })).toBe(false); + expect(spanHasTag('span.kind', 'client', { tags })).toBe(false); + expect(spanHasTag('span.kind', 'server', { tags })).toBe(true); + }); + + const spanTypeTestCases = [ + { fn: isClientSpan, name: 'isClientSpan', key: 'span.kind', value: 'client' }, + { fn: isServerSpan, name: 'isServerSpan', key: 'span.kind', value: 'server' }, + { fn: isErrorSpan, name: 'isErrorSpan', key: 'error', value: true }, + { fn: isErrorSpan, name: 'isErrorSpan', key: 'error', value: 'true' }, + ]; + + spanTypeTestCases.forEach(testCase => { + const msg = `${testCase.name}() is true only when a ${testCase.key}=${testCase.value} tag is present`; + it(msg, () => { + const span = { tags: traceGenerator.tags() }; + expect(testCase.fn(span)).toBe(false); + span.tags.push(testCase); + expect(testCase.fn(span)).toBe(true); + }); + }); + }); + + describe('spanContainsErredSpan()', () => { + it('returns true only when a descendant has an error tag', () => { + const errorTag = { key: 'error', type: 'bool', value: true }; + const getTags = withError => + withError ? traceGenerator.tags().concat(errorTag) : traceGenerator.tags(); + + // Using a string to generate the test spans. Each line results in a span. The + // left number indicates whether or not the generated span has a descendant + // with an error tag (the expectation). The length of the line indicates the + // depth of the span (i.e. further right is higher depth). The right number + // indicates whether or not the span has an error tag. + const config = ` + 1 0 + 1 0 + 0 1 + 0 0 + 1 0 + 1 1 + 0 1 + 0 0 + 1 0 + 0 1 + 0 0 + ` + .trim() + .split('\n') + .map(s => s.trim()); + // Get the expectation, str -> number -> bool + const expectations = config.map(s => Boolean(Number(s[0]))); + const spans = config.map(line => ({ + depth: line.length, + tags: getTags(+line.slice(-1)), + })); + + expectations.forEach((target, i) => { + // include the index in the expect condition to know which span failed + // (if there is a failure, that is) + const result = [i, spanContainsErredSpan(spans, i)]; + expect(result).toEqual([i, target]); + }); + }); + }); + + describe('findServerChildSpan()', () => { + let spans; + + beforeEach(() => { + spans = [ + { depth: 0, tags: [{ key: 'span.kind', value: 'client' }] }, + { depth: 1, tags: [] }, + { depth: 1, tags: [{ key: 'span.kind', value: 'server' }] }, + { depth: 1, tags: [{ key: 'span.kind', value: 'third-kind' }] }, + { depth: 1, tags: [{ key: 'span.kind', value: 'server' }] }, + ]; + }); + + it('returns falsy if the frist span is not a client', () => { + expect(findServerChildSpan(spans.slice(1))).toBeFalsy(); + }); + + it('returns the first server span', () => { + const span = findServerChildSpan(spans); + expect(span).toBe(spans[2]); + }); + + it('bails when a non-child-depth span is encountered', () => { + spans[1].depth++; + expect(findServerChildSpan(spans)).toBeFalsy(); + spans[1].depth = spans[0].depth; + expect(findServerChildSpan(spans)).toBeFalsy(); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/utils.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/utils.tsx new file mode 100644 index 00000000000..6ea0c3742c1 --- /dev/null +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/utils.tsx @@ -0,0 +1,115 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Span } from '../types/trace'; + +export type ViewedBoundsFunctionType = (start: number, end: number) => { start: number; end: number }; +/** + * Given a range (`min`, `max`) and factoring in a zoom (`viewStart`, `viewEnd`) + * a function is created that will find the position of a sub-range (`start`, `end`). + * The calling the generated method will return the result as a `{ start, end }` + * object with values ranging in [0, 1]. + * + * @param {number} min The start of the outer range. + * @param {number} max The end of the outer range. + * @param {number} viewStart The start of the zoom, on a range of [0, 1], + * relative to the `min`, `max`. + * @param {number} viewEnd The end of the zoom, on a range of [0, 1], + * relative to the `min`, `max`. + * @returns {(number, number) => Object} Created view bounds function + */ +export function createViewedBoundsFunc(viewRange: { min: number; max: number; viewStart: number; viewEnd: number }) { + const { min, max, viewStart, viewEnd } = viewRange; + const duration = max - min; + const viewMin = min + viewStart * duration; + const viewMax = max - (1 - viewEnd) * duration; + const viewWindow = viewMax - viewMin; + + /** + * View bounds function + * @param {number} start The start of the sub-range. + * @param {number} end The end of the sub-range. + * @return {Object} The resultant range. + */ + return (start: number, end: number) => ({ + start: (start - viewMin) / viewWindow, + end: (end - viewMin) / viewWindow, + }); +} + +/** + * Returns `true` if the `span` has a tag matching `key` = `value`. + * + * @param {string} key The tag key to match on. + * @param {any} value The tag value to match. + * @param {{tags}} span An object with a `tags` property of { key, value } + * items. + * @return {boolean} True if a match was found. + */ +export function spanHasTag(key: string, value: any, span: Span) { + if (!Array.isArray(span.tags) || !span.tags.length) { + return false; + } + return span.tags.some(tag => tag.key === key && tag.value === value); +} + +export const isClientSpan = spanHasTag.bind(null, 'span.kind', 'client'); +export const isServerSpan = spanHasTag.bind(null, 'span.kind', 'server'); + +const isErrorBool = spanHasTag.bind(null, 'error', true); +const isErrorStr = spanHasTag.bind(null, 'error', 'true'); +export const isErrorSpan = (span: Span) => isErrorBool(span) || isErrorStr(span); + +/** + * Returns `true` if at least one of the descendants of the `parentSpanIndex` + * span contains an error tag. + * + * @param {Span[]} spans The spans for a trace - should be + * sorted with children following parents. + * @param {number} parentSpanIndex The index of the parent span - only + * subsequent spans with depth less than + * the parent span will be checked. + * @return {boolean} Returns `true` if a descendant contains an error tag. + */ +export function spanContainsErredSpan(spans: Span[], parentSpanIndex: number) { + const { depth } = spans[parentSpanIndex]; + let i = parentSpanIndex + 1; + for (; i < spans.length && spans[i].depth > depth; i++) { + if (isErrorSpan(spans[i])) { + return true; + } + } + return false; +} + +/** + * Expects the first span to be the parent span. + */ +export function findServerChildSpan(spans: Span[]) { + if (spans.length <= 1 || !isClientSpan(spans[0])) { + return false; + } + const span = spans[0]; + const spanChildDepth = span.depth + 1; + let i = 1; + while (i < spans.length && spans[i].depth === spanChildDepth) { + if (isServerSpan(spans[i])) { + return spans[i]; + } + i++; + } + return null; +} + +export { formatDuration } from '../utils/date'; diff --git a/packages/jaeger-ui-components/src/Tween.test.js b/packages/jaeger-ui-components/src/Tween.test.js new file mode 100644 index 00000000000..d5a47184001 --- /dev/null +++ b/packages/jaeger-ui-components/src/Tween.test.js @@ -0,0 +1,194 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Tween from './Tween'; + +describe('Tween', () => { + const oldNow = Date.now; + const nowFn = jest.fn(); + const oldSetTimeout = window.setTimeout; + const setTimeoutFn = jest.fn(); + const oldRaf = window.requestAnimationFrame; + const rafFn = jest.fn(); + + const baseOptions = { duration: 10, from: 0, to: 1 }; + + Date.now = nowFn; + window.setTimeout = setTimeoutFn; + window.requestAnimationFrame = rafFn; + + beforeEach(() => { + nowFn.mockReset(); + nowFn.mockReturnValue(0); + setTimeoutFn.mockReset(); + rafFn.mockReset(); + }); + + afterAll(() => { + Date.now = oldNow; + window.setTimeout = oldSetTimeout; + window.requestAnimationFrame = oldRaf; + }); + + describe('ctor', () => { + it('set startTime to the current time', () => { + const n = Math.random(); + nowFn.mockReturnValue(n); + const tween = new Tween(baseOptions); + expect(tween.startTime).toBe(n); + }); + + it('adds delay to the startTime', () => { + const n = Math.random(); + nowFn.mockReturnValue(n); + const tween = new Tween({ ...baseOptions, delay: 10 }); + expect(tween.startTime).toBe(n + 10); + }); + + describe('with callbacks', () => { + it('schedules setTimeout if there is a delay', () => { + const delay = 10; + const tween = new Tween({ ...baseOptions, delay, onUpdate: jest.fn() }); + expect(setTimeoutFn).lastCalledWith(tween._frameCallback, delay); + }); + + it('schedules animation frame if there isnt a delay', () => { + const tween = new Tween({ ...baseOptions, onUpdate: jest.fn() }); + expect(rafFn).lastCalledWith(tween._frameCallback); + }); + }); + }); + + describe('getCurrent()', () => { + it('returns `{done: false, value: from}` when time is before the delay is finished', () => { + const tween = new Tween({ ...baseOptions, delay: 1 }); + const current = tween.getCurrent(); + expect(current).toEqual({ done: false, value: baseOptions.from }); + }); + + describe('in progress tweens', () => { + it('returns `{done: false...`}', () => { + const tween = new Tween(baseOptions); + nowFn.mockReturnValue(1); + const current = tween.getCurrent(); + expect(current.done).toBe(false); + expect(nowFn()).toBeLessThan(tween.startTime + tween.duration); + expect(nowFn()).toBeGreaterThan(tween.startTime); + }); + + it('progresses `{..., value} as time progresses', () => { + const tween = new Tween(baseOptions); + let lastValue = tween.getCurrent().value; + for (let i = 1; i < baseOptions.duration; i++) { + nowFn.mockReturnValue(i); + const { done, value } = tween.getCurrent(); + expect(done).toBe(false); + expect(value).toBeGreaterThan(lastValue); + lastValue = value; + } + }); + }); + + it('returns `{done: true, value: to}` when the time is past the duration', () => { + const tween = new Tween(baseOptions); + nowFn.mockReturnValue(baseOptions.duration); + const current = tween.getCurrent(); + expect(current).toEqual({ done: true, value: baseOptions.to }); + }); + }); + + describe('_frameCallback', () => { + it('freezes the callback argument', () => { + let current; + const fn = jest.fn(_current => { + current = _current; + }); + const tween = new Tween({ ...baseOptions, onUpdate: fn }); + tween._frameCallback(); + expect(current).toBeDefined(); + const copy = { ...current }; + try { + current.done = !current.done; + // eslint-disable-next-line no-empty + } catch (_) {} + expect(current).toEqual(copy); + }); + + it('calls onUpdate if there is an onUpdate callback', () => { + const fn = jest.fn(); + const tween = new Tween({ ...baseOptions, onUpdate: fn }); + tween._frameCallback(); + const current = tween.getCurrent(); + expect(current).toBeDefined(); + expect(fn).lastCalledWith(current); + }); + + it('does not call onComplete if there is an onComplete callback and the tween is not complete', () => { + const fn = jest.fn(); + const tween = new Tween({ ...baseOptions, onComplete: fn }); + tween._frameCallback(); + expect(fn.mock.calls.length).toBe(0); + }); + + it('calls onComplete if there is an onComplete callback and the tween is complete', () => { + const fn = jest.fn(); + const tween = new Tween({ ...baseOptions, onComplete: fn }); + nowFn.mockReturnValue(nowFn() + baseOptions.duration); + tween._frameCallback(); + const current = tween.getCurrent(); + expect(fn.mock.calls).toEqual([[current]]); + expect(current.done).toBe(true); + }); + + it('schedules an animatinon frame if the tween is not complete', () => { + expect(rafFn.mock.calls.length).toBe(0); + const tween = new Tween({ ...baseOptions, onUpdate: () => {} }); + nowFn.mockReturnValue(nowFn() + 0.5 * baseOptions.duration); + rafFn.mockReset(); + tween._frameCallback(); + expect(rafFn.mock.calls).toEqual([[tween._frameCallback]]); + }); + }); + + describe('cancel()', () => { + it('cancels scheduled timeouts or animation frames', () => { + const oldClearTimeout = window.clearTimeout; + const oldCancelRaf = window.cancelAnimationFrame; + const clearFn = jest.fn(); + window.clearTimeout = clearFn; + const cancelFn = jest.fn(); + window.cancelAnimationFrame = cancelFn; + + const tween = new Tween(baseOptions); + const id = 1; + tween.timeoutID = id; + tween.requestID = id; + tween.cancel(); + expect(clearFn.mock.calls).toEqual([[id]]); + expect(cancelFn.mock.calls).toEqual([[id]]); + expect(tween.timeoutID).toBe(undefined); + expect(tween.requestID).toBe(undefined); + + window.clearTimeout = oldClearTimeout; + window.cancelAnimationFrame = oldCancelRaf; + }); + + it('releases references to callbacks', () => { + const tween = new Tween({ ...baseOptions, onComplete: () => {}, onUpdate: () => {} }); + tween.cancel(); + expect(tween.callbackComplete).toBe(undefined); + expect(tween.callbackUpdate).toBe(undefined); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/Tween.tsx b/packages/jaeger-ui-components/src/Tween.tsx new file mode 100644 index 00000000000..72adf26cf19 --- /dev/null +++ b/packages/jaeger-ui-components/src/Tween.tsx @@ -0,0 +1,114 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ease from 'tween-functions'; + +import { TNil } from './types'; + +interface ITweenState { + done: boolean; + value: number; +} + +type TTweenCallback = (state: ITweenState) => void; + +type TTweenOptions = { + delay?: number; + duration: number; + from: number; + onComplete?: TTweenCallback; + onUpdate?: TTweenCallback; + to: number; +}; + +export default class Tween { + callbackComplete: TTweenCallback | TNil; + callbackUpdate: TTweenCallback | TNil; + delay: number | TNil; + duration: number; + from: number; + requestID: number | TNil; + startTime: number; + timeoutID: number | TNil; + to: number; + + constructor({ duration, from, to, delay, onUpdate, onComplete }: TTweenOptions) { + this.startTime = Date.now() + (delay || 0); + this.duration = duration; + this.from = from; + this.to = to; + if (!onUpdate && !onComplete) { + this.callbackComplete = undefined; + this.callbackUpdate = undefined; + this.timeoutID = undefined; + this.requestID = undefined; + } else { + this.callbackComplete = onComplete; + this.callbackUpdate = onUpdate; + if (delay) { + // setTimeout from @types/node returns NodeJS.Timeout, so prefix with `window.` + this.timeoutID = window.setTimeout(this._frameCallback, delay); + this.requestID = undefined; + } else { + this.requestID = window.requestAnimationFrame(this._frameCallback); + this.timeoutID = undefined; + } + } + } + + _frameCallback = () => { + this.timeoutID = undefined; + this.requestID = undefined; + const current = Object.freeze(this.getCurrent()); + if (this.callbackUpdate) { + this.callbackUpdate(current); + } + if (this.callbackComplete && current.done) { + this.callbackComplete(current); + } + if (current.done) { + this.callbackComplete = undefined; + this.callbackUpdate = undefined; + } else { + this.requestID = window.requestAnimationFrame(this._frameCallback); + } + }; + + cancel() { + if (this.timeoutID != null) { + clearTimeout(this.timeoutID); + this.timeoutID = undefined; + } + if (this.requestID != null) { + window.cancelAnimationFrame(this.requestID); + this.requestID = undefined; + } + this.callbackComplete = undefined; + this.callbackUpdate = undefined; + } + + getCurrent(): ITweenState { + const t = Date.now() - this.startTime; + if (t <= 0) { + // still in the delay period + return { done: false, value: this.from }; + } + if (t >= this.duration) { + // after the expiration + return { done: true, value: this.to }; + } + // mid-tween + return { done: false, value: ease.easeOutQuint(t, this.from, this.to, this.duration) }; + } +} diff --git a/packages/jaeger-ui-components/src/common/CopyIcon.test.js b/packages/jaeger-ui-components/src/common/CopyIcon.test.js new file mode 100644 index 00000000000..5c6fc38386c --- /dev/null +++ b/packages/jaeger-ui-components/src/common/CopyIcon.test.js @@ -0,0 +1,70 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; +import * as copy from 'copy-to-clipboard'; +import { UIButton, UITooltip } from '../uiElementsContext'; + +import CopyIcon from './CopyIcon'; + +jest.mock('copy-to-clipboard'); + +describe('', () => { + const props = { + className: 'classNameValue', + copyText: 'copyTextValue', + tooltipTitle: 'tooltipTitleValue', + }; + let copySpy; + let wrapper; + + beforeAll(() => { + copySpy = jest.spyOn(copy, 'default'); + }); + + beforeEach(() => { + copySpy.mockReset(); + wrapper = shallow(); + }); + + it('renders as expected', () => { + expect(wrapper).toMatchSnapshot(); + }); + + it('updates state and copies when clicked', () => { + expect(wrapper.state().hasCopied).toBe(false); + expect(copySpy).not.toHaveBeenCalled(); + + wrapper.find(UIButton).simulate('click'); + expect(wrapper.state().hasCopied).toBe(true); + expect(copySpy).toHaveBeenCalledWith(props.copyText); + }); + + it('updates state when tooltip hides and state.hasCopied is true', () => { + wrapper.setState({ hasCopied: true }); + wrapper.find(UITooltip).prop('onVisibleChange')(false); + expect(wrapper.state().hasCopied).toBe(false); + + const state = wrapper.state(); + wrapper.find(UITooltip).prop('onVisibleChange')(false); + expect(wrapper.state()).toBe(state); + }); + + it('persists state when tooltip opens', () => { + wrapper.setState({ hasCopied: true }); + wrapper.find(UITooltip).prop('onVisibleChange')(true); + expect(wrapper.state().hasCopied).toBe(true); + }); +}); diff --git a/packages/jaeger-ui-components/src/common/CopyIcon.tsx b/packages/jaeger-ui-components/src/common/CopyIcon.tsx new file mode 100644 index 00000000000..ff31e733ead --- /dev/null +++ b/packages/jaeger-ui-components/src/common/CopyIcon.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; +import copy from 'copy-to-clipboard'; + +import { UITooltip, TooltipPlacement, UIButton } from '../uiElementsContext'; +import { createStyle } from '../Theme'; + +const getStyles = createStyle(() => { + return { + CopyIcon: css` + background-color: transparent; + border: none; + color: inherit; + height: 100%; + overflow: hidden; + padding: 0px; + &:focus { + background-color: rgba(255, 255, 255, 0.25); + color: inherit; + } + `, + }; +}); + +type PropsType = { + className?: string; + copyText: string; + icon?: string; + placement?: TooltipPlacement; + tooltipTitle: string; +}; + +type StateType = { + hasCopied: boolean; +}; + +export default class CopyIcon extends React.PureComponent { + static defaultProps: Partial = { + className: undefined, + icon: 'copy', + placement: 'left', + }; + + state = { + hasCopied: false, + }; + + handleClick = () => { + this.setState({ + hasCopied: true, + }); + copy(this.props.copyText); + }; + + handleTooltipVisibilityChange = (visible: boolean) => { + if (!visible && this.state.hasCopied) { + this.setState({ + hasCopied: false, + }); + } + }; + + render() { + const styles = getStyles(); + return ( + + + + ); + } +} diff --git a/packages/jaeger-ui-components/src/common/LabeledList.tsx b/packages/jaeger-ui-components/src/common/LabeledList.tsx new file mode 100644 index 00000000000..6a39943fb66 --- /dev/null +++ b/packages/jaeger-ui-components/src/common/LabeledList.tsx @@ -0,0 +1,69 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; + +import { createStyle } from '../Theme'; +import { UIDivider } from '../uiElementsContext'; + +const getStyles = createStyle(() => { + return { + LabeledList: css` + label: LabeledList; + list-style: none; + margin: 0; + padding: 0; + `, + LabeledListItem: css` + label: LabeledListItem; + display: inline-block; + `, + LabeledListLabel: css` + label: LabeledListLabel; + color: #999; + margin-right: 0.25rem; + `, + }; +}); + +type LabeledListProps = { + className?: string; + dividerClassName?: string; + items: Array<{ key: string; label: React.ReactNode; value: React.ReactNode }>; +}; + +export default function LabeledList(props: LabeledListProps) { + const { className, dividerClassName, items } = props; + const styles = getStyles(); + return ( +
    + {items.map(({ key, label, value }, i) => { + const divider = i < items.length - 1 && ( +
  • + +
  • + ); + return [ +
  • + {label} + {value} +
  • , + divider, + ]; + })} +
+ ); +} diff --git a/packages/jaeger-ui-components/src/common/NewWindowIcon.test.js b/packages/jaeger-ui-components/src/common/NewWindowIcon.test.js new file mode 100644 index 00000000000..d18aae61caa --- /dev/null +++ b/packages/jaeger-ui-components/src/common/NewWindowIcon.test.js @@ -0,0 +1,40 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import NewWindowIcon, { getStyles } from './NewWindowIcon'; + +describe('NewWindowIcon', () => { + const props = { + notIsLarge: 'not is large', + }; + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders as expected', () => { + expect(wrapper).toMatchSnapshot(); + }); + + it('adds is-large className when props.isLarge is true', () => { + const styles = getStyles(); + expect(wrapper.hasClass(styles.NewWindowIconLarge)).toBe(false); + wrapper.setProps({ isLarge: true }); + expect(wrapper.hasClass(styles.NewWindowIconLarge)).toBe(true); + }); +}); diff --git a/packages/jaeger-ui-components/src/common/NewWindowIcon.tsx b/packages/jaeger-ui-components/src/common/NewWindowIcon.tsx new file mode 100644 index 00000000000..bd203d52282 --- /dev/null +++ b/packages/jaeger-ui-components/src/common/NewWindowIcon.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2018 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import cx from 'classnames'; +import IoAndroidOpen from 'react-icons/lib/io/android-open'; +import { css } from 'emotion'; + +import { createStyle } from '../Theme'; + +export const getStyles = createStyle(() => { + return { + NewWindowIconLarge: css` + label: NewWindowIconLarge; + font-size: 1.5em; + `, + }; +}); + +type Props = { + isLarge?: boolean; + className?: string; +}; + +export default function NewWindowIcon(props: Props) { + const { isLarge, className, ...rest } = props; + const styles = getStyles(); + const cls = cx({ [styles.NewWindowIconLarge]: isLarge }, className); + return ; +} + +NewWindowIcon.defaultProps = { + isLarge: false, +}; diff --git a/packages/jaeger-ui-components/src/common/__snapshots__/CopyIcon.test.js.snap b/packages/jaeger-ui-components/src/common/__snapshots__/CopyIcon.test.js.snap new file mode 100644 index 00000000000..7666858de82 --- /dev/null +++ b/packages/jaeger-ui-components/src/common/__snapshots__/CopyIcon.test.js.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders as expected 1`] = ` + + + +`; diff --git a/packages/jaeger-ui-components/src/common/__snapshots__/NewWindowIcon.test.js.snap b/packages/jaeger-ui-components/src/common/__snapshots__/NewWindowIcon.test.js.snap new file mode 100644 index 00000000000..7ef1f60f17e --- /dev/null +++ b/packages/jaeger-ui-components/src/common/__snapshots__/NewWindowIcon.test.js.snap @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NewWindowIcon renders as expected 1`] = ` + +`; diff --git a/packages/jaeger-ui-components/src/constants/default-config.tsx b/packages/jaeger-ui-components/src/constants/default-config.tsx new file mode 100644 index 00000000000..5dfe3420920 --- /dev/null +++ b/packages/jaeger-ui-components/src/constants/default-config.tsx @@ -0,0 +1,86 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import deepFreeze from 'deep-freeze'; + +import { FALLBACK_DAG_MAX_NUM_SERVICES } from './index'; + +export default deepFreeze( + Object.defineProperty( + { + archiveEnabled: false, + dependencies: { + dagMaxNumServices: FALLBACK_DAG_MAX_NUM_SERVICES, + menuEnabled: true, + }, + linkPatterns: [], + menu: [ + { + label: 'About Jaeger', + items: [ + { + label: 'GitHub', + url: 'https://github.com/uber/jaeger', + }, + { + label: 'Docs', + url: 'http://jaeger.readthedocs.io/en/latest/', + }, + { + label: 'Twitter', + url: 'https://twitter.com/JaegerTracing', + }, + { + label: 'Discussion Group', + url: 'https://groups.google.com/forum/#!forum/jaeger-tracing', + }, + { + label: 'Gitter.im', + url: 'https://gitter.im/jaegertracing/Lobby', + }, + { + label: 'Blog', + url: 'https://medium.com/jaegertracing/', + }, + ], + }, + ], + search: { + maxLookback: { + label: '2 Days', + value: '2d', + }, + maxLimit: 1500, + }, + tracking: { + gaID: null, + trackErrors: true, + }, + }, + // fields that should be individually merged vs wholesale replaced + '__mergeFields', + { value: ['dependencies', 'search', 'tracking'] } + ) +); + +export const deprecations = [ + { + formerKey: 'dependenciesMenuEnabled', + currentKey: 'dependencies.menuEnabled', + }, + { + formerKey: 'gaTrackingID', + currentKey: 'tracking.gaID', + }, +]; diff --git a/packages/jaeger-ui-components/src/constants/index.tsx b/packages/jaeger-ui-components/src/constants/index.tsx new file mode 100644 index 00000000000..d9067de57bb --- /dev/null +++ b/packages/jaeger-ui-components/src/constants/index.tsx @@ -0,0 +1,28 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const TOP_NAV_HEIGHT = 46 as 46; + +export const FALLBACK_DAG_MAX_NUM_SERVICES = 100 as 100; +export const FALLBACK_TRACE_NAME = '' as ''; + +export const FETCH_DONE = 'FETCH_DONE' as 'FETCH_DONE'; +export const FETCH_ERROR = 'FETCH_ERROR' as 'FETCH_ERROR'; +export const FETCH_LOADING = 'FETCH_LOADING' as 'FETCH_LOADING'; + +export const fetchedState = { + DONE: FETCH_DONE, + ERROR: FETCH_ERROR, + LOADING: FETCH_LOADING, +}; diff --git a/packages/jaeger-ui-components/src/demo/.eslintrc b/packages/jaeger-ui-components/src/demo/.eslintrc new file mode 100644 index 00000000000..04cfba7cd09 --- /dev/null +++ b/packages/jaeger-ui-components/src/demo/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "import/no-extraneous-dependencies": 0 + } +} diff --git a/packages/jaeger-ui-components/src/demo/trace-generators.js b/packages/jaeger-ui-components/src/demo/trace-generators.js new file mode 100644 index 00000000000..5cd1782ca5d --- /dev/null +++ b/packages/jaeger-ui-components/src/demo/trace-generators.js @@ -0,0 +1,173 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Chance from 'chance'; + +import { getSpanId } from '../selectors/span'; + +const chance = new Chance(); + +export const SERVICE_LIST = ['serviceA', 'serviceB', 'serviceC', 'serviceD', 'serviceE', 'serviceF']; +export const OPERATIONS_LIST = [ + 'GET', + 'PUT', + 'POST', + 'DELETE', + 'MySQL::SELECT', + 'MySQL::INSERT', + 'MongoDB::find', + 'MongoDB::update', +]; + +function setupParentSpan(spans, parentSpanValues) { + Object.assign(spans[0], parentSpanValues); + return spans; +} + +function getParentSpanId(span, levels) { + let nestingLevel = chance.integer({ min: 1, max: levels.length }); + + // pick the correct nesting level if allocated by the levels calculation + levels.forEach((level, idx) => { + if (level.indexOf(getSpanId(span)) >= 0) { + nestingLevel = idx; + } + }); + + return nestingLevel - 1 >= 0 ? chance.pickone(levels[nestingLevel - 1]) : null; +} + +/* this simulates the hierarchy created by CHILD_OF tags */ +function attachReferences(spans, depth, spansPerLevel) { + let levels = [[getSpanId(spans[0])]]; + + const duplicateLevelFilter = currentLevels => span => + !currentLevels.find(level => level.indexOf(span.spanID) >= 0); + + while (levels.length < depth) { + const remainingSpans = spans.filter(duplicateLevelFilter(levels)); + if (remainingSpans.length <= 0) break; + const newLevel = chance + .pickset(remainingSpans, spansPerLevel || chance.integer({ min: 4, max: 8 })) + .map(getSpanId); + levels.push(newLevel); + } + + // filter out empty levels + levels = levels.filter(level => level.length > 0); + + return spans.map(span => { + const parentSpanId = getParentSpanId(span, levels); + return parentSpanId + ? { + ...span, + references: [ + { + refType: 'CHILD_OF', + traceID: span.traceID, + spanID: parentSpanId, + }, + ], + } + : span; + }); +} + +export default chance.mixin({ + trace({ + // long trace + // very short trace + // average case + numberOfSpans = chance.pickone([ + Math.ceil(chance.normal({ mean: 200, dev: 10 })) + 1, + Math.ceil(chance.integer({ min: 3, max: 10 })), + Math.ceil(chance.normal({ mean: 45, dev: 15 })) + 1, + ]), + numberOfProcesses = chance.integer({ min: 1, max: 10 }), + maxDepth = chance.integer({ min: 1, max: 10 }), + spansPerLevel = null, + }) { + const traceID = chance.guid(); + const duration = chance.integer({ min: 10000, max: 5000000 }); + const timestamp = (new Date().getTime() - chance.integer({ min: 0, max: 1000 }) * 1000) * 1000; + + const processArray = chance.processes({ numberOfProcesses }); + const processes = processArray.reduce((pMap, p) => ({ ...pMap, [p.processID]: p }), {}); + + let spans = chance.n(chance.span, numberOfSpans, { + traceID, + processes, + traceStartTime: timestamp, + traceEndTime: timestamp + duration, + }); + spans = attachReferences(spans, maxDepth, spansPerLevel); + if (spans.length > 1) { + spans = setupParentSpan(spans, { startTime: timestamp, duration }); + } + + return { + traceID, + spans, + processes, + }; + }, + tag() { + return { + key: 'http.url', + type: 'String', + value: `/v2/${chance.pickone(['alpha', 'beta', 'gamma'])}/${chance.guid()}`, + }; + }, + span({ + traceID = chance.guid(), + processes = {}, + traceStartTime = chance.timestamp() * 1000 * 1000, + traceEndTime = traceStartTime + 100000, + operations = OPERATIONS_LIST, + }) { + const startTime = chance.integer({ + min: traceStartTime, + max: traceEndTime, + }); + + return { + traceID, + processID: chance.pickone(Object.keys(processes)), + spanID: chance.guid(), + flags: 0, + operationName: chance.pickone(operations), + references: [], + startTime, + duration: chance.integer({ min: 1, max: traceEndTime - startTime }), + tags: chance.tags(), + logs: [], + }; + }, + process({ services = SERVICE_LIST }) { + return { + processID: chance.guid(), + serviceName: chance.pickone(services), + tags: chance.tags(), + }; + }, + traces({ numberOfTraces = chance.integer({ min: 5, max: 15 }) }) { + return chance.n(chance.trace, numberOfTraces, {}); + }, + tags() { + return chance.n(chance.tag, chance.integer({ min: 1, max: 10 }), {}); + }, + processes({ numberOfProcesses = chance.integer({ min: 1, max: 25 }) }) { + return chance.n(chance.process, numberOfProcesses, {}); + }, +}); diff --git a/packages/jaeger-ui-components/src/index.ts b/packages/jaeger-ui-components/src/index.ts new file mode 100644 index 00000000000..4f9997402ec --- /dev/null +++ b/packages/jaeger-ui-components/src/index.ts @@ -0,0 +1,13 @@ +export { default as TraceTimelineViewer } from './TraceTimelineViewer'; +export { default as UIElementsContext } from './uiElementsContext'; +export * from './uiElementsContext'; +export * from './types'; +export * from './TraceTimelineViewer/types'; +export { default as DetailState } from './TraceTimelineViewer/SpanDetail/DetailState'; +export { default as transformTraceData } from './model/transform-trace-data'; + +import { onlyUpdateForKeys } from 'recompose'; + +export default { + onlyUpdateForKeys, +} as any; diff --git a/packages/jaeger-ui-components/src/keyboard-mappings.tsx b/packages/jaeger-ui-components/src/keyboard-mappings.tsx new file mode 100644 index 00000000000..63344354779 --- /dev/null +++ b/packages/jaeger-ui-components/src/keyboard-mappings.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const keyboardMappings: Record = { + scrollPageDown: { binding: 's', label: 'Scroll down' }, + scrollPageUp: { binding: 'w', label: 'Scroll up' }, + scrollToNextVisibleSpan: { binding: 'f', label: 'Scroll to the next visible span' }, + scrollToPrevVisibleSpan: { binding: 'b', label: 'Scroll to the previous visible span' }, + panLeft: { binding: ['a', 'left'], label: 'Pan left' }, + panLeftFast: { binding: ['shift+a', 'shift+left'], label: 'Pan left — Large' }, + panRight: { binding: ['d', 'right'], label: 'Pan right' }, + panRightFast: { binding: ['shift+d', 'shift+right'], label: 'Pan right — Large' }, + zoomIn: { binding: 'up', label: 'Zoom in' }, + zoomInFast: { binding: 'shift+up', label: 'Zoom in — Large' }, + zoomOut: { binding: 'down', label: 'Zoom out' }, + zoomOutFast: { binding: 'shift+down', label: 'Zoom out — Large' }, + collapseAll: { binding: ']', label: 'Collapse All' }, + expandAll: { binding: '[', label: 'Expand All' }, + collapseOne: { binding: 'p', label: 'Collapse One Level' }, + expandOne: { binding: 'o', label: 'Expand One Level' }, + searchSpans: { binding: 'ctrl+b', label: 'Search Spans' }, + clearSearch: { binding: 'escape', label: 'Clear Search' }, +}; + +export default keyboardMappings; diff --git a/packages/jaeger-ui-components/src/keyboard-shortcuts.tsx b/packages/jaeger-ui-components/src/keyboard-shortcuts.tsx new file mode 100644 index 00000000000..b0c00901914 --- /dev/null +++ b/packages/jaeger-ui-components/src/keyboard-shortcuts.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import Combokeys from 'combokeys'; + +import keyboardMappings from './keyboard-mappings'; + +export type CombokeysHandler = + | (() => void) + | ((event: React.KeyboardEvent) => void) + | ((event: React.KeyboardEvent, s: string) => void); + +export type ShortcutCallbacks = { + [name: string]: CombokeysHandler; +}; + +let instance: Combokeys | undefined; + +function getInstance(): Combokeys { + if (instance) { + return instance; + } + const local = new Combokeys(document.body); + instance = local; + return local; +} + +export function merge(callbacks: ShortcutCallbacks) { + const inst = getInstance(); + Object.keys(callbacks).forEach(name => { + const keysHandler = callbacks[name]; + if (keysHandler) { + inst.bind(keyboardMappings[name].binding, keysHandler); + } + }); +} + +export function reset() { + const combokeys = getInstance(); + combokeys.reset(); +} diff --git a/packages/jaeger-ui-components/src/model/ddg/PathElem.test.js b/packages/jaeger-ui-components/src/model/ddg/PathElem.test.js new file mode 100644 index 00000000000..e7eba0d49e3 --- /dev/null +++ b/packages/jaeger-ui-components/src/model/ddg/PathElem.test.js @@ -0,0 +1,186 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import PathElem from './PathElem'; +import { simplePath } from './sample-paths.test.resources'; + +describe('PathElem', () => { + const getPath = () => { + const path = { + focalIdx: 2, + }; + const members = simplePath.map( + ({ operation, service }, i) => + new PathElem({ + memberIdx: i, + operation: { + name: operation, + service: { + name: service, + }, + }, + path, + }) + ); + members[2].visibilityIdx = 0; + members[3].visibilityIdx = 1; + members[1].visibilityIdx = 2; + members[4].visibilityIdx = 3; + members[0].visibilityIdx = 4; + path.members = members; + return path; + }; + const testMemberIdx = 3; + const testOperation = {}; + const testPath = { + focalIdx: 4, + members: ['member0', 'member1', 'member2', 'member3', 'member4', 'member5'], + }; + const testVisibilityIdx = 105; + let pathElem; + + beforeEach(() => { + pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: testMemberIdx }); + }); + + it('initializes instance properties', () => { + expect(pathElem.memberIdx).toBe(testMemberIdx); + expect(pathElem.memberOf).toBe(testPath); + expect(pathElem.operation).toBe(testOperation); + }); + + it('calculates distance', () => { + expect(pathElem.distance).toBe(-1); + }); + + it('sets visibilityIdx', () => { + pathElem.visibilityIdx = testVisibilityIdx; + expect(pathElem.visibilityIdx).toBe(testVisibilityIdx); + }); + + it('errors when trying to access unset visibilityIdx', () => { + expect(() => pathElem.visibilityIdx).toThrowError(); + }); + + it('errors when trying to override visibilityIdx', () => { + pathElem.visibilityIdx = testVisibilityIdx; + expect(() => { + pathElem.visibilityIdx = testVisibilityIdx; + }).toThrowError(); + }); + + it('has externalSideNeighbor if distance is not 0 and it is not external', () => { + expect(pathElem.externalSideNeighbor).toBe(testPath.members[testMemberIdx - 1]); + }); + + it('has a null externalSideNeighbor if distance is 0', () => { + pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: testPath.focalIdx }); + expect(pathElem.externalSideNeighbor).toBe(null); + }); + + it('has an undefined externalSideNeighbor if is external', () => { + pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: 0 }); + expect(pathElem.externalSideNeighbor).toBe(undefined); + }); + + it('has focalSideNeighbor if distance is not 0', () => { + expect(pathElem.focalSideNeighbor).toBe(testPath.members[testMemberIdx + 1]); + }); + + it('has a null focalSideNeighbor if distance is 0', () => { + pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: testPath.focalIdx }); + expect(pathElem.focalSideNeighbor).toBe(null); + }); + + it('is external if it is first or last PathElem in memberOf.path and not the focalElem', () => { + expect(pathElem.isExternal).toBe(false); + + const firstElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: 0 }); + expect(firstElem.isExternal).toBe(true); + + const lastElem = new PathElem({ + path: testPath, + operation: testOperation, + memberIdx: testPath.members.length - 1, + }); + expect(lastElem.isExternal).toBe(true); + + const path = { + ...testPath, + focalIdx: testPath.members.length - 1, + }; + const focalElem = new PathElem({ path, operation: testOperation, memberIdx: path.members.length - 1 }); + expect(focalElem.isExternal).toBe(false); + }); + + describe('externalPath', () => { + const path = getPath(); + + it('returns array of itself if it is focal elem', () => { + const targetPathElem = path.members[path.focalIdx]; + expect(targetPathElem.externalPath).toEqual([targetPathElem]); + }); + + it('returns path away from focal elem in correct order for upstream elem', () => { + const idx = path.focalIdx - 1; + const targetPathElem = path.members[idx]; + expect(targetPathElem.externalPath).toEqual(path.members.slice(0, idx + 1)); + }); + + it('returns path away from focal elem in correct order for downstream elem', () => { + const idx = path.focalIdx + 1; + const targetPathElem = path.members[idx]; + expect(targetPathElem.externalPath).toEqual(path.members.slice(idx)); + }); + }); + + describe('focalPath', () => { + const path = getPath(); + + it('returns array of itself if it is focal elem', () => { + const targetPathElem = path.members[path.focalIdx]; + expect(targetPathElem.focalPath).toEqual([targetPathElem]); + }); + + it('returns path to focal elem in correct order for upstream elem', () => { + const targetPathElem = path.members[0]; + expect(targetPathElem.focalPath).toEqual(path.members.slice(0, path.focalIdx + 1)); + }); + + it('returns path to focal elem in correct order for downstream elem', () => { + const idx = path.members.length - 1; + const targetPathElem = path.members[idx]; + expect(targetPathElem.focalPath).toEqual(path.members.slice(path.focalIdx, idx + 1)); + }); + }); + + describe('legibility', () => { + const path = getPath(); + const targetPathElem = path.members[1]; + + it('creates consumable JSON', () => { + expect(targetPathElem.toJSON()).toMatchSnapshot(); + }); + + it('creates consumable string', () => { + expect(targetPathElem.toString()).toBe(JSON.stringify(targetPathElem.toJSON(), null, 2)); + }); + + it('creates informative string tag', () => { + expect(Object.prototype.toString.call(targetPathElem)).toEqual( + `[object PathElem ${targetPathElem.visibilityIdx}]` + ); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/model/ddg/PathElem.tsx b/packages/jaeger-ui-components/src/model/ddg/PathElem.tsx new file mode 100644 index 00000000000..4a964a4952f --- /dev/null +++ b/packages/jaeger-ui-components/src/model/ddg/PathElem.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TDdgOperation, TDdgPath } from './types'; + +export default class PathElem { + memberIdx: number; + memberOf: TDdgPath; + operation: TDdgOperation; + private _visibilityIdx?: number; + + constructor({ path, operation, memberIdx }: { path: TDdgPath; operation: TDdgOperation; memberIdx: number }) { + this.memberIdx = memberIdx; + this.memberOf = path; + this.operation = operation; + } + + get distance() { + return this.memberIdx - this.memberOf.focalIdx; + } + + get externalPath(): PathElem[] { + const result: PathElem[] = []; + let current: PathElem | null | undefined = this; + while (current) { + result.push(current); + current = current.externalSideNeighbor; + } + if (this.distance < 0) result.reverse(); + return result; + } + + get externalSideNeighbor(): PathElem | null | undefined { + if (!this.distance) return null; + return this.memberOf.members[this.memberIdx + Math.sign(this.distance)]; + } + + get focalPath(): PathElem[] { + const result: PathElem[] = []; + let current: PathElem | null = this; + while (current) { + result.push(current); + current = current.focalSideNeighbor; + } + if (this.distance > 0) result.reverse(); + return result; + } + + get focalSideNeighbor(): PathElem | null { + if (!this.distance) return null; + return this.memberOf.members[this.memberIdx - Math.sign(this.distance)]; + } + + get isExternal(): boolean { + return Boolean(this.distance) && (this.memberIdx === 0 || this.memberIdx === this.memberOf.members.length - 1); + } + + set visibilityIdx(visibilityIdx: number) { + if (this._visibilityIdx == null) { + this._visibilityIdx = visibilityIdx; + } else { + throw new Error('Visibility Index cannot be changed once set'); + } + } + + get visibilityIdx(): number { + if (this._visibilityIdx == null) { + throw new Error('Visibility Index was never set for this PathElem'); + } + return this._visibilityIdx; + } + + private toJSONHelper = () => ({ + memberIdx: this.memberIdx, + operation: this.operation.name, + service: this.operation.service.name, + visibilityIdx: this._visibilityIdx, + }); + + /* + * Because the memberOf on a PathElem contains an array of all of its members which in turn all contain + * memberOf back to the path, some assistance is necessary when creating error messages. toJSON is called by + * JSON.stringify and expected to return a JSON object. To that end, this method simplifies the + * representation of the PathElems in memberOf's path to remove the circular reference. + */ + toJSON() { + return { + ...this.toJSONHelper(), + memberOf: { + focalIdx: this.memberOf.focalIdx, + members: this.memberOf.members.map(member => member.toJSONHelper()), + }, + }; + } + + // `toJSON` is called by `JSON.stringify` while `toString` is used by template strings and string concat + toString() { + return JSON.stringify(this.toJSON(), null, 2); + } + + // `[Symbol.toStringTag]` is used when attempting to use an object as a key on an object, where a full + // stringified JSON would reduce clarity + get [Symbol.toStringTag]() { + return `PathElem ${this._visibilityIdx}`; + } +} diff --git a/packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.js.snap b/packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.js.snap new file mode 100644 index 00000000000..95e81b6a8d3 --- /dev/null +++ b/packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.js.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PathElem legibility creates consumable JSON 1`] = ` +Object { + "memberIdx": 1, + "memberOf": Object { + "focalIdx": 2, + "members": Array [ + Object { + "memberIdx": 0, + "operation": "firstOperation", + "service": "firstService", + "visibilityIdx": 4, + }, + Object { + "memberIdx": 1, + "operation": "beforeOperation", + "service": "beforeService", + "visibilityIdx": 2, + }, + Object { + "memberIdx": 2, + "operation": "focalOperation", + "service": "focalService", + "visibilityIdx": 0, + }, + Object { + "memberIdx": 3, + "operation": "afterOperation", + "service": "afterService", + "visibilityIdx": 1, + }, + Object { + "memberIdx": 4, + "operation": "lastOperation", + "service": "lastService", + "visibilityIdx": 3, + }, + ], + }, + "operation": "beforeOperation", + "service": "beforeService", + "visibilityIdx": 2, +} +`; diff --git a/packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.js b/packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.js new file mode 100644 index 00000000000..d19de5b909f --- /dev/null +++ b/packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.js @@ -0,0 +1,133 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const simplePayloadElemMaker = label => ({ + operation: `${label}Operation`, + service: `${label}Service`, +}); + +export const focalPayloadElem = simplePayloadElemMaker('focal'); + +const sameFocalServicePayloadElem = { + operation: 'someOtherOperation', + service: focalPayloadElem.service, +}; + +const pathLengthener = path => { + const prequels = []; + const sequels = []; + path.forEach(({ operation, service }) => { + if (operation !== focalPayloadElem.operation && service !== focalPayloadElem.service) { + prequels.push({ + operation: `prequel-${operation}`, + service, + }); + sequels.push({ + operation, + service: `sequel-${service}`, + }); + } + }); + return [...prequels, ...path, ...sequels]; +}; + +export const firstPayloadElem = simplePayloadElemMaker('first'); +export const beforePayloadElem = simplePayloadElemMaker('before'); +export const midPayloadElem = simplePayloadElemMaker('mid'); +export const afterPayloadElem = simplePayloadElemMaker('after'); +export const lastPayloadElem = simplePayloadElemMaker('last'); + +export const shortPath = [beforePayloadElem, focalPayloadElem]; +export const simplePath = [ + firstPayloadElem, + beforePayloadElem, + focalPayloadElem, + afterPayloadElem, + lastPayloadElem, +]; +export const longSimplePath = pathLengthener(simplePath); +export const noFocalPath = [ + firstPayloadElem, + beforePayloadElem, + midPayloadElem, + afterPayloadElem, + lastPayloadElem, +]; +export const doubleFocalPath = [ + firstPayloadElem, + beforePayloadElem, + focalPayloadElem, + midPayloadElem, + focalPayloadElem, + afterPayloadElem, + lastPayloadElem, +]; +export const almostDoubleFocalPath = [ + firstPayloadElem, + beforePayloadElem, + sameFocalServicePayloadElem, + midPayloadElem, + focalPayloadElem, + afterPayloadElem, + lastPayloadElem, +]; + +const divergentPayloadElem = simplePayloadElemMaker('divergentPayloadElem'); +export const convergentPaths = [ + [firstPayloadElem, focalPayloadElem, divergentPayloadElem, afterPayloadElem, lastPayloadElem], + [firstPayloadElem, focalPayloadElem, midPayloadElem, afterPayloadElem, lastPayloadElem], +]; + +const generationPayloadElems = { + afterFocalMid: simplePayloadElemMaker('afterFocalMid'), + afterTarget0: simplePayloadElemMaker('afterTarget0'), + afterTarget1: simplePayloadElemMaker('afterTarget1'), + beforeFocalMid: simplePayloadElemMaker('beforeFocalMid'), + beforeTarget0: simplePayloadElemMaker('beforeTarget0'), + beforeTarget1: simplePayloadElemMaker('beforeTarget1'), + target: simplePayloadElemMaker('target'), +}; + +export const generationPaths = [ + [ + generationPayloadElems.beforeTarget0, + generationPayloadElems.target, + generationPayloadElems.beforeFocalMid, + focalPayloadElem, + ], + [ + generationPayloadElems.beforeTarget1, + generationPayloadElems.target, + generationPayloadElems.beforeFocalMid, + focalPayloadElem, + ], + [focalPayloadElem, generationPayloadElems.afterFocalMid, generationPayloadElems.target], + [ + focalPayloadElem, + generationPayloadElems.afterFocalMid, + generationPayloadElems.target, + generationPayloadElems.afterTarget0, + ], + [ + focalPayloadElem, + generationPayloadElems.afterFocalMid, + generationPayloadElems.target, + generationPayloadElems.afterTarget1, + ], + [generationPayloadElems.target, generationPayloadElems.beforeFocalMid, focalPayloadElem], +]; + +export const wrap = paths => ({ + dependencies: paths.map(path => ({ path, attributes: [] })), +}); diff --git a/packages/jaeger-ui-components/src/model/ddg/types.tsx b/packages/jaeger-ui-components/src/model/ddg/types.tsx new file mode 100644 index 00000000000..f6d4f0f965a --- /dev/null +++ b/packages/jaeger-ui-components/src/model/ddg/types.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import PathElem from './PathElem'; + +export { default as PathElem } from './PathElem'; + +export type TDdgService = { + name: string; + operations: Map; +}; + +export type TDdgOperation = { + name: string; + pathElems: PathElem[]; + service: TDdgService; +}; + +export type TDdgServiceMap = Map; + +export type TDdgPath = { + focalIdx: number; + members: PathElem[]; + traceIDs: string[]; +}; + +export type TDdgDistanceToPathElems = Map; + +export type TDdgModel = { + distanceToPathElems: TDdgDistanceToPathElems; + hash: string; + paths: TDdgPath[]; + services: TDdgServiceMap; + visIdxToPathElem: PathElem[]; +}; diff --git a/packages/jaeger-ui-components/src/model/link-patterns.test.js b/packages/jaeger-ui-components/src/model/link-patterns.test.js new file mode 100644 index 00000000000..a92961a6d49 --- /dev/null +++ b/packages/jaeger-ui-components/src/model/link-patterns.test.js @@ -0,0 +1,431 @@ +// Copyright (c) 2017 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + processTemplate, + createTestFunction, + getParameterInArray, + getParameterInAncestor, + processLinkPattern, + computeLinks, + createGetLinks, + computeTraceLink, +} from './link-patterns'; + +describe('processTemplate()', () => { + it('correctly replaces variables', () => { + const processedTemplate = processTemplate( + 'this is a test with #{oneVariable}#{anotherVariable} and the same #{oneVariable}', + a => a + ); + expect(processedTemplate.parameters).toEqual(['oneVariable', 'anotherVariable']); + expect(processedTemplate.template({ oneVariable: 'MYFIRSTVAR', anotherVariable: 'SECOND' })).toBe( + 'this is a test with MYFIRSTVARSECOND and the same MYFIRSTVAR' + ); + }); + + it('correctly uses the encoding function', () => { + const processedTemplate = processTemplate( + 'this is a test with #{oneVariable}#{anotherVariable} and the same #{oneVariable}', + e => `/${e}\\` + ); + expect(processedTemplate.parameters).toEqual(['oneVariable', 'anotherVariable']); + expect(processedTemplate.template({ oneVariable: 'MYFIRSTVAR', anotherVariable: 'SECOND' })).toBe( + 'this is a test with /MYFIRSTVAR\\/SECOND\\ and the same /MYFIRSTVAR\\' + ); + }); + + /* + // kept on ice until #123 is implemented: + + it('correctly returns the same object when passing an already processed template', () => { + const alreadyProcessed = { + parameters: ['b'], + template: data => `a${data.b}c`, + }; + const processedTemplate = processTemplate(alreadyProcessed, a => a); + expect(processedTemplate).toBe(alreadyProcessed); + }); + + */ + + it('reports an error when passing an object that does not look like an already processed template', () => { + expect(() => + processTemplate( + { + template: data => `a${data.b}c`, + }, + a => a + ) + ).toThrow(); + expect(() => + processTemplate( + { + parameters: ['b'], + }, + a => a + ) + ).toThrow(); + expect(() => processTemplate({}, a => a)).toThrow(); + }); +}); + +describe('createTestFunction()', () => { + it('accepts a string', () => { + const testFn = createTestFunction('myValue'); + expect(testFn('myValue')).toBe(true); + expect(testFn('myFirstValue')).toBe(false); + expect(testFn('mySecondValue')).toBe(false); + expect(testFn('otherValue')).toBe(false); + }); + + it('accepts an array', () => { + const testFn = createTestFunction(['myFirstValue', 'mySecondValue']); + expect(testFn('myValue')).toBe(false); + expect(testFn('myFirstValue')).toBe(true); + expect(testFn('mySecondValue')).toBe(true); + expect(testFn('otherValue')).toBe(false); + }); + + /* + // kept on ice until #123 is implemented: + + it('accepts a regular expression', () => { + const testFn = createTestFunction(/^my.*Value$/); + expect(testFn('myValue')).toBe(true); + expect(testFn('myFirstValue')).toBe(true); + expect(testFn('mySecondValue')).toBe(true); + expect(testFn('otherValue')).toBe(false); + }); + + it('accepts a function', () => { + const mockCallback = jest.fn(); + mockCallback + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + .mockReturnValue(false); + const testFn = createTestFunction(mockCallback); + expect(testFn('myValue')).toBe(true); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith('myValue'); + expect(testFn('myFirstValue')).toBe(false); + expect(mockCallback).toHaveBeenCalledTimes(2); + expect(mockCallback).toHaveBeenCalledWith('myFirstValue'); + expect(testFn('mySecondValue')).toBe(true); + expect(mockCallback).toHaveBeenCalledTimes(3); + expect(mockCallback).toHaveBeenCalledWith('mySecondValue'); + expect(testFn('otherValue')).toBe(false); + expect(mockCallback).toHaveBeenCalledTimes(4); + expect(mockCallback).toHaveBeenCalledWith('otherValue'); + }); + + */ + + it('accepts undefined', () => { + const testFn = createTestFunction(); + expect(testFn('myValue')).toBe(true); + expect(testFn('myFirstValue')).toBe(true); + expect(testFn('mySecondValue')).toBe(true); + expect(testFn('otherValue')).toBe(true); + }); + + it('rejects unknown values', () => { + expect(() => createTestFunction({})).toThrow(); + expect(() => createTestFunction(true)).toThrow(); + expect(() => createTestFunction(false)).toThrow(); + expect(() => createTestFunction(0)).toThrow(); + expect(() => createTestFunction(5)).toThrow(); + }); +}); + +describe('getParameterInArray()', () => { + const data = [{ key: 'mykey', value: 'ok' }, { key: 'otherkey', value: 'v' }]; + + it('returns an entry that is present', () => { + expect(getParameterInArray('mykey', data)).toBe(data[0]); + expect(getParameterInArray('otherkey', data)).toBe(data[1]); + }); + + it('returns undefined when the entry cannot be found', () => { + expect(getParameterInArray('myotherkey', data)).toBeUndefined(); + }); + + it('returns undefined when there is no array', () => { + expect(getParameterInArray('otherkey')).toBeUndefined(); + expect(getParameterInArray('otherkey', null)).toBeUndefined(); + }); +}); + +describe('getParameterInAncestor()', () => { + const spans = [ + { + depth: 0, + process: { + tags: [ + { key: 'a', value: 'a7' }, + { key: 'b', value: 'b7' }, + { key: 'c', value: 'c7' }, + { key: 'd', value: 'd7' }, + { key: 'e', value: 'e7' }, + { key: 'f', value: 'f7' }, + { key: 'g', value: 'g7' }, + { key: 'h', value: 'h7' }, + ], + }, + tags: [ + { key: 'a', value: 'a6' }, + { key: 'b', value: 'b6' }, + { key: 'c', value: 'c6' }, + { key: 'd', value: 'd6' }, + { key: 'e', value: 'e6' }, + { key: 'f', value: 'f6' }, + { key: 'g', value: 'g6' }, + ], + }, + { + depth: 1, + process: { + tags: [ + { key: 'a', value: 'a5' }, + { key: 'b', value: 'b5' }, + { key: 'c', value: 'c5' }, + { key: 'd', value: 'd5' }, + { key: 'e', value: 'e5' }, + { key: 'f', value: 'f5' }, + ], + }, + tags: [ + { key: 'a', value: 'a4' }, + { key: 'b', value: 'b4' }, + { key: 'c', value: 'c4' }, + { key: 'd', value: 'd4' }, + { key: 'e', value: 'e4' }, + ], + }, + { + depth: 1, + process: { + tags: [ + { key: 'a', value: 'a3' }, + { key: 'b', value: 'b3' }, + { key: 'c', value: 'c3' }, + { key: 'd', value: 'd3' }, + ], + }, + tags: [{ key: 'a', value: 'a2' }, { key: 'b', value: 'b2' }, { key: 'c', value: 'c2' }], + }, + { + depth: 2, + process: { + tags: [{ key: 'a', value: 'a1' }, { key: 'b', value: 'b1' }], + }, + tags: [{ key: 'a', value: 'a0' }], + }, + ]; + spans[1].references = [ + { + refType: 'CHILD_OF', + span: spans[0], + }, + ]; + spans[2].references = [ + { + refType: 'CHILD_OF', + span: spans[0], + }, + ]; + spans[3].references = [ + { + refType: 'CHILD_OF', + span: spans[2], + }, + ]; + + it('uses current span tags', () => { + expect(getParameterInAncestor('a', spans[3])).toEqual({ key: 'a', value: 'a0' }); + expect(getParameterInAncestor('a', spans[2])).toEqual({ key: 'a', value: 'a2' }); + expect(getParameterInAncestor('a', spans[1])).toEqual({ key: 'a', value: 'a4' }); + expect(getParameterInAncestor('a', spans[0])).toEqual({ key: 'a', value: 'a6' }); + }); + + it('uses current span process tags', () => { + expect(getParameterInAncestor('b', spans[3])).toEqual({ key: 'b', value: 'b1' }); + expect(getParameterInAncestor('d', spans[2])).toEqual({ key: 'd', value: 'd3' }); + expect(getParameterInAncestor('f', spans[1])).toEqual({ key: 'f', value: 'f5' }); + expect(getParameterInAncestor('h', spans[0])).toEqual({ key: 'h', value: 'h7' }); + }); + + it('uses parent span tags', () => { + expect(getParameterInAncestor('c', spans[3])).toEqual({ key: 'c', value: 'c2' }); + expect(getParameterInAncestor('e', spans[2])).toEqual({ key: 'e', value: 'e6' }); + expect(getParameterInAncestor('f', spans[2])).toEqual({ key: 'f', value: 'f6' }); + expect(getParameterInAncestor('g', spans[2])).toEqual({ key: 'g', value: 'g6' }); + expect(getParameterInAncestor('g', spans[1])).toEqual({ key: 'g', value: 'g6' }); + }); + + it('uses parent span process tags', () => { + expect(getParameterInAncestor('d', spans[3])).toEqual({ key: 'd', value: 'd3' }); + expect(getParameterInAncestor('h', spans[2])).toEqual({ key: 'h', value: 'h7' }); + expect(getParameterInAncestor('h', spans[1])).toEqual({ key: 'h', value: 'h7' }); + }); + + it('uses grand-parent span tags', () => { + expect(getParameterInAncestor('e', spans[3])).toEqual({ key: 'e', value: 'e6' }); + expect(getParameterInAncestor('f', spans[3])).toEqual({ key: 'f', value: 'f6' }); + expect(getParameterInAncestor('g', spans[3])).toEqual({ key: 'g', value: 'g6' }); + }); + + it('uses grand-parent process tags', () => { + expect(getParameterInAncestor('h', spans[3])).toEqual({ key: 'h', value: 'h7' }); + }); + + it('returns undefined when the entry cannot be found', () => { + expect(getParameterInAncestor('i', spans[3])).toBeUndefined(); + }); + + it('does not break if some tags are not defined', () => { + const spansWithUndefinedTags = [ + { + depth: 0, + process: {}, + }, + ]; + expect(getParameterInAncestor('a', spansWithUndefinedTags[0])).toBeUndefined(); + }); +}); + +describe('computeTraceLink()', () => { + const linkPatterns = [ + { + type: 'traces', + url: 'http://example.com/?myKey=#{traceID}', + text: 'first link (#{traceID})', + }, + { + type: 'traces', + url: 'http://example.com/?myKey=#{traceID}&myKey=#{myKey}', + text: 'second link (#{myKey})', + }, + ].map(processLinkPattern); + + const trace = { + processes: [], + traceID: 'trc1', + spans: [], + startTime: 1000, + endTime: 2000, + duration: 1000, + services: [], + }; + + it('correctly computes links', () => { + expect(computeTraceLink(linkPatterns, trace)).toEqual([ + { + url: 'http://example.com/?myKey=trc1', + text: 'first link (trc1)', + }, + ]); + }); +}); + +describe('computeLinks()', () => { + const linkPatterns = [ + { + type: 'tags', + key: 'myKey', + url: 'http://example.com/?myKey=#{myKey}', + text: 'first link (#{myKey})', + }, + { + key: 'myOtherKey', + url: 'http://example.com/?myKey=#{myOtherKey}&myKey=#{myKey}', + text: 'second link (#{myOtherKey})', + }, + ].map(processLinkPattern); + + const spans = [ + { depth: 0, process: {}, tags: [{ key: 'myKey', value: 'valueOfMyKey' }] }, + { depth: 1, process: {}, logs: [{ fields: [{ key: 'myOtherKey', value: 'valueOfMy+Other+Key' }] }] }, + ]; + spans[1].references = [ + { + refType: 'CHILD_OF', + span: spans[0], + }, + ]; + + it('correctly computes links', () => { + expect(computeLinks(linkPatterns, spans[0], spans[0].tags, 0)).toEqual([ + { + url: 'http://example.com/?myKey=valueOfMyKey', + text: 'first link (valueOfMyKey)', + }, + ]); + expect(computeLinks(linkPatterns, spans[1], spans[1].logs[0].fields, 0)).toEqual([ + { + url: 'http://example.com/?myKey=valueOfMy%2BOther%2BKey&myKey=valueOfMyKey', + text: 'second link (valueOfMy+Other+Key)', + }, + ]); + }); +}); + +describe('getLinks()', () => { + const linkPatterns = [ + { + key: 'mySpecialKey', + url: 'http://example.com/?mySpecialKey=#{mySpecialKey}', + text: 'special key link (#{mySpecialKey})', + }, + ].map(processLinkPattern); + const template = jest.spyOn(linkPatterns[0].url, 'template'); + + const span = { depth: 0, process: {}, tags: [{ key: 'mySpecialKey', value: 'valueOfMyKey' }] }; + + let cache; + + beforeEach(() => { + cache = new WeakMap(); + template.mockClear(); + }); + + it('does not access the cache if there is no link pattern', () => { + cache.get = jest.fn(); + const getLinks = createGetLinks([], cache); + expect(getLinks(span, span.tags, 0)).toEqual([]); + expect(cache.get).not.toHaveBeenCalled(); + }); + + it('returns the result from the cache', () => { + const result = []; + cache.set(span.tags[0], result); + const getLinks = createGetLinks(linkPatterns, cache); + expect(getLinks(span, span.tags, 0)).toBe(result); + expect(template).not.toHaveBeenCalled(); + }); + + it('adds the result to the cache', () => { + const getLinks = createGetLinks(linkPatterns, cache); + const result = getLinks(span, span.tags, 0); + expect(template).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { + url: 'http://example.com/?mySpecialKey=valueOfMyKey', + text: 'special key link (valueOfMyKey)', + }, + ]); + expect(cache.get(span.tags[0])).toBe(result); + }); +}); diff --git a/packages/jaeger-ui-components/src/model/link-patterns.tsx b/packages/jaeger-ui-components/src/model/link-patterns.tsx new file mode 100644 index 00000000000..50f55f5fba6 --- /dev/null +++ b/packages/jaeger-ui-components/src/model/link-patterns.tsx @@ -0,0 +1,249 @@ +// Copyright (c) 2017 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import _uniq from 'lodash/uniq'; +import memoize from 'lru-memoize'; +import { getConfigValue } from '../utils/config/get-config'; +import { getParent } from './span'; +import { TNil } from '../types'; +import { Span, Link, KeyValuePair, Trace } from '../types/trace'; + +const parameterRegExp = /#\{([^{}]*)\}/g; + +type ProcessedTemplate = { + parameters: string[]; + template: (template: { [key: string]: any }) => string; +}; + +type ProcessedLinkPattern = { + object: any; + type: (link: string) => boolean; + key: (link: string) => boolean; + value: (value: any) => boolean; + url: ProcessedTemplate; + text: ProcessedTemplate; + parameters: string[]; +}; + +type TLinksRV = { url: string; text: string }[]; + +function getParamNames(str: string) { + const names = new Set(); + str.replace(parameterRegExp, (match, name) => { + names.add(name); + return match; + }); + return Array.from(names); +} + +function stringSupplant(str: string, encodeFn: (unencoded: any) => string, map: Record) { + return str.replace(parameterRegExp, (_, name) => { + const value = map[name]; + return value == null ? '' : encodeFn(value); + }); +} + +export function processTemplate(template: any, encodeFn: (unencoded: any) => string): ProcessedTemplate { + if (typeof template !== 'string') { + /* + + // kept on ice until #123 is implemented: + if (template && Array.isArray(template.parameters) && (typeof template.template === 'function')) { + return template; + } + + */ + throw new Error('Invalid template'); + } + return { + parameters: getParamNames(template), + template: stringSupplant.bind(null, template, encodeFn), + }; +} + +export function createTestFunction(entry: any) { + if (typeof entry === 'string') { + return (arg: any) => arg === entry; + } + if (Array.isArray(entry)) { + return (arg: any) => entry.indexOf(arg) > -1; + } + /* + + // kept on ice until #123 is implemented: + if (entry instanceof RegExp) { + return (arg: any) => entry.test(arg); + } + if (typeof entry === 'function') { + return entry; + } + + */ + if (entry == null) { + return () => true; + } + throw new Error(`Invalid value: ${entry}`); +} + +const identity = (a: any): typeof a => a; + +export function processLinkPattern(pattern: any): ProcessedLinkPattern | TNil { + try { + const url = processTemplate(pattern.url, encodeURIComponent); + const text = processTemplate(pattern.text, identity); + return { + object: pattern, + type: createTestFunction(pattern.type), + key: createTestFunction(pattern.key), + value: createTestFunction(pattern.value), + url, + text, + parameters: _uniq(url.parameters.concat(text.parameters)), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Ignoring invalid link pattern: ${error}`, pattern); + return null; + } +} + +export function getParameterInArray(name: string, array: KeyValuePair[]) { + if (array) { + return array.find(entry => entry.key === name); + } + return undefined; +} + +export function getParameterInAncestor(name: string, span: Span) { + let currentSpan: Span | TNil = span; + while (currentSpan) { + const result = getParameterInArray(name, currentSpan.tags) || getParameterInArray(name, currentSpan.process.tags); + if (result) { + return result; + } + currentSpan = getParent(currentSpan); + } + return undefined; +} + +function callTemplate(template: ProcessedTemplate, data: any) { + return template.template(data); +} + +export function computeTraceLink(linkPatterns: ProcessedLinkPattern[], trace: Trace) { + const result: TLinksRV = []; + const validKeys = (Object.keys(trace) as (keyof Trace)[]).filter( + key => typeof trace[key] === 'string' || trace[key] === 'number' + ); + + linkPatterns + .filter(pattern => pattern.type('traces')) + .forEach(pattern => { + const parameterValues: Record = {}; + const allParameters = pattern.parameters.every(parameter => { + const key = parameter as keyof Trace; + if (validKeys.includes(key)) { + // At this point is safe to access to trace object using parameter variable because + // we validated parameter against validKeys, this implies that parameter a keyof Trace. + parameterValues[parameter] = trace[key]; + return true; + } + return false; + }); + if (allParameters) { + result.push({ + url: callTemplate(pattern.url, parameterValues), + text: callTemplate(pattern.text, parameterValues), + }); + } + }); + + return result; +} + +export function computeLinks( + linkPatterns: ProcessedLinkPattern[], + span: Span, + items: KeyValuePair[], + itemIndex: number +) { + const item = items[itemIndex]; + let type = 'logs'; + const processTags = span.process.tags === items; + if (processTags) { + type = 'process'; + } + const spanTags = span.tags === items; + if (spanTags) { + type = 'tags'; + } + const result: { url: string; text: string }[] = []; + linkPatterns.forEach(pattern => { + if (pattern.type(type) && pattern.key(item.key) && pattern.value(item.value)) { + const parameterValues: Record = {}; + const allParameters = pattern.parameters.every(parameter => { + let entry = getParameterInArray(parameter, items); + if (!entry && !processTags) { + // do not look in ancestors for process tags because the same object may appear in different places in the hierarchy + // and the cache in getLinks uses that object as a key + entry = getParameterInAncestor(parameter, span); + } + if (entry) { + parameterValues[parameter] = entry.value; + return true; + } + // eslint-disable-next-line no-console + console.warn( + `Skipping link pattern, missing parameter ${parameter} for key ${item.key} in ${type}.`, + pattern.object + ); + return false; + }); + if (allParameters) { + result.push({ + url: callTemplate(pattern.url, parameterValues), + text: callTemplate(pattern.text, parameterValues), + }); + } + } + }); + return result; +} + +export function createGetLinks(linkPatterns: ProcessedLinkPattern[], cache: WeakMap) { + return (span: Span, items: KeyValuePair[], itemIndex: number) => { + if (linkPatterns.length === 0) { + return []; + } + const item = items[itemIndex]; + let result = cache.get(item); + if (!result) { + result = computeLinks(linkPatterns, span, items, itemIndex); + cache.set(item, result); + } + return result; + }; +} + +const processedLinks: ProcessedLinkPattern[] = (getConfigValue('linkPatterns') || []) + .map(processLinkPattern) + .filter(Boolean); + +export const getTraceLinks: (trace: Trace | undefined) => TLinksRV = memoize(10)((trace: Trace | undefined) => { + const result: TLinksRV = []; + if (!trace) return result; + return computeTraceLink(processedLinks, trace); +}); + +export default createGetLinks(processedLinks, new WeakMap()); diff --git a/packages/jaeger-ui-components/src/model/span.tsx b/packages/jaeger-ui-components/src/model/span.tsx new file mode 100644 index 00000000000..462c7886af9 --- /dev/null +++ b/packages/jaeger-ui-components/src/model/span.tsx @@ -0,0 +1,26 @@ +// Copyright (c) 2017 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Span } from '../types/trace'; + +/** + * Searches the span.references to find 'CHILD_OF' reference type or returns null. + * @param {Span} span The span whose parent is to be returned. + * @return {Span|null} The parent span if there is one, null otherwise. + */ +// eslint-disable-next-line import/prefer-default-export +export function getParent(span: Span) { + const parentRef = span.references ? span.references.find(ref => ref.refType === 'CHILD_OF') : null; + return parentRef ? parentRef.span : null; +} diff --git a/packages/jaeger-ui-components/src/model/transform-trace-data.test.js b/packages/jaeger-ui-components/src/model/transform-trace-data.test.js new file mode 100644 index 00000000000..99c2bbad258 --- /dev/null +++ b/packages/jaeger-ui-components/src/model/transform-trace-data.test.js @@ -0,0 +1,55 @@ +// Copyright (c) 2019 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { orderTags, deduplicateTags } from './transform-trace-data'; + +describe('orderTags()', () => { + it('correctly orders tags', () => { + const orderedTags = orderTags( + [ + { key: 'b.ip', value: '8.8.4.4' }, + { key: 'http.Status_code', value: '200' }, + { key: 'z.ip', value: '8.8.8.16' }, + { key: 'a.ip', value: '8.8.8.8' }, + { key: 'http.message', value: 'ok' }, + ], + ['z.', 'a.', 'HTTP.'] + ); + expect(orderedTags).toEqual([ + { key: 'z.ip', value: '8.8.8.16' }, + { key: 'a.ip', value: '8.8.8.8' }, + { key: 'http.message', value: 'ok' }, + { key: 'http.Status_code', value: '200' }, + { key: 'b.ip', value: '8.8.4.4' }, + ]); + }); +}); + +describe('deduplicateTags()', () => { + it('deduplicates tags', () => { + const tagsInfo = deduplicateTags([ + { key: 'b.ip', value: '8.8.4.4' }, + { key: 'b.ip', value: '8.8.8.8' }, + { key: 'b.ip', value: '8.8.4.4' }, + { key: 'a.ip', value: '8.8.8.8' }, + ]); + + expect(tagsInfo.tags).toEqual([ + { key: 'b.ip', value: '8.8.4.4' }, + { key: 'b.ip', value: '8.8.8.8' }, + { key: 'a.ip', value: '8.8.8.8' }, + ]); + expect(tagsInfo.warnings).toEqual(['Duplicate tag "b.ip:8.8.4.4"']); + }); +}); diff --git a/packages/jaeger-ui-components/src/model/transform-trace-data.tsx b/packages/jaeger-ui-components/src/model/transform-trace-data.tsx new file mode 100644 index 00000000000..0c041f06fa6 --- /dev/null +++ b/packages/jaeger-ui-components/src/model/transform-trace-data.tsx @@ -0,0 +1,184 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import _isEqual from 'lodash/isEqual'; + +// @ts-ignore +import { getTraceSpanIdsAsTree } from '../selectors/trace'; +import { getConfigValue } from '../utils/config/get-config'; +import { KeyValuePair, Span, SpanData, Trace, TraceData } from '../types/trace'; +// @ts-ignore +import TreeNode from '../utils/TreeNode'; + +// exported for tests +export function deduplicateTags(spanTags: KeyValuePair[]) { + const warningsHash: Map = new Map(); + const tags: KeyValuePair[] = spanTags.reduce((uniqueTags, tag) => { + if (!uniqueTags.some(t => t.key === tag.key && t.value === tag.value)) { + uniqueTags.push(tag); + } else { + warningsHash.set(`${tag.key}:${tag.value}`, `Duplicate tag "${tag.key}:${tag.value}"`); + } + return uniqueTags; + }, []); + const warnings = Array.from(warningsHash.values()); + return { tags, warnings }; +} + +// exported for tests +export function orderTags(spanTags: KeyValuePair[], topPrefixes?: string[]) { + const orderedTags: KeyValuePair[] = spanTags.slice(); + const tp = (topPrefixes || []).map((p: string) => p.toLowerCase()); + + orderedTags.sort((a, b) => { + const aKey = a.key.toLowerCase(); + const bKey = b.key.toLowerCase(); + + for (let i = 0; i < tp.length; i++) { + const p = tp[i]; + if (aKey.startsWith(p) && !bKey.startsWith(p)) { + return -1; + } + if (!aKey.startsWith(p) && bKey.startsWith(p)) { + return 1; + } + } + + if (aKey > bKey) { + return 1; + } + if (aKey < bKey) { + return -1; + } + return 0; + }); + + return orderedTags; +} + +/** + * NOTE: Mutates `data` - Transform the HTTP response data into the form the app + * generally requires. + */ +export default function transformTraceData(data: TraceData & { spans: SpanData[] }): Trace | null { + let { traceID } = data; + if (!traceID) { + return null; + } + traceID = traceID.toLowerCase(); + + let traceEndTime = 0; + let traceStartTime = Number.MAX_SAFE_INTEGER; + const spanIdCounts = new Map(); + const spanMap = new Map(); + // filter out spans with empty start times + // eslint-disable-next-line no-param-reassign + data.spans = data.spans.filter(span => Boolean(span.startTime)); + + const max = data.spans.length; + for (let i = 0; i < max; i++) { + const span: Span = data.spans[i] as Span; + const { startTime, duration, processID } = span; + // + let spanID = span.spanID; + // check for start / end time for the trace + if (startTime < traceStartTime) { + traceStartTime = startTime; + } + if (startTime + duration > traceEndTime) { + traceEndTime = startTime + duration; + } + // make sure span IDs are unique + const idCount = spanIdCounts.get(spanID); + if (idCount != null) { + // eslint-disable-next-line no-console + console.warn(`Dupe spanID, ${idCount + 1} x ${spanID}`, span, spanMap.get(spanID)); + if (_isEqual(span, spanMap.get(spanID))) { + // eslint-disable-next-line no-console + console.warn('\t two spans with same ID have `isEqual(...) === true`'); + } + spanIdCounts.set(spanID, idCount + 1); + spanID = `${spanID}_${idCount}`; + span.spanID = spanID; + } else { + spanIdCounts.set(spanID, 1); + } + span.process = data.processes[processID]; + spanMap.set(spanID, span); + } + // tree is necessary to sort the spans, so children follow parents, and + // siblings are sorted by start time + const tree = getTraceSpanIdsAsTree(data); + const spans: Span[] = []; + const svcCounts: Record = {}; + let traceName = ''; + + // Eslint complains about number type not needed but then TS complains it is implicitly any. + // eslint-disable-next-line @typescript-eslint/no-inferrable-types + tree.walk((spanID: string, node: TreeNode, depth: number = 0) => { + if (spanID === '__root__') { + return; + } + const span = spanMap.get(spanID) as Span; + if (!span) { + return; + } + const { serviceName } = span.process; + svcCounts[serviceName] = (svcCounts[serviceName] || 0) + 1; + if (!span.references || !span.references.length) { + traceName = `${serviceName}: ${span.operationName}`; + } + span.relativeStartTime = span.startTime - traceStartTime; + span.depth = depth - 1; + span.hasChildren = node.children.length > 0; + span.warnings = span.warnings || []; + span.tags = span.tags || []; + span.references = span.references || []; + const tagsInfo = deduplicateTags(span.tags); + span.tags = orderTags(tagsInfo.tags, getConfigValue('topTagPrefixes')); + span.warnings = span.warnings.concat(tagsInfo.warnings); + span.references.forEach((ref, index) => { + const refSpan = spanMap.get(ref.spanID) as Span; + if (refSpan) { + // eslint-disable-next-line no-param-reassign + ref.span = refSpan; + if (index > 0) { + // Don't take into account the parent, just other references. + refSpan.subsidiarilyReferencedBy = refSpan.subsidiarilyReferencedBy || []; + refSpan.subsidiarilyReferencedBy.push({ + spanID, + traceID, + span, + refType: ref.refType, + }); + } + } + }); + spans.push(span); + }); + const services = Object.keys(svcCounts).map(name => ({ name, numberOfSpans: svcCounts[name] })); + return { + services, + spans, + traceID, + traceName, + // can't use spread operator for intersection types + // repl: https://goo.gl/4Z23MJ + // issue: https://github.com/facebook/flow/issues/1511 + processes: data.processes, + duration: traceEndTime - traceStartTime, + startTime: traceStartTime, + endTime: traceEndTime, + }; +} diff --git a/packages/jaeger-ui-components/src/scroll-page.test.js b/packages/jaeger-ui-components/src/scroll-page.test.js new file mode 100644 index 00000000000..f04f7aebf80 --- /dev/null +++ b/packages/jaeger-ui-components/src/scroll-page.test.js @@ -0,0 +1,159 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* eslint-disable import/first */ +jest.mock('./Tween'); + +import { scrollBy, scrollTo, cancel } from './scroll-page'; +import Tween from './Tween'; + +// keep track of instances, manually +// https://github.com/facebook/jest/issues/5019 +const tweenInstances = []; + +describe('scroll-by', () => { + beforeEach(() => { + window.scrollY = 100; + tweenInstances.length = 0; + Tween.mockClear(); + Tween.mockImplementation(opts => { + const rv = { to: opts.to, onUpdate: opts.onUpdate }; + Object.keys(Tween.prototype).forEach(name => { + if (name !== 'constructor') { + rv[name] = jest.fn(); + } + }); + tweenInstances.push(rv); + return rv; + }); + }); + + afterEach(() => { + cancel(); + }); + + describe('scrollBy()', () => { + describe('when `appendToLast` is `false`', () => { + it('scrolls from `window.scrollY` to `window.scrollY + yDelta`', () => { + const yDelta = 10; + scrollBy(yDelta); + const spec = expect.objectContaining({ to: window.scrollY + yDelta }); + expect(Tween.mock.calls).toEqual([[spec]]); + }); + }); + + describe('when `appendToLast` is true', () => { + it('is the same as `appendToLast === false` without an in-progress scroll', () => { + const yDelta = 10; + scrollBy(yDelta, true); + expect(Tween.mock.calls.length).toBe(1); + scrollBy(yDelta, false); + expect(Tween.mock.calls[0]).toEqual(Tween.mock.calls[1]); + }); + + it('is additive when an in-progress scroll is the same direction', () => { + const yDelta = 10; + const spec = expect.objectContaining({ to: window.scrollY + 2 * yDelta }); + scrollBy(yDelta); + scrollBy(yDelta, true); + expect(Tween.mock.calls.length).toBe(2); + expect(Tween.mock.calls[1]).toEqual([spec]); + }); + + it('ignores the in-progress scroll is the other direction', () => { + const yDelta = 10; + const spec = expect.objectContaining({ to: window.scrollY - yDelta }); + scrollBy(yDelta); + scrollBy(-yDelta, true); + expect(Tween.mock.calls.length).toBe(2); + expect(Tween.mock.calls[1]).toEqual([spec]); + }); + }); + }); + + describe('scrollTo', () => { + it('scrolls to `y`', () => { + const to = 10; + const spec = expect.objectContaining({ to }); + scrollTo(to); + expect(Tween.mock.calls).toEqual([[spec]]); + }); + + it('ignores the in-progress scroll', () => { + const to = 10; + const spec = expect.objectContaining({ to }); + scrollTo(Math.random()); + scrollTo(to); + expect(Tween.mock.calls.length).toBe(2); + expect(Tween.mock.calls[1]).toEqual([spec]); + }); + }); + + describe('cancel', () => { + it('cancels the in-progress scroll', () => { + scrollTo(10); + // there is now an in-progress tween + expect(tweenInstances.length).toBe(1); + const tw = tweenInstances[0]; + cancel(); + expect(tw.cancel.mock.calls).toEqual([[]]); + }); + + it('is a noop if there is not an in-progress scroll', () => { + scrollTo(10); + // there is now an in-progress tween + expect(tweenInstances.length).toBe(1); + const tw = tweenInstances[0]; + cancel(); + expect(tw.cancel.mock.calls).toEqual([[]]); + tw.cancel.mockReset(); + // now, we check to see if `cancel()` has an effect on the last created tween + cancel(); + expect(tw.cancel.mock.calls.length).toBe(0); + }); + }); + + describe('_onTweenUpdate', () => { + let oldScrollTo; + + beforeEach(() => { + oldScrollTo = window.scrollTo; + window.scrollTo = jest.fn(); + }); + + afterEach(() => { + window.scrollTo = oldScrollTo; + }); + + it('scrolls to `value`', () => { + const value = 123; + // cause a `Tween` to be created to get a reference to _onTweenUpdate + scrollTo(10); + const { onUpdate } = tweenInstances[0]; + onUpdate({ value, done: false }); + expect(window.scrollTo.mock.calls.length).toBe(1); + expect(window.scrollTo.mock.calls[0][1]).toBe(value); + }); + + it('discards the in-progress scroll if the scroll is done', () => { + // cause a `Tween` to be created to get a reference to _onTweenUpdate + scrollTo(10); + const { onUpdate, cancel: twCancel } = tweenInstances[0]; + onUpdate({ value: 123, done: true }); + // if the tween is not discarded, `cancel()` will cancel it + cancel(); + expect(twCancel.mock.calls.length).toBe(0); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/scroll-page.tsx b/packages/jaeger-ui-components/src/scroll-page.tsx new file mode 100644 index 00000000000..389d122e261 --- /dev/null +++ b/packages/jaeger-ui-components/src/scroll-page.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Tween from './Tween'; + +const DURATION_MS = 350; + +let lastTween: Tween | void; + +// TODO(joe): this util can be modified a bit to be generalized (e.g. take in +// an element as a parameter and use scrollTop instead of window.scrollTo) + +function _onTweenUpdate({ done, value }: { done: boolean; value: number }) { + window.scrollTo(window.scrollX, value); + if (done) { + lastTween = undefined; + } +} + +export function scrollBy(yDelta: number, appendToLast: boolean = false) { + const { scrollY } = window; + let targetFrom = scrollY; + if (appendToLast && lastTween) { + const currentDirection = lastTween.to < scrollY ? 'up' : 'down'; + const nextDirection = yDelta < 0 ? 'up' : 'down'; + if (currentDirection === nextDirection) { + targetFrom = lastTween.to; + } + } + const to = targetFrom + yDelta; + lastTween = new Tween({ to, duration: DURATION_MS, from: scrollY, onUpdate: _onTweenUpdate }); +} + +export function scrollTo(y: number) { + const { scrollY } = window; + lastTween = new Tween({ duration: DURATION_MS, from: scrollY, to: y, onUpdate: _onTweenUpdate }); +} + +export function cancel() { + if (lastTween) { + lastTween.cancel(); + lastTween = undefined; + } +} diff --git a/packages/jaeger-ui-components/src/selectors/process.js b/packages/jaeger-ui-components/src/selectors/process.js new file mode 100644 index 00000000000..495c6428490 --- /dev/null +++ b/packages/jaeger-ui-components/src/selectors/process.js @@ -0,0 +1,16 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const getProcessServiceName = proc => proc.serviceName; +export const getProcessTags = proc => proc.tags; diff --git a/packages/jaeger-ui-components/src/selectors/process.test.js b/packages/jaeger-ui-components/src/selectors/process.test.js new file mode 100644 index 00000000000..0326198fc97 --- /dev/null +++ b/packages/jaeger-ui-components/src/selectors/process.test.js @@ -0,0 +1,30 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as processSelectors from './process'; +import traceGenerator from '../demo/trace-generators'; + +const generatedTrace = traceGenerator.trace({ numberOfSpans: 45 }); + +it('getProcessServiceName() should return the serviceName of the process', () => { + const proc = generatedTrace.processes[Object.keys(generatedTrace.processes)[0]]; + + expect(processSelectors.getProcessServiceName(proc)).toBe(proc.serviceName); +}); + +it('getProcessTags() should return the tags on the process', () => { + const proc = generatedTrace.processes[Object.keys(generatedTrace.processes)[0]]; + + expect(processSelectors.getProcessTags(proc)).toBe(proc.tags); +}); diff --git a/packages/jaeger-ui-components/src/selectors/span.js b/packages/jaeger-ui-components/src/selectors/span.js new file mode 100644 index 00000000000..a3c3b1dc1ca --- /dev/null +++ b/packages/jaeger-ui-components/src/selectors/span.js @@ -0,0 +1,96 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createSelector } from 'reselect'; +import fuzzy from 'fuzzy'; + +import { getProcessServiceName } from './process'; + +export const getSpanId = span => span.spanID; +export const getSpanName = span => span.operationName; +export const getSpanDuration = span => span.duration; +export const getSpanTimestamp = span => span.startTime; +export const getSpanProcessId = span => span.processID; +export const getSpanReferences = span => span.references || []; +export const getSpanReferenceByType = createSelector( + createSelector( + ({ span }) => span, + getSpanReferences + ), + ({ type }) => type, + (references, type) => references.find(ref => ref.refType === type) +); +export const getSpanParentId = createSelector( + span => getSpanReferenceByType({ span, type: 'CHILD_OF' }), + childOfRef => (childOfRef ? childOfRef.spanID : null) +); + +export const getSpanProcess = span => { + if (!span.process) { + throw new Error( + ` + you must hydrate the spans with the processes, perhaps + using hydrateSpansWithProcesses(), before accessing a span's process + ` + ); + } + + return span.process; +}; + +export const getSpanServiceName = createSelector( + getSpanProcess, + getProcessServiceName +); + +export const filterSpansForTimestamps = createSelector( + ({ spans }) => spans, + ({ leftBound }) => leftBound, + ({ rightBound }) => rightBound, + (spans, leftBound, rightBound) => + spans.filter(span => getSpanTimestamp(span) >= leftBound && getSpanTimestamp(span) <= rightBound) +); + +export const filterSpansForText = createSelector( + ({ spans }) => spans, + ({ text }) => text, + (spans, text) => + fuzzy + .filter(text, spans, { + extract: span => `${getSpanServiceName(span)} ${getSpanName(span)}`, + }) + .map(({ original }) => original) +); + +const getTextFilterdSpansAsMap = createSelector( + filterSpansForText, + matchingSpans => + matchingSpans.reduce( + (obj, span) => ({ + ...obj, + [getSpanId(span)]: span, + }), + {} + ) +); + +export const highlightSpansForTextFilter = createSelector( + ({ spans }) => spans, + getTextFilterdSpansAsMap, + (spans, textFilteredSpansMap) => + spans.map(span => ({ + ...span, + muted: !textFilteredSpansMap[getSpanId(span)], + })) +); diff --git a/packages/jaeger-ui-components/src/selectors/span.test.js b/packages/jaeger-ui-components/src/selectors/span.test.js new file mode 100644 index 00000000000..2ae05438136 --- /dev/null +++ b/packages/jaeger-ui-components/src/selectors/span.test.js @@ -0,0 +1,206 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as spanSelectors from './span'; +import traceGenerator from '../demo/trace-generators'; + +const generatedTrace = traceGenerator.trace({ numberOfSpans: 45 }); + +it('getSpanId() should return the name of the span', () => { + const span = generatedTrace.spans[0]; + + expect(spanSelectors.getSpanId(span)).toBe(span.spanID); +}); + +it('getSpanName() should return the name of the span', () => { + const span = generatedTrace.spans[0]; + + expect(spanSelectors.getSpanName(span)).toBe(span.operationName); +}); + +it('getSpanDuration() should return the duration of the span', () => { + const span = generatedTrace.spans[0]; + + expect(spanSelectors.getSpanDuration(span)).toBe(span.duration); +}); + +it('getSpanTimestamp() should return the timestamp of the span', () => { + const span = generatedTrace.spans[0]; + + expect(spanSelectors.getSpanTimestamp(span)).toBe(span.startTime); +}); + +it('getSpanReferences() should return the span reference array', () => { + expect(spanSelectors.getSpanReferences(generatedTrace.spans[0])).toEqual( + generatedTrace.spans[0].references + ); +}); + +it('getSpanReferences() should return empty array for null references', () => { + expect(spanSelectors.getSpanReferences({ references: null })).toEqual([]); +}); + +it('getSpanReferenceByType() should return the span reference requested', () => { + expect( + spanSelectors.getSpanReferenceByType({ + span: generatedTrace.spans[1], + type: 'CHILD_OF', + }).refType + ).toBe('CHILD_OF'); +}); + +it('getSpanReferenceByType() should return undefined if one does not exist', () => { + expect( + spanSelectors.getSpanReferenceByType({ + span: generatedTrace.spans[0], + type: 'FOLLOWS_FROM', + }) + ).toBe(undefined); +}); + +it('getSpanParentId() should return the spanID of the parent span', () => { + expect(spanSelectors.getSpanParentId(generatedTrace.spans[1])).toBe( + generatedTrace.spans[1].references.find(({ refType }) => refType === 'CHILD_OF').spanID + ); +}); + +it('getSpanParentId() should return null if no CHILD_OF reference exists', () => { + expect(spanSelectors.getSpanParentId(generatedTrace.spans[0])).toBe(null); +}); + +it('getSpanProcessId() should return the processID of the span', () => { + const span = generatedTrace.spans[0]; + + expect(spanSelectors.getSpanProcessId(span)).toBe(span.processID); +}); + +it('getSpanProcess() should return the process of the span', () => { + const span = { + ...generatedTrace.spans[0], + process: {}, + }; + + expect(spanSelectors.getSpanProcess(span)).toBe(span.process); +}); + +it('getSpanProcess() should throw if no process exists', () => { + expect(() => spanSelectors.getSpanProcess(generatedTrace.spans[0])).toThrow(); +}); + +it('getSpanServiceName() should return the service name of the span', () => { + const serviceName = 'bagel'; + const span = { + ...generatedTrace.spans[0], + process: { serviceName }, + }; + + expect(spanSelectors.getSpanServiceName(span)).toBe(serviceName); +}); + +it('filterSpansForTimestamps() should return a filtered list of spans between the times', () => { + const now = new Date().getTime() * 1000; + const spans = [ + { + startTime: now - 1000, + id: 'start-time-1', + }, + { + startTime: now, + id: 'start-time-2', + }, + { + startTime: now + 1000, + id: 'start-time-3', + }, + ]; + + expect( + spanSelectors.filterSpansForTimestamps({ + spans, + leftBound: now - 500, + rightBound: now + 500, + }) + ).toEqual([spans[1]]); + + expect( + spanSelectors.filterSpansForTimestamps({ + spans, + leftBound: now - 2000, + rightBound: now + 2000, + }) + ).toEqual([...spans]); + + expect( + spanSelectors.filterSpansForTimestamps({ + spans, + leftBound: now - 1000, + rightBound: now, + }) + ).toEqual([spans[0], spans[1]]); + + expect( + spanSelectors.filterSpansForTimestamps({ + spans, + leftBound: now, + rightBound: now + 1000, + }) + ).toEqual([spans[1], spans[2]]); +}); + +it('filterSpansForText() should return a filtered list of spans between the times', () => { + const spans = [ + { + operationName: 'GET /mything', + process: { + serviceName: 'alpha', + }, + id: 'start-time-1', + }, + { + operationName: 'GET /another', + process: { + serviceName: 'beta', + }, + id: 'start-time-1', + }, + { + operationName: 'POST /mything', + process: { + serviceName: 'alpha', + }, + id: 'start-time-1', + }, + ]; + + expect( + spanSelectors.filterSpansForText({ + spans, + text: '/mything', + }) + ).toEqual([spans[0], spans[2]]); + + expect( + spanSelectors.filterSpansForText({ + spans, + text: 'GET', + }) + ).toEqual([spans[0], spans[1]]); + + expect( + spanSelectors.filterSpansForText({ + spans, + text: 'alpha', + }) + ).toEqual([spans[0], spans[2]]); +}); diff --git a/packages/jaeger-ui-components/src/selectors/trace.fixture.js b/packages/jaeger-ui-components/src/selectors/trace.fixture.js new file mode 100644 index 00000000000..8f99f996bfd --- /dev/null +++ b/packages/jaeger-ui-components/src/selectors/trace.fixture.js @@ -0,0 +1,60 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// See https://github.com/jaegertracing/jaeger-ui/issues/115 for details. +// eslint-disable-next-line import/prefer-default-export +export const followsFromRef = { + processes: { + p1: { + serviceName: 'issue115', + tags: [], + }, + }, + spans: [ + { + duration: 1173, + flags: 1, + logs: [], + operationName: 'thread', + processID: 'p1', + references: [ + { + refType: 'FOLLOWS_FROM', + spanID: 'ea7cfaca83f0724b', + traceID: '2992f2a5b5d037a8aabffd08ef384237', + }, + ], + spanID: '1bdf4201221bb2ac', + startTime: 1509533706521220, + tags: [], + traceID: '2992f2a5b5d037a8aabffd08ef384237', + warnings: null, + }, + { + duration: 70406, + flags: 1, + logs: [], + operationName: 'demo', + processID: 'p1', + references: [], + spanID: 'ea7cfaca83f0724b', + startTime: 1509533706470949, + tags: [], + traceID: '2992f2a5b5d037a8aabffd08ef384237', + warnings: null, + }, + ], + traceID: '2992f2a5b5d037a8aabffd08ef384237', + warnings: null, +}; diff --git a/packages/jaeger-ui-components/src/selectors/trace.js b/packages/jaeger-ui-components/src/selectors/trace.js new file mode 100644 index 00000000000..0c092f06daa --- /dev/null +++ b/packages/jaeger-ui-components/src/selectors/trace.js @@ -0,0 +1,343 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createSelector, createStructuredSelector } from 'reselect'; + +import { + getSpanId, + getSpanName, + getSpanServiceName, + getSpanTimestamp, + getSpanDuration, + getSpanProcessId, +} from './span'; +import { getProcessServiceName } from './process'; +import { formatMillisecondTime, formatSecondTime, ONE_SECOND } from '../utils/date'; +import { numberSortComparator } from '../utils/sort'; +import TreeNode from '../utils/TreeNode'; + +export const getTraceId = trace => trace.traceID; + +export const getTraceSpans = trace => trace.spans; + +const getTraceProcesses = trace => trace.processes; + +const getSpanWithProcess = createSelector( + state => state.span, + state => state.processes, + (span, processes) => ({ + ...span, + process: processes[getSpanProcessId(span)], + }) +); + +export const getTraceSpansAsMap = createSelector( + getTraceSpans, + spans => spans.reduce((map, span) => map.set(getSpanId(span), span), new Map()) +); + +export const TREE_ROOT_ID = '__root__'; + +/** + * Build a tree of { value: spanID, children } items derived from the + * `span.references` information. The tree represents the grouping of parent / + * child relationships. The root-most node is nominal in that + * `.value === TREE_ROOT_ID`. This is done because a root span (the main trace + * span) is not always included with the trace data. Thus, there can be + * multiple top-level spans, and the root node acts as their common parent. + * + * The children are sorted by `span.startTime` after the tree is built. + * + * @param {Trace} trace The trace to build the tree of spanIDs. + * @return {TreeNode} A tree of spanIDs derived from the relationships + * between spans in the trace. + */ +export function getTraceSpanIdsAsTree(trace) { + const nodesById = new Map(trace.spans.map(span => [span.spanID, new TreeNode(span.spanID)])); + const spansById = new Map(trace.spans.map(span => [span.spanID, span])); + const root = new TreeNode(TREE_ROOT_ID); + trace.spans.forEach(span => { + const node = nodesById.get(span.spanID); + if (Array.isArray(span.references) && span.references.length) { + const { refType, spanID: parentID } = span.references[0]; + if (refType === 'CHILD_OF' || refType === 'FOLLOWS_FROM') { + const parent = nodesById.get(parentID) || root; + parent.children.push(node); + } else { + throw new Error(`Unrecognized ref type: ${refType}`); + } + } else { + root.children.push(node); + } + }); + const comparator = (nodeA, nodeB) => { + const a = spansById.get(nodeA.value); + const b = spansById.get(nodeB.value); + return +(a.startTime > b.startTime) || +(a.startTime === b.startTime) - 1; + }; + trace.spans.forEach(span => { + const node = nodesById.get(span.spanID); + if (node.children.length > 1) { + node.children.sort(comparator); + } + }); + root.children.sort(comparator); + return root; +} + +// attach "process" as an object to each span. +export const hydrateSpansWithProcesses = trace => { + const spans = getTraceSpans(trace); + const processes = getTraceProcesses(trace); + + return { + ...trace, + spans: spans.map(span => getSpanWithProcess({ span, processes })), + }; +}; + +export const getTraceSpanCount = createSelector( + getTraceSpans, + spans => spans.length +); + +export const getTraceTimestamp = createSelector( + getTraceSpans, + spans => + spans.reduce( + (prevTimestamp, span) => + prevTimestamp ? Math.min(prevTimestamp, getSpanTimestamp(span)) : getSpanTimestamp(span), + null + ) +); + +export const getTraceDuration = createSelector( + getTraceSpans, + getTraceTimestamp, + (spans, timestamp) => + spans.reduce( + (prevDuration, span) => + prevDuration + ? Math.max(getSpanTimestamp(span) - timestamp + getSpanDuration(span), prevDuration) + : getSpanDuration(span), + null + ) +); + +export const getTraceEndTimestamp = createSelector( + getTraceTimestamp, + getTraceDuration, + (timestamp, duration) => timestamp + duration +); + +export const getParentSpan = createSelector( + getTraceSpanIdsAsTree, + getTraceSpansAsMap, + (tree, spanMap) => + tree.children + .map(node => spanMap.get(node.value)) + .sort((spanA, spanB) => numberSortComparator(getSpanTimestamp(spanA), getSpanTimestamp(spanB)))[0] +); + +export const getTraceDepth = createSelector( + getTraceSpanIdsAsTree, + spanTree => spanTree.depth - 1 +); + +export const getSpanDepthForTrace = createSelector( + createSelector( + state => state.trace, + getTraceSpanIdsAsTree + ), + createSelector( + state => state.span, + getSpanId + ), + (node, spanID) => node.getPath(spanID).length - 1 +); + +export const getTraceServices = createSelector( + getTraceProcesses, + processes => + Object.keys(processes).reduce( + (services, processID) => services.add(getProcessServiceName(processes[processID])), + new Set() + ) +); + +export const getTraceServiceCount = createSelector( + getTraceServices, + services => services.size +); + +// establish constants to determine how math should be handled +// for nanosecond-to-millisecond conversions. +export const DURATION_FORMATTERS = { + ms: formatMillisecondTime, + s: formatSecondTime, +}; + +const getDurationFormatterForTrace = createSelector( + getTraceDuration, + totalDuration => (totalDuration >= ONE_SECOND ? DURATION_FORMATTERS.s : DURATION_FORMATTERS.ms) +); + +export const formatDurationForUnit = createSelector( + ({ duration }) => duration, + ({ unit }) => DURATION_FORMATTERS[unit], + (duration, formatter) => formatter(duration) +); + +export const formatDurationForTrace = createSelector( + ({ duration }) => duration, + createSelector( + ({ trace }) => trace, + getDurationFormatterForTrace + ), + (duration, formatter) => formatter(duration) +); + +export const getSortedSpans = createSelector( + ({ trace }) => trace, + ({ spans }) => spans, + ({ sort }) => sort, + (trace, spans, { dir, comparator, selector }) => + [...spans].sort((spanA, spanB) => dir * comparator(selector(spanA, trace), selector(spanB, trace))) +); + +const getTraceSpansByHierarchyPosition = createSelector( + getTraceSpanIdsAsTree, + tree => { + const hierarchyPositionMap = new Map(); + let i = 0; + tree.walk(spanID => hierarchyPositionMap.set(spanID, i++)); + return hierarchyPositionMap; + } +); + +export const getTreeSizeForTraceSpan = createSelector( + createSelector( + state => state.trace, + getTraceSpanIdsAsTree + ), + createSelector( + state => state.span, + getSpanId + ), + (tree, spanID) => { + const node = tree.find(spanID); + if (!node) { + return -1; + } + return node.size - 1; + } +); + +export const getSpanHierarchySortPositionForTrace = createSelector( + createSelector( + ({ trace }) => trace, + getTraceSpansByHierarchyPosition + ), + ({ span }) => span, + (hierarchyPositionMap, span) => hierarchyPositionMap.get(getSpanId(span)) +); + +export const getTraceName = createSelector( + createSelector( + createSelector( + hydrateSpansWithProcesses, + getParentSpan + ), + createStructuredSelector({ + name: getSpanName, + serviceName: getSpanServiceName, + }) + ), + ({ name, serviceName }) => `${serviceName}: ${name}` +); + +export const omitCollapsedSpans = createSelector( + ({ spans }) => spans, + createSelector( + ({ trace }) => trace, + getTraceSpanIdsAsTree + ), + ({ collapsed }) => collapsed, + (spans, tree, collapse) => { + const hiddenSpanIds = collapse.reduce((result, collapsedSpanId) => { + tree.find(collapsedSpanId).walk(id => id !== collapsedSpanId && result.add(id)); + return result; + }, new Set()); + + return hiddenSpanIds.size > 0 ? spans.filter(span => !hiddenSpanIds.has(getSpanId(span))) : spans; + } +); + +export const DEFAULT_TICK_INTERVAL = 4; +export const DEFAULT_TICK_WIDTH = 3; +export const getTicksForTrace = createSelector( + ({ trace }) => trace, + ({ interval = DEFAULT_TICK_INTERVAL }) => interval, + ({ width = DEFAULT_TICK_WIDTH }) => width, + ( + trace, + interval, + width + // timestamps will be spaced over the interval, starting from the initial timestamp + ) => + [...Array(interval + 1).keys()].map(num => ({ + timestamp: getTraceTimestamp(trace) + getTraceDuration(trace) * (num / interval), + width, + })) +); + +// TODO: delete this when the backend can ensure uniqueness +/* istanbul ignore next */ +export const enforceUniqueSpanIds = createSelector( + /* istanbul ignore next */ trace => trace, + getTraceSpans, + /* istanbul ignore next */ (trace, spans) => { + const map = new Map(); + + return { + ...trace, + spans: spans.reduce((result, span) => { + const spanID = map.has(getSpanId(span)) + ? `${getSpanId(span)}_${map.get(getSpanId(span))}` + : getSpanId(span); + const updatedSpan = { ...span, spanID }; + + if (spanID !== getSpanId(span)) { + // eslint-disable-next-line no-console + console.warn('duplicate spanID in trace replaced', getSpanId(span), 'new:', spanID); + } + + // set the presence of the span in the map or increment the number + map.set(getSpanId(span), (map.get(getSpanId(span)) || 0) + 1); + + return result.concat([updatedSpan]); + }, []), + }; + } +); + +// TODO: delete this when the backend can ensure uniqueness +export const dropEmptyStartTimeSpans = createSelector( + /* istanbul ignore next */ trace => trace, + getTraceSpans, + /* istanbul ignore next */ (trace, spans) => ({ + ...trace, + spans: spans.filter(span => !!getSpanTimestamp(span)), + }) +); diff --git a/packages/jaeger-ui-components/src/selectors/trace.test.js b/packages/jaeger-ui-components/src/selectors/trace.test.js new file mode 100644 index 00000000000..06c28736b53 --- /dev/null +++ b/packages/jaeger-ui-components/src/selectors/trace.test.js @@ -0,0 +1,357 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import _values from 'lodash/values'; + +import { followsFromRef } from './trace.fixture'; +import { + getSpanId, + getSpanName, + getSpanParentId, + getSpanProcess, + getSpanProcessId, + getSpanServiceName, + getSpanTimestamp, +} from './span'; +import * as traceSelectors from './trace'; +import traceGenerator from '../demo/trace-generators'; +import { numberSortComparator } from '../utils/sort'; + +const generatedTrace = traceGenerator.trace({ numberOfSpans: 45 }); + +it('getTraceId() should return the traceID', () => { + expect(traceSelectors.getTraceId(generatedTrace)).toBe(generatedTrace.traceID); +}); + +it('hydrateSpansWithProcesses() should return the trace with processes on each span', () => { + const hydratedTrace = traceSelectors.hydrateSpansWithProcesses(generatedTrace); + + hydratedTrace.spans.forEach(span => + expect(getSpanProcess(span)).toBe(generatedTrace.processes[getSpanProcessId(span)]) + ); +}); + +it('getTraceSpansAsMap() should return a map of all of the spans', () => { + const spanMap = traceSelectors.getTraceSpansAsMap(generatedTrace); + [...spanMap.entries()].forEach(pair => { + expect(pair[1]).toEqual(generatedTrace.spans.find(span => getSpanId(span) === pair[0])); + }); +}); + +describe('getTraceSpanIdsAsTree()', () => { + it('builds the tree properly', () => { + const tree = traceSelectors.getTraceSpanIdsAsTree(generatedTrace); + const spanMap = traceSelectors.getTraceSpansAsMap(generatedTrace); + + tree.walk((value, node) => { + const expectedParentValue = value === traceSelectors.TREE_ROOT_ID ? null : value; + node.children.forEach(childNode => { + expect(getSpanParentId(spanMap.get(childNode.value))).toBe(expectedParentValue); + }); + }); + }); + + it('#115 - handles FOLLOW_FROM refs', () => { + expect(() => traceSelectors.getTraceSpanIdsAsTree(followsFromRef)).not.toThrow(); + }); +}); + +it('getParentSpan() should return the parent span of the tree', () => { + expect(traceSelectors.getParentSpan(generatedTrace)).toBe( + traceSelectors + .getTraceSpansAsMap(generatedTrace) + .get(traceSelectors.getTraceSpanIdsAsTree(generatedTrace).children[0].value) + ); +}); + +it('getParentSpan() should return the first span if there are multiple parents', () => { + const initialTimestamp = new Date().getTime() * 1000; + const firstSpan = { + startTime: initialTimestamp, + spanID: 'my-span-1', + references: [], + }; + + const trace = { + spans: [ + { + startTime: initialTimestamp + 2000, + spanID: 'my-span-3', + references: [], + }, + firstSpan, + { + startTime: initialTimestamp + 1000, + spanID: 'my-span-2', + references: [], + }, + ], + }; + + expect(traceSelectors.getParentSpan(trace)).toBe(firstSpan); +}); + +it('getTraceName() should return a formatted name for the first span', () => { + const hydratedTrace = traceSelectors.hydrateSpansWithProcesses(generatedTrace); + const parentSpan = traceSelectors.getParentSpan(hydratedTrace); + + expect(traceSelectors.getTraceName(hydratedTrace)).toBe( + `${getSpanServiceName(parentSpan)}: ${getSpanName(parentSpan)}` + ); +}); + +it('getTraceSpanCount() should return the length of the spans array', () => { + expect(traceSelectors.getTraceSpanCount(generatedTrace)).toBe(generatedTrace.spans.length); +}); + +it('getTraceDuration() should return the duration for the span', () => { + expect(traceSelectors.getTraceDuration(generatedTrace)).toBe(generatedTrace.spans[0].duration); +}); + +it('getTraceTimestamp() should return the first timestamp for the conventional trace', () => { + expect(traceSelectors.getTraceTimestamp(generatedTrace)).toBe(generatedTrace.spans[0].startTime); +}); + +it('getTraceDepth() should determine the total depth of the trace tree', () => { + expect(traceSelectors.getTraceDepth(generatedTrace)).toBe( + traceSelectors.getTraceSpanIdsAsTree(generatedTrace).depth - 1 + ); +}); + +it('getSpanDepthForTrace() should determine the depth of a given span in the parent', () => { + function testDepthCalc(span) { + let depth = 2; + let currentId = getSpanParentId(span); + + const findCurrentSpanById = item => getSpanId(item) === currentId; + while (currentId !== getSpanId(generatedTrace.spans[0])) { + depth++; + currentId = getSpanParentId(generatedTrace.spans.find(findCurrentSpanById)); + } + + // console.log('hypothetical depth', depth); + + expect( + traceSelectors.getSpanDepthForTrace({ + trace: generatedTrace, + span, + }) + ).toBe(depth); + } + + // test depth calculations for a few random spans + testDepthCalc(generatedTrace.spans[1]); + testDepthCalc(generatedTrace.spans[Math.floor(generatedTrace.spans.length / 2)]); + testDepthCalc(generatedTrace.spans[Math.floor(generatedTrace.spans.length / 4)]); + testDepthCalc(generatedTrace.spans[Math.floor(generatedTrace.spans.length * 0.75)]); +}); + +it('getTraceServices() should return an unique array of all services in the trace', () => { + const svcs = [...traceSelectors.getTraceServices(generatedTrace)].sort(); + const set = new Set(_values(generatedTrace.processes).map(v => v.serviceName)); + const setSvcs = [...set.values()].sort(); + expect(svcs).toEqual(setSvcs); +}); + +it('getTraceServiceCount() should return the length of the service list', () => { + expect(traceSelectors.getTraceServiceCount(generatedTrace)).toBe( + generatedTrace.spans.reduce( + (results, { processID }) => results.add(generatedTrace.processes[processID].serviceName), + new Set() + ).size + ); +}); + +it('formatDurationForUnit() should use the formatters to return the proper value', () => { + expect(traceSelectors.formatDurationForUnit({ duration: 302000, unit: 'ms' })).toBe('302ms'); + + expect(traceSelectors.formatDurationForUnit({ duration: 1302000, unit: 'ms' })).toBe('1302ms'); + + expect(traceSelectors.formatDurationForUnit({ duration: 1302000, unit: 's' })).toBe('1.302s'); + + expect(traceSelectors.formatDurationForUnit({ duration: 90000, unit: 's' })).toBe('0.09s'); +}); + +it('formatDurationForTrace() should return a ms value for traces shorter than a second', () => { + expect( + traceSelectors.formatDurationForTrace({ + trace: { + spans: [{ duration: 600000 }], + }, + duration: 302000, + }) + ).toBe('302ms'); +}); + +it('formatDurationForTrace() should return a s value for traces longer than a second', () => { + expect( + traceSelectors.formatDurationForTrace({ + trace: { + ...generatedTrace, + spans: generatedTrace.spans.concat([ + { + ...generatedTrace.spans[0], + duration: 1000000, + }, + ]), + }, + duration: 302000, + }) + ).toBe('0.302s'); + + expect( + traceSelectors.formatDurationForTrace({ + trace: { + ...generatedTrace, + spans: generatedTrace.spans.concat([ + { + ...generatedTrace.spans[0], + duration: 1200000, + }, + ]), + }, + duration: 302000, + }) + ).toBe('0.302s'); +}); + +it('getSortedSpans() should sort spans given a sort object', () => { + expect( + traceSelectors.getSortedSpans({ + trace: generatedTrace, + spans: generatedTrace.spans, + sort: { + dir: 1, + comparator: numberSortComparator, + selector: getSpanTimestamp, + }, + }) + ).toEqual([...generatedTrace.spans].sort((spanA, spanB) => spanA.startTime - spanB.startTime)); + + expect( + traceSelectors.getSortedSpans({ + trace: generatedTrace, + spans: generatedTrace.spans, + sort: { + dir: -1, + comparator: numberSortComparator, + selector: getSpanTimestamp, + }, + }) + ).toEqual([...generatedTrace.spans].sort((spanA, spanB) => spanB.startTime - spanA.startTime)); +}); + +it('getTreeSizeForTraceSpan() should return the size for the parent span', () => { + expect( + traceSelectors.getTreeSizeForTraceSpan({ + trace: generatedTrace, + span: generatedTrace.spans[0], + }) + ).toBe(generatedTrace.spans.length - 1); +}); + +it('getTreeSizeForTraceSpan() should return the size for a child span', () => { + expect( + traceSelectors.getTreeSizeForTraceSpan({ + trace: generatedTrace, + span: generatedTrace.spans[1], + }) + ).toBe(traceSelectors.getTraceSpanIdsAsTree(generatedTrace).find(generatedTrace.spans[1].spanID).size - 1); +}); + +it('getTreeSizeForTraceSpan() should return -1 for an absent span', () => { + expect( + traceSelectors.getTreeSizeForTraceSpan({ + trace: generatedTrace, + span: { spanID: 'whatever' }, + }) + ).toBe(-1); +}); + +it('getTraceName() should return the trace name based on the parentSpan', () => { + const serviceName = generatedTrace.processes[generatedTrace.spans[0].processID].serviceName; + const operationName = generatedTrace.spans[0].operationName; + + expect(traceSelectors.getTraceName(generatedTrace)).toBe(`${serviceName}: ${operationName}`); +}); + +it('omitCollapsedSpans() should filter out collaped spans', () => { + const span = generatedTrace.spans[1]; + const size = traceSelectors.getTraceSpanIdsAsTree(generatedTrace).find(span.spanID).size - 1; + + expect( + traceSelectors.omitCollapsedSpans({ + trace: generatedTrace, + spans: generatedTrace.spans, + collapsed: [span.spanID], + }).length + ).toBe(generatedTrace.spans.length - size); +}); + +it('getTicksForTrace() should return a list of ticks given interval parameters', () => { + const timestamp = new Date().getTime() * 1000; + const trace = { + spans: [ + { + startTime: timestamp, + duration: 3000000, + }, + ], + }; + + expect( + traceSelectors.getTicksForTrace({ + trace, + interval: 3, + width: 10, + }) + ).toEqual([ + { timestamp, width: 10 }, + { timestamp: timestamp + 1000000, width: 10 }, + { timestamp: timestamp + 2000000, width: 10 }, + { timestamp: timestamp + 3000000, width: 10 }, + ]); +}); + +it('getTicksForTrace() should use defaults', () => { + const timestamp = new Date().getTime() * 1000; + const trace = { + spans: [ + { + startTime: timestamp, + duration: 4000000, + }, + ], + }; + + expect(traceSelectors.getTicksForTrace({ trace })).toEqual([ + { timestamp, width: traceSelectors.DEFAULT_TICK_WIDTH }, + { + timestamp: timestamp + 1000000, + width: traceSelectors.DEFAULT_TICK_WIDTH, + }, + { + timestamp: timestamp + 2000000, + width: traceSelectors.DEFAULT_TICK_WIDTH, + }, + { + timestamp: timestamp + 3000000, + width: traceSelectors.DEFAULT_TICK_WIDTH, + }, + { + timestamp: timestamp + 4000000, + width: traceSelectors.DEFAULT_TICK_WIDTH, + }, + ]); +}); diff --git a/packages/jaeger-ui-components/src/types/TDdgState.tsx b/packages/jaeger-ui-components/src/types/TDdgState.tsx new file mode 100644 index 00000000000..4540714a790 --- /dev/null +++ b/packages/jaeger-ui-components/src/types/TDdgState.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ApiError } from './api-error'; +import { fetchedState } from '../constants'; +import { TDdgModel } from '../model/ddg/types'; + +export type TDdgStateEntry = + | { + state: typeof fetchedState.LOADING; + } + | { + error: ApiError; + state: typeof fetchedState.ERROR; + } + | { + model: TDdgModel; + state: typeof fetchedState.DONE; + viewModifiers: Map; + }; + +type TDdgState = Record; + +// eslint-disable-next-line no-undef +export default TDdgState; diff --git a/packages/jaeger-ui-components/src/types/TNil.tsx b/packages/jaeger-ui-components/src/types/TNil.tsx new file mode 100644 index 00000000000..2d642dbaebb --- /dev/null +++ b/packages/jaeger-ui-components/src/types/TNil.tsx @@ -0,0 +1,18 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +type TNil = null | undefined; + +// eslint-disable-next-line no-undef +export default TNil; diff --git a/packages/jaeger-ui-components/src/types/TTraceDiffState.tsx b/packages/jaeger-ui-components/src/types/TTraceDiffState.tsx new file mode 100644 index 00000000000..2ef0eda4e39 --- /dev/null +++ b/packages/jaeger-ui-components/src/types/TTraceDiffState.tsx @@ -0,0 +1,24 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import TNil from './TNil'; + +type TTraceDiffState = { + a?: string | TNil; + b?: string | TNil; + cohort: string[]; +}; + +// eslint-disable-next-line no-undef +export default TTraceDiffState; diff --git a/packages/jaeger-ui-components/src/types/TTraceTimeline.tsx b/packages/jaeger-ui-components/src/types/TTraceTimeline.tsx new file mode 100644 index 00000000000..1fd1f4300b5 --- /dev/null +++ b/packages/jaeger-ui-components/src/types/TTraceTimeline.tsx @@ -0,0 +1,28 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import DetailState from '../TraceTimelineViewer/SpanDetail/DetailState'; +import TNil from './TNil'; + +type TTraceTimeline = { + childrenHiddenIDs: Set; + detailStates: Map; + hoverIndentGuideIds: Set; + shouldScrollToFirstUiFindMatch: boolean; + spanNameColumnWidth: number; + traceID: string | TNil; +}; + +// eslint-disable-next-line no-undef +export default TTraceTimeline; diff --git a/packages/jaeger-ui-components/src/types/api-error.tsx b/packages/jaeger-ui-components/src/types/api-error.tsx new file mode 100644 index 00000000000..1cccaf6e2e9 --- /dev/null +++ b/packages/jaeger-ui-components/src/types/api-error.tsx @@ -0,0 +1,24 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export type ApiError = // eslint-disable-line import/prefer-default-export + | string + | { + message: string; + httpStatus?: any; + httpStatusText?: string; + httpUrl?: string; + httpQuery?: string; + httpBody?: string; + }; diff --git a/packages/jaeger-ui-components/src/types/archive.tsx b/packages/jaeger-ui-components/src/types/archive.tsx new file mode 100644 index 00000000000..1006800d203 --- /dev/null +++ b/packages/jaeger-ui-components/src/types/archive.tsx @@ -0,0 +1,25 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ApiError } from './api-error'; + +export type TraceArchive = { + isLoading?: boolean; + isArchived?: boolean; + isError?: boolean; + error?: ApiError; + isAcknowledged?: boolean; +}; + +export type TracesArchive = Record; diff --git a/packages/jaeger-ui-components/src/types/config.tsx b/packages/jaeger-ui-components/src/types/config.tsx new file mode 100644 index 00000000000..d0fc4a3f5eb --- /dev/null +++ b/packages/jaeger-ui-components/src/types/config.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TNil } from '.'; + +export type ConfigMenuItem = { + label: string; + url: string; + anchorTarget?: '_self' | '_blank' | '_parent' | '_top'; +}; + +export type ConfigMenuGroup = { + label: string; + items: ConfigMenuItem[]; +}; + +export type TScript = { + text: string; + type: 'inline'; +}; + +export type LinkPatternsConfig = { + type: 'process' | 'tags' | 'logs' | 'traces'; + key?: string; + url: string; + text: string; +}; + +export type Config = { + archiveEnabled?: boolean; + deepDependencies?: { menuEnabled?: boolean }; + dependencies?: { dagMaxServicesLen?: number; menuEnabled?: boolean }; + menu: (ConfigMenuGroup | ConfigMenuItem)[]; + search?: { maxLookback: { label: string; value: string }; maxLimit: number }; + scripts?: TScript[]; + topTagPrefixes?: string[]; + tracking?: { + cookieToDimension?: { + cookie: string; + dimension: string; + }[]; + gaID: string | TNil; + trackErrors: boolean | TNil; + }; + linkPatterns?: LinkPatternsConfig; +}; diff --git a/packages/jaeger-ui-components/src/types/embedded.tsx b/packages/jaeger-ui-components/src/types/embedded.tsx new file mode 100644 index 00000000000..cedc458a50a --- /dev/null +++ b/packages/jaeger-ui-components/src/types/embedded.tsx @@ -0,0 +1,25 @@ +// Copyright (c) 2018 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +type EmbeddedStateV0 = { + version: 'v0'; + searchHideGraph: boolean; + timeline: { + collapseTitle: boolean; + hideMinimap: boolean; + hideSummary: boolean; + }; +}; + +export type EmbeddedState = EmbeddedStateV0; // eslint-disable-line import/prefer-default-export diff --git a/packages/jaeger-ui-components/src/types/index.tsx b/packages/jaeger-ui-components/src/types/index.tsx new file mode 100644 index 00000000000..a99b5b1a295 --- /dev/null +++ b/packages/jaeger-ui-components/src/types/index.tsx @@ -0,0 +1,29 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ApiError } from './api-error'; +import { Trace } from './trace'; + +export * from './trace'; +export { default as TTraceTimeline } from './TTraceTimeline'; +export { default as TNil } from './TNil'; + +export type FetchedState = 'FETCH_DONE' | 'FETCH_ERROR' | 'FETCH_LOADING'; + +export type FetchedTrace = { + data?: Trace; + error?: ApiError; + id: string; + state?: FetchedState; +}; diff --git a/packages/jaeger-ui-components/src/types/search.tsx b/packages/jaeger-ui-components/src/types/search.tsx new file mode 100644 index 00000000000..5747876ee59 --- /dev/null +++ b/packages/jaeger-ui-components/src/types/search.tsx @@ -0,0 +1,54 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TNil } from '.'; + +export type SearchQuery = { + end: number | string; + limit: number | string; + lookback: string; + maxDuration: null | string; + minDuration: null | string; + operation: string | TNil; + service: string; + start: number | string; + tags: string | TNil; +}; + +/** + * Type used to summarize traces for the search page. + */ +export type TraceSummary = { + /** + * Duration of trace in milliseconds. + */ + duration: number; + /** + * Start time of trace in milliseconds. + */ + timestamp: number; + traceName: string; + traceID: string; + numberOfErredSpans: number; + numberOfSpans: number; + services: { name: string; numberOfSpans: number }[]; +}; + +export type TraceSummaries = { + /** + * Duration of longest trace in `traces` in milliseconds. + */ + maxDuration: number; + traces: TraceSummary[]; +}; diff --git a/packages/jaeger-ui-components/src/types/trace.tsx b/packages/jaeger-ui-components/src/types/trace.tsx new file mode 100644 index 00000000000..83f650edff3 --- /dev/null +++ b/packages/jaeger-ui-components/src/types/trace.tsx @@ -0,0 +1,87 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * All timestamps are in microseconds + */ + +// TODO: Everett Tech Debt: Fix KeyValuePair types +export type KeyValuePair = { + key: string; + type: string; + value: any; +}; + +export type Link = { + url: string; + text: string; +}; + +export type Log = { + timestamp: number; + fields: KeyValuePair[]; +}; + +export type Process = { + serviceName: string; + tags: KeyValuePair[]; +}; + +export type SpanReference = { + refType: 'CHILD_OF' | 'FOLLOWS_FROM'; + // eslint-disable-next-line no-use-before-define + span?: Span | null | undefined; + spanID: string; + traceID: string; +}; + +export type SpanData = { + spanID: string; + traceID: string; + processID: string; + operationName: string; + startTime: number; + duration: number; + logs: Log[]; + tags?: KeyValuePair[]; + references?: SpanReference[]; + warnings?: string[] | null; + flags: number; +}; + +export type Span = SpanData & { + depth: number; + hasChildren: boolean; + process: Process; + relativeStartTime: number; + tags: NonNullable; + references: NonNullable; + warnings: NonNullable; + subsidiarilyReferencedBy: SpanReference[]; +}; + +export type TraceData = { + processes: Record; + traceID: string; + warnings?: string[] | null; +}; + +export type Trace = TraceData & { + duration: number; + endTime: number; + spans: Span[]; + startTime: number; + traceName: string; + services: Array<{ name: string; numberOfSpans: number }>; +}; diff --git a/packages/jaeger-ui-components/src/uberUtilityStyles.ts b/packages/jaeger-ui-components/src/uberUtilityStyles.ts new file mode 100644 index 00000000000..cdc675f0fec --- /dev/null +++ b/packages/jaeger-ui-components/src/uberUtilityStyles.ts @@ -0,0 +1,57 @@ +import { css } from 'emotion'; + +export const ubRelative = css` + position: relative; +`; + +export const ubMb1 = css` + margin-bottom: 0.25rem; +`; + +export const ubMy1 = css` + margin-top: 0.25rem; + margin-bottom: 0.25rem; +`; + +export const ubM0 = css` + margin: 0; +`; + +export const ubPx2 = css` + padding-left: 0.5rem; + padding-right: 0.5rem; +`; + +export const ubFlex = css` + display: flex; +`; + +export const ubItemsCenter = css` + align-items: center; +`; + +export const ubFlexAuto = css` + flex: 1 1 auto; + min-width: 0; /* 1 */ + min-height: 0; /* 1 */ +`; + +export const ubTxRightAlign = css` + text-align: right; +`; + +export const ubInlineBlock = css` + display: inline-block; +`; + +export const uAlignIcon = css` + margin: -0.2rem 0.25rem 0 0; +`; + +export const uTxEllipsis = css` + text-overflow: ellipsis; +`; + +export const uWidth100 = css` + width: 100%; +`; diff --git a/packages/jaeger-ui-components/src/uiElementsContext.tsx b/packages/jaeger-ui-components/src/uiElementsContext.tsx new file mode 100644 index 00000000000..d8dd549d398 --- /dev/null +++ b/packages/jaeger-ui-components/src/uiElementsContext.tsx @@ -0,0 +1,202 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; + +export type TooltipPlacement = + | 'top' + | 'left' + | 'right' + | 'bottom' + | 'topLeft' + | 'topRight' + | 'bottomLeft' + | 'bottomRight' + | 'leftTop' + | 'leftBottom' + | 'rightTop' + | 'rightBottom'; +export type PopoverProps = { + content?: React.ReactNode; + arrowPointAtCenter?: boolean; + overlayClassName?: string; + placement?: TooltipPlacement; + children?: React.ReactNode; +}; + +export const UIPopover: React.ComponentType = function UIPopover(props: PopoverProps) { + return ( + + {(elements: Elements) => { + return ; + }} + + ); +}; + +type RenderFunction = () => React.ReactNode; +export type TooltipProps = { + title?: React.ReactNode | RenderFunction; + getPopupContainer?: (triggerNode: Element) => HTMLElement; + overlayClassName?: string; + children?: React.ReactNode; + placement?: TooltipPlacement; + mouseLeaveDelay?: number; + arrowPointAtCenter?: boolean; + onVisibleChange?: (visible: boolean) => void; +}; + +export const UITooltip: React.ComponentType = function UITooltip(props: TooltipProps) { + return ( + + {(elements: Elements) => { + return ; + }} + + ); +}; + +export type IconProps = { + type: string; + className?: string; + onClick?: React.MouseEventHandler; +}; + +export const UIIcon: React.ComponentType = function UIIcon(props: IconProps) { + return ( + + {(elements: Elements) => { + return ; + }} + + ); +}; + +export type DropdownProps = { + overlay: React.ReactNode; + placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight'; + trigger?: Array<'click' | 'hover' | 'contextMenu'>; + children?: React.ReactNode; +}; + +export const UIDropdown = function UIDropdown(props: DropdownProps) { + return ( + + {(elements: Elements) => { + return ; + }} + + ); +}; + +export type MenuProps = { + children?: React.ReactNode; +}; + +export const UIMenu = function UIMenu(props: MenuProps) { + return ( + + {(elements: Elements) => { + return ; + }} + + ); +}; + +export type MenuItemProps = { + children?: React.ReactNode; +}; + +export const UIMenuItem = function UIMenuItem(props: MenuItemProps) { + return ( + + {(elements: Elements) => { + return ; + }} + + ); +}; + +export type ButtonHTMLType = 'submit' | 'button' | 'reset'; +export type ButtonProps = { + children?: React.ReactNode; + className?: string; + htmlType?: ButtonHTMLType; + icon?: string; + onClick?: React.MouseEventHandler; +}; + +export const UIButton = function UIButton(props: ButtonProps) { + return ( + + {(elements: Elements) => { + return ; + }} + + ); +}; + +export type DividerProps = { + className?: string; + type?: 'vertical' | 'horizontal'; +}; + +export const UIDivider = function UIDivider(props: DividerProps) { + return ( + + {(elements: Elements) => { + return ; + }} + + ); +}; + +type Elements = { + Popover: React.ComponentType; + Tooltip: React.ComponentType; + Icon: React.ComponentType; + Dropdown: React.ComponentType; + Menu: React.ComponentType; + MenuItem: React.ComponentType; + Button: React.ComponentType; + Divider: React.ComponentType; +}; + +/** + * Allows for injecting custom UI elements that will be used. Mainly for styling and removing dependency on + * any specific UI library but can also inject specific behaviour. + */ +const UIElementsContext = React.createContext(undefined); +UIElementsContext.displayName = 'UIElementsContext'; +export default UIElementsContext; + +type GetElementsContextProps = { + children: (elements: Elements) => React.ReactNode; +}; + +/** + * Convenience render prop style component to handle error state when elements are not defined. + */ +export function GetElementsContext(props: GetElementsContextProps) { + return ( + + {(value: Elements | undefined) => { + if (!value) { + throw new Error('Elements context is required. You probably forget to use UIElementsContext.Provider'); + } + return props.children(value); + }} + + ); +} diff --git a/packages/jaeger-ui-components/src/url/ReferenceLink.test.js b/packages/jaeger-ui-components/src/url/ReferenceLink.test.js new file mode 100644 index 00000000000..20867663e5d --- /dev/null +++ b/packages/jaeger-ui-components/src/url/ReferenceLink.test.js @@ -0,0 +1,72 @@ +// Copyright (c) 2019 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow, mount } from 'enzyme'; + +import ReferenceLink from './ReferenceLink'; +import ExternalLinkContext from './externalLinkContext'; + +describe(ReferenceLink, () => { + const focusMock = jest.fn(); + + const sameTraceRef = { + refType: 'CHILD_OF', + traceID: 'trace1', + spanID: 'span1', + span: { + // not null or undefined is an indicator of an internal reference + }, + }; + + const externalRef = { + refType: 'CHILD_OF', + traceID: 'trace2', + spanID: 'span2', + }; + + describe('rendering', () => { + it('render for this trace', () => { + const component = shallow(); + const link = component.find('a'); + expect(link.length).toBe(1); + expect(link.props().role).toBe('button'); + }); + + it('render for external trace', () => { + const component = mount( + `${trace}/${span}`}> + + + ); + const link = component.find('a[href="trace2/span2"]'); + expect(link.length).toBe(1); + }); + + it('throws if ExternalLinkContext is not set', () => { + expect(() => mount()).toThrow( + 'ExternalLinkContext' + ); + }); + }); + describe('focus span', () => { + it('call focusSpan', () => { + focusMock.mockReset(); + const component = shallow(); + const link = component.find('a'); + link.simulate('click'); + expect(focusMock).toHaveBeenLastCalledWith('span1'); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/url/ReferenceLink.tsx b/packages/jaeger-ui-components/src/url/ReferenceLink.tsx new file mode 100644 index 00000000000..e96f1e482a0 --- /dev/null +++ b/packages/jaeger-ui-components/src/url/ReferenceLink.tsx @@ -0,0 +1,58 @@ +// Copyright (c) 2019 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { SpanReference } from '../types/trace'; +import ExternalLinkContext from './externalLinkContext'; + +type ReferenceLinkProps = { + reference: SpanReference; + children: React.ReactNode; + className?: string; + focusSpan: (spanID: string) => void; + onClick?: () => void; +}; + +export default function ReferenceLink(props: ReferenceLinkProps) { + const { reference, children, className, focusSpan, ...otherProps } = props; + delete otherProps.onClick; + if (reference.span) { + return ( + focusSpan(reference.spanID)} className={className} {...otherProps}> + {children} + + ); + } + + return ( + + {createLinkToExternalSpan => { + if (!createLinkToExternalSpan) { + throw new Error("ExternalLinkContext does not have a value, you probably forgot to setup it's provider"); + } + return ( + + {children} + + ); + }} + + ); +} diff --git a/packages/jaeger-ui-components/src/url/externalLinkContext.tsx b/packages/jaeger-ui-components/src/url/externalLinkContext.tsx new file mode 100644 index 00000000000..a14d47cb3a5 --- /dev/null +++ b/packages/jaeger-ui-components/src/url/externalLinkContext.tsx @@ -0,0 +1,24 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; + +/** + * There are several places where external links to spans are created. The url layout though is something + * that should be decided on the application level and not on the component level but at the same time + * propagating the factory function everywhere could be cumbersome so we use this context for that. + */ +const ExternalLinkContext = React.createContext<((traceID: string, spanID: string) => string) | undefined>(undefined); +ExternalLinkContext.displayName = 'ExternalLinkContext'; +export default ExternalLinkContext; diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.test.js b/packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.test.js new file mode 100644 index 00000000000..9821c6a0079 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.test.js @@ -0,0 +1,303 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import DraggableManager from './DraggableManager'; +import EUpdateTypes from './EUpdateTypes'; + +describe('DraggableManager', () => { + const baseClientX = 100; + // left button mouse events have `.button === 0` + const baseMouseEvt = { button: 0, clientX: baseClientX }; + const tag = 'some-tag'; + let bounds; + let getBounds; + let ctorOpts; + let instance; + + function startDragging(dragManager) { + dragManager.handleMouseDown({ ...baseMouseEvt, type: 'mousedown' }); + expect(dragManager.isDragging()).toBe(true); + } + + beforeEach(() => { + bounds = { + clientXLeft: 50, + maxValue: 0.9, + minValue: 0.1, + width: 100, + }; + getBounds = jest.fn(() => bounds); + ctorOpts = { + getBounds, + tag, + onMouseEnter: jest.fn(), + onMouseLeave: jest.fn(), + onMouseMove: jest.fn(), + onDragStart: jest.fn(), + onDragMove: jest.fn(), + onDragEnd: jest.fn(), + resetBoundsOnResize: false, + }; + instance = new DraggableManager(ctorOpts); + }); + + describe('_getPosition()', () => { + it('invokes the getBounds ctor argument', () => { + instance._getPosition(0); + expect(ctorOpts.getBounds.mock.calls).toEqual([[tag]]); + }); + + it('converts clientX to x and [0, 1] value', () => { + const left = 100; + const pos = instance._getPosition(left); + expect(pos).toEqual({ + x: left - bounds.clientXLeft, + value: (left - bounds.clientXLeft) / bounds.width, + }); + }); + + it('clamps x and [0, 1] value based on getBounds().minValue', () => { + const left = 0; + const pos = instance._getPosition(left); + expect(pos).toEqual({ + x: bounds.minValue * bounds.width, + value: bounds.minValue, + }); + }); + + it('clamps x and [0, 1] value based on getBounds().maxValue', () => { + const left = 10000; + const pos = instance._getPosition(left); + expect(pos).toEqual({ + x: bounds.maxValue * bounds.width, + value: bounds.maxValue, + }); + }); + }); + + describe('window resize event listener', () => { + it('is added in the ctor iff `resetBoundsOnResize` param is truthy', () => { + const oldFn = window.addEventListener; + window.addEventListener = jest.fn(); + + ctorOpts.resetBoundsOnResize = false; + instance = new DraggableManager(ctorOpts); + expect(window.addEventListener.mock.calls).toEqual([]); + ctorOpts.resetBoundsOnResize = true; + instance = new DraggableManager(ctorOpts); + expect(window.addEventListener.mock.calls).toEqual([['resize', expect.any(Function)]]); + + window.addEventListener = oldFn; + }); + + it('is removed in `.dispose()` iff `resetBoundsOnResize` param is truthy', () => { + const oldFn = window.removeEventListener; + window.removeEventListener = jest.fn(); + + ctorOpts.resetBoundsOnResize = false; + instance = new DraggableManager(ctorOpts); + instance.dispose(); + expect(window.removeEventListener.mock.calls).toEqual([]); + ctorOpts.resetBoundsOnResize = true; + instance = new DraggableManager(ctorOpts); + instance.dispose(); + expect(window.removeEventListener.mock.calls).toEqual([['resize', expect.any(Function)]]); + + window.removeEventListener = oldFn; + }); + }); + + describe('minor mouse events', () => { + it('throws an error for invalid event types', () => { + const type = 'invalid-event-type'; + const throwers = [ + () => instance.handleMouseEnter({ ...baseMouseEvt, type }), + () => instance.handleMouseMove({ ...baseMouseEvt, type }), + () => instance.handleMouseLeave({ ...baseMouseEvt, type }), + ]; + throwers.forEach(thrower => expect(thrower).toThrow()); + }); + + it('does nothing if already dragging', () => { + startDragging(instance); + expect(getBounds.mock.calls.length).toBe(1); + + instance.handleMouseEnter({ ...baseMouseEvt, type: 'mouseenter' }); + instance.handleMouseMove({ ...baseMouseEvt, type: 'mousemove' }); + instance.handleMouseLeave({ ...baseMouseEvt, type: 'mouseleave' }); + expect(ctorOpts.onMouseEnter).not.toHaveBeenCalled(); + expect(ctorOpts.onMouseMove).not.toHaveBeenCalled(); + expect(ctorOpts.onMouseLeave).not.toHaveBeenCalled(); + + const evt = { ...baseMouseEvt, type: 'invalid-type' }; + expect(() => instance.handleMouseEnter(evt)).not.toThrow(); + + expect(getBounds.mock.calls.length).toBe(1); + }); + + it('passes data based on the mouse event type to callbacks', () => { + const x = baseClientX - bounds.clientXLeft; + const value = (baseClientX - bounds.clientXLeft) / bounds.width; + const cases = [ + { + type: 'mouseenter', + handler: instance.handleMouseEnter, + callback: ctorOpts.onMouseEnter, + updateType: EUpdateTypes.MouseEnter, + }, + { + type: 'mousemove', + handler: instance.handleMouseMove, + callback: ctorOpts.onMouseMove, + updateType: EUpdateTypes.MouseMove, + }, + { + type: 'mouseleave', + handler: instance.handleMouseLeave, + callback: ctorOpts.onMouseLeave, + updateType: EUpdateTypes.MouseLeave, + }, + ]; + + cases.forEach(testCase => { + const { type, handler, callback, updateType } = testCase; + const event = { ...baseMouseEvt, type }; + handler(event); + expect(callback.mock.calls).toEqual([ + [{ event, tag, value, x, manager: instance, type: updateType }], + ]); + }); + }); + }); + + describe('drag events', () => { + let realWindowAddEvent; + let realWindowRmEvent; + + beforeEach(() => { + realWindowAddEvent = window.addEventListener; + realWindowRmEvent = window.removeEventListener; + window.addEventListener = jest.fn(); + window.removeEventListener = jest.fn(); + }); + + afterEach(() => { + window.addEventListener = realWindowAddEvent; + window.removeEventListener = realWindowRmEvent; + }); + + it('throws an error for invalid event types', () => { + expect(() => instance.handleMouseDown({ ...baseMouseEvt, type: 'invalid-event-type' })).toThrow(); + }); + + describe('mousedown', () => { + it('is ignored if already dragging', () => { + startDragging(instance); + window.addEventListener.mockReset(); + ctorOpts.onDragStart.mockReset(); + + expect(getBounds.mock.calls.length).toBe(1); + instance.handleMouseDown({ ...baseMouseEvt, type: 'mousedown' }); + expect(getBounds.mock.calls.length).toBe(1); + + expect(window.addEventListener).not.toHaveBeenCalled(); + expect(ctorOpts.onDragStart).not.toHaveBeenCalled(); + }); + + it('sets `isDragging()` to true', () => { + instance.handleMouseDown({ ...baseMouseEvt, type: 'mousedown' }); + expect(instance.isDragging()).toBe(true); + }); + + it('adds the window mouse listener events', () => { + instance.handleMouseDown({ ...baseMouseEvt, type: 'mousedown' }); + expect(window.addEventListener.mock.calls).toEqual([ + ['mousemove', expect.any(Function)], + ['mouseup', expect.any(Function)], + ]); + }); + }); + + describe('mousemove', () => { + it('is ignored if not already dragging', () => { + instance._handleDragEvent({ ...baseMouseEvt, type: 'mousemove' }); + expect(ctorOpts.onDragMove).not.toHaveBeenCalled(); + startDragging(instance); + instance._handleDragEvent({ ...baseMouseEvt, type: 'mousemove' }); + expect(ctorOpts.onDragMove).toHaveBeenCalled(); + }); + }); + + describe('mouseup', () => { + it('is ignored if not already dragging', () => { + instance._handleDragEvent({ ...baseMouseEvt, type: 'mouseup' }); + expect(ctorOpts.onDragEnd).not.toHaveBeenCalled(); + startDragging(instance); + instance._handleDragEvent({ ...baseMouseEvt, type: 'mouseup' }); + expect(ctorOpts.onDragEnd).toHaveBeenCalled(); + }); + + it('sets `isDragging()` to false', () => { + startDragging(instance); + expect(instance.isDragging()).toBe(true); + instance._handleDragEvent({ ...baseMouseEvt, type: 'mouseup' }); + expect(instance.isDragging()).toBe(false); + }); + + it('removes the window mouse listener events', () => { + startDragging(instance); + expect(window.removeEventListener).not.toHaveBeenCalled(); + instance._handleDragEvent({ ...baseMouseEvt, type: 'mouseup' }); + expect(window.removeEventListener.mock.calls).toEqual([ + ['mousemove', expect.any(Function)], + ['mouseup', expect.any(Function)], + ]); + }); + }); + + it('passes drag event data to the callbacks', () => { + const x = baseClientX - bounds.clientXLeft; + const value = (baseClientX - bounds.clientXLeft) / bounds.width; + const cases = [ + { + type: 'mousedown', + handler: instance.handleMouseDown, + callback: ctorOpts.onDragStart, + updateType: EUpdateTypes.DragStart, + }, + { + type: 'mousemove', + handler: instance._handleDragEvent, + callback: ctorOpts.onDragMove, + updateType: EUpdateTypes.DragMove, + }, + { + type: 'mouseup', + handler: instance._handleDragEvent, + callback: ctorOpts.onDragEnd, + updateType: EUpdateTypes.DragEnd, + }, + ]; + + cases.forEach(testCase => { + const { type, handler, callback, updateType } = testCase; + const event = { ...baseMouseEvt, type }; + handler(event); + expect(callback.mock.calls).toEqual([ + [{ event, tag, value, x, manager: instance, type: updateType }], + ]); + }); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.tsx b/packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.tsx new file mode 100644 index 00000000000..9ebb92ba70b --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.tsx @@ -0,0 +1,224 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import _get from 'lodash/get'; + +import EUpdateTypes from './EUpdateTypes'; +import { DraggableBounds, DraggingUpdate } from './types'; +import { TNil } from '../../types'; + +const LEFT_MOUSE_BUTTON = 0; + +type DraggableManagerOptions = { + getBounds: (tag: string | TNil) => DraggableBounds; + onMouseEnter?: (update: DraggingUpdate) => void; + onMouseLeave?: (update: DraggingUpdate) => void; + onMouseMove?: (update: DraggingUpdate) => void; + onDragStart?: (update: DraggingUpdate) => void; + onDragMove?: (update: DraggingUpdate) => void; + onDragEnd?: (update: DraggingUpdate) => void; + resetBoundsOnResize?: boolean; + tag?: string; +}; + +export default class DraggableManager { + // cache the last known DraggableBounds (invalidate via `#resetBounds()) + _bounds: DraggableBounds | TNil; + _isDragging: boolean; + // optional callbacks for various dragging events + _onMouseEnter: ((update: DraggingUpdate) => void) | TNil; + _onMouseLeave: ((update: DraggingUpdate) => void) | TNil; + _onMouseMove: ((update: DraggingUpdate) => void) | TNil; + _onDragStart: ((update: DraggingUpdate) => void) | TNil; + _onDragMove: ((update: DraggingUpdate) => void) | TNil; + _onDragEnd: ((update: DraggingUpdate) => void) | TNil; + // whether to reset the bounds on window resize + _resetBoundsOnResize: boolean; + + /** + * Get the `DraggableBounds` for the current drag. The returned value is + * cached until either `#resetBounds()` is called or the window is resized + * (assuming `_resetBoundsOnResize` is `true`). The `DraggableBounds` defines + * the range the current drag can span to. It also establishes the left offset + * to adjust `clientX` by (from the `MouseEvent`s). + */ + getBounds: (tag: string | TNil) => DraggableBounds; + + // convenience data + tag: string | TNil; + + // handlers for integration with DOM elements + handleMouseEnter: (event: React.MouseEvent) => void; + handleMouseMove: (event: React.MouseEvent) => void; + handleMouseLeave: (event: React.MouseEvent) => void; + handleMouseDown: (event: React.MouseEvent) => void; + + constructor({ getBounds, tag, resetBoundsOnResize = true, ...rest }: DraggableManagerOptions) { + this.handleMouseDown = this._handleDragEvent; + this.handleMouseEnter = this._handleMinorMouseEvent; + this.handleMouseMove = this._handleMinorMouseEvent; + this.handleMouseLeave = this._handleMinorMouseEvent; + + this.getBounds = getBounds; + this.tag = tag; + this._isDragging = false; + this._bounds = undefined; + this._resetBoundsOnResize = Boolean(resetBoundsOnResize); + if (this._resetBoundsOnResize) { + window.addEventListener('resize', this.resetBounds); + } + this._onMouseEnter = rest.onMouseEnter; + this._onMouseLeave = rest.onMouseLeave; + this._onMouseMove = rest.onMouseMove; + this._onDragStart = rest.onDragStart; + this._onDragMove = rest.onDragMove; + this._onDragEnd = rest.onDragEnd; + } + + _getBounds(): DraggableBounds { + if (!this._bounds) { + this._bounds = this.getBounds(this.tag); + } + return this._bounds; + } + + _getPosition(clientX: number) { + const { clientXLeft, maxValue, minValue, width } = this._getBounds(); + let x = clientX - clientXLeft; + let value = x / width; + if (minValue != null && value < minValue) { + value = minValue; + x = minValue * width; + } else if (maxValue != null && value > maxValue) { + value = maxValue; + x = maxValue * width; + } + return { value, x }; + } + + _stopDragging() { + window.removeEventListener('mousemove', this._handleDragEvent); + window.removeEventListener('mouseup', this._handleDragEvent); + const style = _get(document, 'body.style'); + if (style) { + style.userSelect = null; + } + this._isDragging = false; + } + + isDragging() { + return this._isDragging; + } + + dispose() { + if (this._isDragging) { + this._stopDragging(); + } + if (this._resetBoundsOnResize) { + window.removeEventListener('resize', this.resetBounds); + } + this._bounds = undefined; + this._onMouseEnter = undefined; + this._onMouseLeave = undefined; + this._onMouseMove = undefined; + this._onDragStart = undefined; + this._onDragMove = undefined; + this._onDragEnd = undefined; + } + + resetBounds = () => { + this._bounds = undefined; + }; + + _handleMinorMouseEvent = (event: React.MouseEvent) => { + const { button, clientX, type: eventType } = event; + if (this._isDragging || button !== LEFT_MOUSE_BUTTON) { + return; + } + let type: EUpdateTypes | null = null; + let handler: ((update: DraggingUpdate) => void) | TNil; + if (eventType === 'mouseenter') { + type = EUpdateTypes.MouseEnter; + handler = this._onMouseEnter; + } else if (eventType === 'mouseleave') { + type = EUpdateTypes.MouseLeave; + handler = this._onMouseLeave; + } else if (eventType === 'mousemove') { + type = EUpdateTypes.MouseMove; + handler = this._onMouseMove; + } else { + throw new Error(`invalid event type: ${eventType}`); + } + if (!handler) { + return; + } + const { value, x } = this._getPosition(clientX); + handler({ + event, + type, + value, + x, + manager: this, + tag: this.tag, + }); + }; + + _handleDragEvent = (event: MouseEvent | React.MouseEvent) => { + const { button, clientX, type: eventType } = event; + let type: EUpdateTypes | null = null; + let handler: ((update: DraggingUpdate) => void) | TNil; + if (eventType === 'mousedown') { + if (this._isDragging || button !== LEFT_MOUSE_BUTTON) { + return; + } + window.addEventListener('mousemove', this._handleDragEvent); + window.addEventListener('mouseup', this._handleDragEvent); + const style = _get(document, 'body.style'); + if (style) { + style.userSelect = 'none'; + } + this._isDragging = true; + + type = EUpdateTypes.DragStart; + handler = this._onDragStart; + } else if (eventType === 'mousemove') { + if (!this._isDragging) { + return; + } + type = EUpdateTypes.DragMove; + handler = this._onDragMove; + } else if (eventType === 'mouseup') { + if (!this._isDragging) { + return; + } + this._stopDragging(); + type = EUpdateTypes.DragEnd; + handler = this._onDragEnd; + } else { + throw new Error(`invalid event type: ${eventType}`); + } + if (!handler) { + return; + } + const { value, x } = this._getPosition(clientX); + handler({ + event, + type, + value, + x, + manager: this, + tag: this.tag, + }); + }; +} diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/EUpdateTypes.tsx b/packages/jaeger-ui-components/src/utils/DraggableManager/EUpdateTypes.tsx new file mode 100644 index 00000000000..b9d33d0ee15 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/EUpdateTypes.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// // export default { +// const updateTypes = { +// DRAG_END: 'DRAG_END', +// DRAG_MOVE: 'DRAG_MOVE', +// DRAG_START: 'DRAG_START', +// MOUSE_ENTER: 'MOUSE_ENTER', +// MOUSE_LEAVE: 'MOUSE_LEAVE', +// MOUSE_MOVE: 'MOUSE_MOVE', +// }; + +// const typeUpdateTypes = updateTypes as { [K in keyof typeof updateTypes]: K }; + +enum EUpdateTypes { + DragEnd = 'DragEnd', + DragMove = 'DragMove', + DragStart = 'DragStart', + MouseEnter = 'MouseEnter', + MouseLeave = 'MouseLeave', + MouseMove = 'MouseMove', +} + +export default EUpdateTypes; diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/README.md b/packages/jaeger-ui-components/src/utils/DraggableManager/README.md new file mode 100644 index 00000000000..e21236f56f2 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/README.md @@ -0,0 +1,287 @@ +# DraggbleManager Information and Demo + +In the `src/utils/DraggableManager/demo` folder there is a small project that demonstrates the use of the `DraggableManager` utility. + +The demo contains two components: + +- `DividerDemo`, which occupies the top half of the web page +- `RegionDemo`, which occupies the bottom half of the web page, as shown in the GIF, below + +![GIF of Demo](demo/demo-ux.gif) + +## Caveat + +This DraggableManager utility does not actually "drag" anything, it does not move or drag DOM elements, it just tells us where the mouse is while the mouse is down. Primarily, it listens for `mousedown` and subsequent `mousemove` and then finally `mouseup` events. (It listens to `window` for the `mousemove` and `mouseup` events.) + +What we do with that information is up to us. This is mentioned because you need to handle the DraggableManager callbacks _to create the illusion of dragging_. + +## In brief + +DraggableManager instances provide three (and a half) conveniences: + +- Handle mouse events related to dragging. +- Maps `MouseEvent.clientX` from the [client area](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/clientX) to the local context (yielding `x` (pixels) and `value` (0 -> 1, e.g, `x/width`)). +- Maintains a sense of state in terms of whether or not the subject DOM element is being dragged. For example, it fires `onMouseMove` callbacks when not being dragged and `onDragMove` when being dragged. +- Two other minor conveniences (relating to window events) + +And, DraggableManager instances have two (or three) primary requirements: + +- Mouse events need to be piped into it +- The `getBounds()` constructor parameter must be provided +- At least some of the callbacks need to be handled + +## Conveniences + +### Handles the mouse events related to dragging + +For the purposes of handling mouse events related to the intended dragging functionality, DraggableManager instances expose the following methods (among others): + +- `handleMouseEnter` +- `handleMouseMove` +- `handleMouseLeave` +- `handleMouseDown` + +To use a DraggableManager instance, relevant mouse events should be piped to the above handlers: + +```jsx +
+
+
+``` + +Note: Not all handlers are always necessary. See "Mouse events need to be piped into it" for more details. + +### Maps the `clientX` to `x` and `value` + +`MouseEvent` (and `SyntheticMouseEvent`) events provide the [`clientX`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/clientX) property, which generally needs some adjustments before it's useful. For instance, in the following snippet we transform `clientX` to the `x` within the `
`. The `value` is simply the `x/width` ratio, which is pretty much the percent but divided by `100`. + +```jsx +
+
{ + const { clientX, target } = event; + const { left, width } = target.getBoundingClientRect(); + const localX = clientX - left; + console.log('within the client area, x:', clientX); + console.log('within the div, x: ', localX); + console.log('position along the width: ', localX / width); + }} + /> +
+``` + +In other words, DraggableManager instances convert the data to the relevant context. (The "relevant context" is, naturally, varies... see the `getBounds()` constructor parameter below). + +### Maintains a sense of state + +The callbacks for DraggableManager instances are: + +- onMouseEnter +- onMouseLeave +- onMouseMove +- onDragStart +- onDragMove +- onDragEnd + +Implicit in the breakdown of the callbacks is the notion that `onDrag*` callbacks are fired when dragging and `onMouse*` callbacks are issued, otherwise. + +Therefore, using the DraggableManager util relieves us of the necessity of keeping track of whether we are currently dragging or not. + +### Two other minor conveniences + +When dragging starts, the util then switches over to listening to window events (`mousemove` and `mouseup`). This prevents the dragging from having strange behavior if / when the user moves the mouse anywhere on the page. + +Last but not least... + +The util listens for window resize events and makes adjustments accordingly, preventing things from going crazy (due to miscalibration) if the user resizes the window. This primary relates to the `getBounds()` constructor option (see below). + +## Requirements + +### Mouse events need to be piped into it + +In my use, DraggbaleManager instances become the receiver of the relevant mouse events instead of handlers on the React component. + +For instance, if implementing a draggable divider (see `DividerDemo.js` and the top half of the gif), only `onMouseDown` needs to be handled: + +```jsx +
+
+
+``` + +But, if implementing the ability to drag a sub-range (see `RegionDemo.js` and the bottom of demo gif), you generally want to show a vertical line at the mouse cursor until the dragging starts (`onMouseDown`), then you want to draw the region being dragged. So, the `onMouseMove`, `onMouseLeave` and `onMouseDown` handlers are necessary: + +```jsx +
+ {/* Draw visuals for the currently dragged range, otherwise empty */} +
+``` + +### `getBounds()` constructor parameter + +The crux of the conversion from `clientX` to `x` and `value` is the `getBounds()` constructor parameter. + +The function is a required constructor parameter, and it must return a `DraggableBounds` object: + +``` +type DraggableBounds = { + clientXLeft: number, + maxValue?: number, + minValue?: number, + width: number, +}; +``` + +This generally amounts to calling [`Element#getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) on the DOM element that defines the valid dragging range. + +For instance, in the `DividerDemo`, the function used is `DivideDemo#_getDraggingBounds()`: + +```js +_getDraggingBounds = (): DraggableBounds => { + if (!this._realmElm) { + throw new Error('invalid state'); + } + const { left: clientXLeft, width } = this._realmElm.getBoundingClientRect(); + return { + clientXLeft, + width, + maxValue: 0.98, + minValue: 0.02, + }; +}; +``` + +In the snippet above, `this._realmElm` is the `
` that fills the green draggable region. + +On the other hand, if you need more flexibility, this function can ignore the DOM altogether and do something else entirely. It just needs to return an object with `clientXLeft` and `width` properties, at the minimum. + +`maxValue` and `minValue` are optional and will restrict the extent of the dragging. They are in terms of `value`, not `x`. + +### The callbacks need to be handled + +Last but not least, if the callbacks are ignored, nothing happens. + +In the `DividerDemo`, we're only interested in repositioning the divider when it is dragged. We don't care about mouse related callbacks. So, only the drag related callbacks are handled. And, all of the drag callbacks are handled in the same way: we update the position of the divider. Done. See `DividerDemo#_handleDragEvent()`. + +In the other scenario, `RegionDemo`, we care about showing the red vertical line for mouse-over. This sort of indicates to the user they can click and drag, and when they drag we want to show a region that spans the current drag. So, we handle the mousemove and mouseleave callbacks along with the drag callbacks. + +The `RegionDemo` is a bit more involved, so, to break down how we handle the callbacks... First, we store the following state (in the parent element, incidentally): + +- `regionCursor` is where we draw the cursor indicator (a red vertical line, in the demo). +- `regionDragging` represents the start (at index `0`) and current position (at index `1`) of the region currently being dragged. + +``` +{ + regionCursor: ?number, + regionDragging: ?[number, number], +} +``` + +Then, we handle the callbacks as follows: + +- `onMouseMove` + - Set `regionCursor` to `value` + - This allows us to draw the red vertical line at the cursor +- `onMouseLeave` + - Set `regionCursor` to `null` + - So we know not to draw the red vertical line +- `onDragStart` + - Set `regionDragging` to `[value, value]` + - This allows us to draw the dragging region +- `onDragMove` + - Set `regionDragging` to `[regionDragging[0], value]` + - Again, for drawing the dragging region. We keep `regionDragging[0]` as-is so we always know where the drag started +- `onDragEnd` + - Set `regionDragging` to `null`, set `regionCursor` to `value` + - Setting `regionDragging` to `null` lets us know not to draw the region, and setting `regionCursor` lets us know to draw the cursor right where the user left off + +This is a contrived demo, so `onDragEnd` is kind of boring... Usually we would do something more interesting with the final `x` or `value`. + +## API + +### Constants `updateTypes` + +Used as the `type` field on `DraggingUpdate` objects. + +``` +{ + DRAG_END: 'DRAG_END', + DRAG_MOVE: 'DRAG_MOVE', + DRAG_START: 'DRAG_START', + MOUSE_ENTER: 'MOUSE_ENTER', + MOUSE_LEAVE: 'MOUSE_LEAVE', + MOUSE_MOVE: 'MOUSE_MOVE', +}; +``` + +### Type `DraggingUpdate` + +The data type issued for all callbacks. + +``` +type DraggingUpdate = { + event: SyntheticMouseEvent, + manager: DraggableManager, + tag: ?string, + type: UpdateType, + value: number, + x: number, +}; +``` + +### Type `DraggableBounds` + +The type the `getBounds()` constructor parameter must return. + +``` +type DraggableBounds = { + clientXLeft: number, + maxValue?: number, + minValue?: number, + width: number, +}; +``` + +`clientXLeft` is used to convert [`MouseEvent.clientX`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/clientX) from the client area to the dragging area. + +`maxValue` and `minValue` are in terms of `value` on the updates, e.g. they are in the range from `[0, 1]` where `0` is the far left (e.g. style `left: 0;`) end of the draggable region and `1` is the far right end (style `right: 0`). If set, they will restrict the `x` and `value` issued by the callbacks. + +`width` is used to convert `x` to `value` and is also the span on which `minValue` and `maxValue` are mapped onto when calculating `x` and `value` for issuing callbacks. + +### Constructor parameters + +``` +type DraggableManagerOptions = { + getBounds: (?string) => DraggableBounds, + onMouseEnter?: DraggingUpdate => void, + onMouseLeave?: DraggingUpdate => void, + onMouseMove?: DraggingUpdate => void, + onDragStart?: DraggingUpdate => void, + onDragMove?: DraggingUpdate => void, + onDragEnd?: DraggingUpdate => void, + resetBoundsOnResize?: boolean, + tag?: string, +}; +``` + +`getBounds()` is used to map the `clientX` to whatever the dragging context is. **It is called lazily** and the returned value is cached, until either `DraggableManager#resetBounds()` is called, the window is resized (when `resetBoundsOnResize` is `true`) or `DraggableManager#dispose()` is called. + +The callbacks are all optional. The callbacks all present the same data (`DraggingUpdate`), with the `type` field being set based on which callback is firing (e.g. `type` is `'MOUSE_ENTER'` when `onMouseEnter` is fired), and the `x` and `value` representing the last know position of the mouse cursor. + +If `resetBoundsOnResize` is `true`, the instance resets the cached `DraggableBounds` when the window is resized. + +`tag` is an optional string parameter. It is a convenience field for distinguishing different `DraggableManager` instances. If set on the constructor, it is set on every `DraggingUpdate` that is issued. + +### `DraggableManager# isDragging()` + +Returns `true` when the instance is in a dragged state, e.g. after `onDragStart` is fired and before `onDragEnd` is fired. + +### `DraggableManager# dispose()` + +Removes any event listeners attached to `window` and sets all instance properties to `undefined`. diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.css b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.css new file mode 100644 index 00000000000..609ba6ba291 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.css @@ -0,0 +1,44 @@ +/* +Copyright (c) 2017 Uber Technologies, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.DividerDemo--realm { + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; +} + +.DividerDemo--divider { + background: #888; + border-bottom: none; + border-top: none; + border: 1px solid #a9dccc; + bottom: 0; + cursor: col-resize; + position: absolute; + top: 0; + width: 4px; +} + +.DividerDemo--divider::before { + bottom: 0; + content: ' '; + left: -2px; + position: absolute; + right: -2px; + top: 0; +} diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.tsx b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.tsx new file mode 100644 index 00000000000..6007b55e995 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.tsx @@ -0,0 +1,81 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; + +import { DraggableBounds, DraggingUpdate } from '..'; +import DraggableManager from '../DraggableManager'; +import TNil from '../../../types/TNil'; + +import './DividerDemo.css'; + +type DividerDemoProps = { + position: number; + updateState: (udpate: { dividerPosition: number }) => void; +}; + +export default class DividerDemo extends React.PureComponent { + _dragManager: DraggableManager; + + _realmElm: HTMLElement | TNil; + + constructor(props: DividerDemoProps) { + super(props); + + this._realmElm = null; + + this._dragManager = new DraggableManager({ + getBounds: this._getDraggingBounds, + onDragEnd: this._handleDragEvent, + onDragMove: this._handleDragEvent, + onDragStart: this._handleDragEvent, + }); + } + + _setRealm = (elm: HTMLElement | TNil) => { + this._realmElm = elm; + }; + + _getDraggingBounds = (): DraggableBounds => { + if (!this._realmElm) { + throw new Error('invalid state'); + } + const { left: clientXLeft, width } = this._realmElm.getBoundingClientRect(); + return { + clientXLeft, + width, + maxValue: 0.98, + minValue: 0.02, + }; + }; + + _handleDragEvent = ({ value }: DraggingUpdate) => { + this.props.updateState({ dividerPosition: value }); + }; + + render() { + const { position } = this.props; + const style = { left: `${position * 100}%` }; + return ( +
+
+
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.css b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.css new file mode 100644 index 00000000000..09229c2d492 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.css @@ -0,0 +1,31 @@ +/* +Copyright (c) 2017 Uber Technologies, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.DraggableManagerDemo { + margin: 5em; +} + +.DraggableManagerDemo--scenario { + margin-top: 2em; +} + +.DraggableManagerDemo--realm { + background: #d1f1e7; + border: 1px solid #888; + height: 100px; + position: relative; + width: 800px; +} diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.tsx b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.tsx new file mode 100644 index 00000000000..dc85094536d --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.tsx @@ -0,0 +1,69 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; + +import DividerDemo from './DividerDemo'; +import RegionDemo from './RegionDemo'; +import { TNil } from '../../../types'; + +import './DraggableManagerDemo.css'; + +export type DraggableManagerDemoState = { + dividerPosition: number; + regionCursor: number | TNil; + regionDragging: [number, number] | TNil; +}; + +export default class DraggableManagerDemo extends React.PureComponent<{}, DraggableManagerDemoState> { + state: DraggableManagerDemoState; + + constructor(props: {}) { + super(props); + this.state = { + dividerPosition: 0.25, + regionCursor: null, + regionDragging: null, + }; + } + + _udpateState = (nextState: {}) => { + this.setState(nextState); + }; + + render() { + const { dividerPosition, regionCursor, regionDragging } = this.state; + return ( +
+

DraggableManager demo

+
+

Dragging a Divider

+

Click and drag the gray divider in the colored area, below.

+

Value: {dividerPosition.toFixed(3)}

+
+ +
+
+
+

Dragging a Sub-Region

+

Click and drag horizontally somewhere in the colored area, below.

+

Value: {regionDragging && regionDragging.map(n => n.toFixed(3)).join(', ')}

+
+ +
+
+
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.css b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.css new file mode 100644 index 00000000000..97f50441962 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.css @@ -0,0 +1,38 @@ +/* +Copyright (c) 2017 Uber Technologies, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.RegionDemo--realm { + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; +} + +.RegionDemo--regionCursor { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: red; +} + +.RegionDemo--region { + position: absolute; + top: 0; + bottom: 0; + background: red; +} diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.tsx b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.tsx new file mode 100644 index 00000000000..5338d7f7c4d --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; + +import DraggableManager, { DraggableBounds, DraggingUpdate } from '..'; +import { TNil } from '../../../types'; + +import './RegionDemo.css'; + +type TUpdate = { + regionCursor?: number | null; + regionDragging?: number[] | null; +}; + +type RegionDemoProps = { + regionCursor: number | TNil; + regionDragging: [number, number] | TNil; + updateState: (update: TUpdate) => void; +}; + +export default class RegionDemo extends React.PureComponent { + _dragManager: DraggableManager; + + _realmElm: HTMLElement | TNil; + + constructor(props: RegionDemoProps) { + super(props); + + this._realmElm = null; + + this._dragManager = new DraggableManager({ + getBounds: this._getDraggingBounds, + onDragEnd: this._handleDragEnd, + onDragMove: this._handleDragUpdate, + onDragStart: this._handleDragUpdate, + onMouseMove: this._handleMouseMove, + onMouseLeave: this._handleMouseLeave, + }); + } + + _setRealm = (elm: HTMLElement | TNil) => { + this._realmElm = elm; + }; + + _getDraggingBounds = (): DraggableBounds => { + if (!this._realmElm) { + throw new Error('invalid state'); + } + const { left: clientXLeft, width } = this._realmElm.getBoundingClientRect(); + return { + clientXLeft, + width, + maxValue: 1, + minValue: 0, + }; + }; + + _handleMouseMove = ({ value }: DraggingUpdate) => { + this.props.updateState({ regionCursor: value }); + }; + + _handleMouseLeave = () => { + this.props.updateState({ regionCursor: null }); + }; + + _handleDragUpdate = ({ value }: DraggingUpdate) => { + const { regionDragging: prevRegionDragging } = this.props; + let regionDragging; + if (prevRegionDragging) { + regionDragging = [prevRegionDragging[0], value]; + } else { + regionDragging = [value, value]; + } + this.props.updateState({ regionDragging }); + }; + + _handleDragEnd = ({ value }: DraggingUpdate) => { + this.props.updateState({ regionDragging: null, regionCursor: value }); + }; + + render() { + const { regionCursor, regionDragging } = this.props; + let cursorElm; + let regionElm; + if (regionDragging) { + const [a, b] = regionDragging; + const [left, right] = a < b ? [a, 1 - b] : [b, 1 - a]; + const regionStyle = { left: `${left * 100}%`, right: `${right * 100}%` }; + regionElm =
; + } else if (regionCursor) { + const cursorStyle = { left: `${regionCursor * 100}%` }; + cursorElm =
; + } + return ( +
+ {regionElm} + {cursorElm} +
+ ); + } +} diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/demo-ux.gif b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/demo-ux.gif new file mode 100644 index 00000000000..f6544ff14cc Binary files /dev/null and b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/demo-ux.gif differ diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/index.tsx b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/index.tsx new file mode 100644 index 00000000000..543358925fd --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/demo/index.tsx @@ -0,0 +1,15 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export { default } from './DraggableManagerDemo'; diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/index.tsx b/packages/jaeger-ui-components/src/utils/DraggableManager/index.tsx new file mode 100644 index 00000000000..150607a3925 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/index.tsx @@ -0,0 +1,17 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export * from './types'; +export { default as EUpdateTypes } from './EUpdateTypes'; +export { default } from './DraggableManager'; diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/types.tsx b/packages/jaeger-ui-components/src/utils/DraggableManager/types.tsx new file mode 100644 index 00000000000..16ab7948d4e --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/DraggableManager/types.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; + +import DraggableManager from './DraggableManager'; +import EUpdateTypes from './EUpdateTypes'; +import { TNil } from '../../types'; + +export type DraggableBounds = { + clientXLeft: number; + maxValue?: number; + minValue?: number; + width: number; +}; + +export type DraggingUpdate = { + event: React.MouseEvent | MouseEvent; + manager: DraggableManager; + tag: string | TNil; + type: EUpdateTypes; + value: number; + x: number; +}; diff --git a/packages/jaeger-ui-components/src/utils/TreeNode.js b/packages/jaeger-ui-components/src/utils/TreeNode.js new file mode 100644 index 00000000000..b3ef63c8ccf --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/TreeNode.js @@ -0,0 +1,100 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export default class TreeNode { + static iterFunction(fn, depth = 0) { + return node => fn(node.value, node, depth); + } + + static searchFunction(search) { + if (typeof search === 'function') { + return search; + } + + return (value, node) => (search instanceof TreeNode ? node === search : value === search); + } + + constructor(value, children = []) { + this.value = value; + this.children = children; + } + + get depth() { + return this.children.reduce((depth, child) => Math.max(child.depth + 1, depth), 1); + } + + get size() { + let i = 0; + this.walk(() => i++); + return i; + } + + addChild(child) { + this.children.push(child instanceof TreeNode ? child : new TreeNode(child)); + return this; + } + + find(search) { + const searchFn = TreeNode.iterFunction(TreeNode.searchFunction(search)); + if (searchFn(this)) { + return this; + } + for (let i = 0; i < this.children.length; i++) { + const result = this.children[i].find(search); + if (result) { + return result; + } + } + return null; + } + + getPath(search) { + const searchFn = TreeNode.iterFunction(TreeNode.searchFunction(search)); + + const findPath = (currentNode, currentPath) => { + // skip if we already found the result + const attempt = currentPath.concat([currentNode]); + // base case: return the array when there is a match + if (searchFn(currentNode)) { + return attempt; + } + for (let i = 0; i < currentNode.children.length; i++) { + const child = currentNode.children[i]; + const match = findPath(child, attempt); + if (match) { + return match; + } + } + return null; + }; + + return findPath(this, []); + } + + walk(fn, depth = 0) { + const nodeStack = []; + let actualDepth = depth; + nodeStack.push({ node: this, depth: actualDepth }); + while (nodeStack.length) { + const { node, depth: nodeDepth } = nodeStack.pop(); + fn(node.value, node, nodeDepth); + actualDepth = nodeDepth + 1; + let i = node.children.length - 1; + while (i >= 0) { + nodeStack.push({ node: node.children[i], depth: actualDepth }); + i--; + } + } + } +} diff --git a/packages/jaeger-ui-components/src/utils/TreeNode.test.js b/packages/jaeger-ui-components/src/utils/TreeNode.test.js new file mode 100644 index 00000000000..b102fd8766c --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/TreeNode.test.js @@ -0,0 +1,293 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import TreeNode from './TreeNode'; + +it('TreeNode constructor should return a tree node', () => { + const node = new TreeNode(4); + + expect(node.value).toBe(4); + expect(node.children).toEqual([]); +}); + +it('TreeNode constructor should return a tree node', () => { + const node = new TreeNode(4, [new TreeNode(3)]); + + expect(node.value).toBe(4); + expect(node.children).toEqual([new TreeNode(3)]); +}); + +it('depth should work for a single node', () => { + expect(new TreeNode().depth).toBe(1); +}); + +it('depth should caluclate the depth', () => { + let treeRoot = new TreeNode(1); + let firstChildNode = new TreeNode(2); + firstChildNode = firstChildNode.addChild(3); + firstChildNode = firstChildNode.addChild(4); + firstChildNode = firstChildNode.addChild(5); + let secondChildNode = new TreeNode(6); + let thirdDeepestChildNode = new TreeNode(7); + thirdDeepestChildNode = thirdDeepestChildNode.addChild(8); + thirdDeepestChildNode = thirdDeepestChildNode.addChild(9); + thirdDeepestChildNode = thirdDeepestChildNode.addChild(10); + secondChildNode = secondChildNode.addChild(thirdDeepestChildNode); + firstChildNode = firstChildNode.addChild(secondChildNode); + treeRoot = treeRoot.addChild(firstChildNode); + treeRoot = treeRoot.addChild(11); + treeRoot = treeRoot.addChild(12); + + expect(treeRoot.depth).toBe(5); + expect(secondChildNode.depth).toBe(3); +}); + +it('size should walk to get total number of nodes', () => { + const treeRoot = new TreeNode(1); + const firstChildNode = new TreeNode(2); + firstChildNode.addChild(3); + firstChildNode.addChild(4); + firstChildNode.addChild(5); + const secondChildNode = new TreeNode(6); + const thirdDeepestChildNode = new TreeNode(7); + thirdDeepestChildNode.addChild(8); + thirdDeepestChildNode.addChild(9); + thirdDeepestChildNode.addChild(10); + secondChildNode.addChild(thirdDeepestChildNode); + firstChildNode.addChild(secondChildNode); + treeRoot.addChild(firstChildNode); + treeRoot.addChild(11); + treeRoot.addChild(12); + + expect(treeRoot.size).toBe(12); +}); + +it('addChild() should add a child to the set', () => { + const treeRoot = new TreeNode(4); + treeRoot.addChild(3); + treeRoot.addChild(1); + treeRoot.addChild(2); + + expect(treeRoot).toEqual(new TreeNode(4, [new TreeNode(3), new TreeNode(1), new TreeNode(2)])); +}); + +it('addChild() should support taking a treenode', () => { + const treeRoot = new TreeNode(4); + const otherNode = new TreeNode(2); + treeRoot.addChild(otherNode); + treeRoot.addChild(1); + treeRoot.addChild(2); + + expect(treeRoot).toEqual(new TreeNode(4, [otherNode, new TreeNode(1), new TreeNode(2)])); +}); + +it('addChild() should support the parent argument for nested insertion', () => { + const treeRoot = new TreeNode(1); + const secondTier = new TreeNode(2); + const thirdTier = new TreeNode(3); + treeRoot.addChild(secondTier); + secondTier.addChild(thirdTier); + + expect(treeRoot).toEqual(new TreeNode(1, [new TreeNode(2, [new TreeNode(3)])])); +}); + +it('find() should return the found item for a function', () => { + const treeRoot = new TreeNode(1); + const firstChildNode = new TreeNode(2); + firstChildNode.addChild(3); + firstChildNode.addChild(4); + firstChildNode.addChild(5); + const secondChildNode = new TreeNode(6); + const thirdDeepestChildNode = new TreeNode(7); + thirdDeepestChildNode.addChild(8); + thirdDeepestChildNode.addChild(9); + thirdDeepestChildNode.addChild(10); + secondChildNode.addChild(thirdDeepestChildNode); + firstChildNode.addChild(secondChildNode); + treeRoot.addChild(firstChildNode); + treeRoot.addChild(11); + treeRoot.addChild(12); + + expect(treeRoot.find(value => value === 6)).toEqual(secondChildNode); + expect(treeRoot.find(12)).toEqual(new TreeNode(12)); +}); + +it('find() should return the found item for a value', () => { + const treeRoot = new TreeNode(1); + const firstChildNode = new TreeNode(2); + firstChildNode.addChild(3); + firstChildNode.addChild(4); + firstChildNode.addChild(5); + const secondChildNode = new TreeNode(6); + const thirdDeepestChildNode = new TreeNode(7); + thirdDeepestChildNode.addChild(8); + thirdDeepestChildNode.addChild(9); + thirdDeepestChildNode.addChild(10); + secondChildNode.addChild(thirdDeepestChildNode); + firstChildNode.addChild(secondChildNode); + treeRoot.addChild(firstChildNode); + treeRoot.addChild(11); + treeRoot.addChild(12); + + expect(treeRoot.find(7)).toEqual(thirdDeepestChildNode); + expect(treeRoot.find(12)).toEqual(new TreeNode(12)); +}); + +it('find() should return the found item for a treenode', () => { + const treeRoot = new TreeNode(1); + const firstChildNode = new TreeNode(2); + firstChildNode.addChild(3); + firstChildNode.addChild(4); + firstChildNode.addChild(5); + const secondChildNode = new TreeNode(6); + const thirdDeepestChildNode = new TreeNode(7); + thirdDeepestChildNode.addChild(8); + thirdDeepestChildNode.addChild(9); + thirdDeepestChildNode.addChild(10); + secondChildNode.addChild(thirdDeepestChildNode); + firstChildNode.addChild(secondChildNode); + treeRoot.addChild(firstChildNode); + treeRoot.addChild(11); + treeRoot.addChild(12); + + expect(treeRoot.find(thirdDeepestChildNode)).toEqual(thirdDeepestChildNode); + expect(treeRoot.find(treeRoot)).toEqual(treeRoot); +}); + +it('find() should return null for none found', () => { + const treeRoot = new TreeNode(1); + const firstChildNode = new TreeNode(2); + firstChildNode.addChild(3); + firstChildNode.addChild(4); + firstChildNode.addChild(5); + const secondChildNode = new TreeNode(6); + const thirdDeepestChildNode = new TreeNode(7); + thirdDeepestChildNode.addChild(8); + thirdDeepestChildNode.addChild(9); + thirdDeepestChildNode.addChild(10); + secondChildNode.addChild(thirdDeepestChildNode); + firstChildNode.addChild(secondChildNode); + treeRoot.addChild(firstChildNode); + treeRoot.addChild(11); + treeRoot.addChild(12); + + expect(treeRoot.find(13)).toBe(null); + expect(treeRoot.find(value => value === 'foo')).toBe(null); +}); + +it('getPath() should return the path to the node', () => { + const treeRoot = new TreeNode(1); + const firstChildNode = new TreeNode(2); + firstChildNode.addChild(3); + firstChildNode.addChild(4); + firstChildNode.addChild(5); + const secondChildNode = new TreeNode(6); + const thirdDeepestChildNode = new TreeNode(7); + thirdDeepestChildNode.addChild(8); + thirdDeepestChildNode.addChild(9); + thirdDeepestChildNode.addChild(10); + secondChildNode.addChild(thirdDeepestChildNode); + firstChildNode.addChild(secondChildNode); + treeRoot.addChild(firstChildNode); + treeRoot.addChild(11); + treeRoot.addChild(12); + + expect(treeRoot.getPath(secondChildNode)).toEqual([treeRoot, firstChildNode, secondChildNode]); +}); + +it('getPath() should return null if the node is not in the tree', () => { + const treeRoot = new TreeNode(1); + const firstChildNode = new TreeNode(2); + firstChildNode.addChild(3); + firstChildNode.addChild(4); + firstChildNode.addChild(5); + const secondChildNode = new TreeNode(6); + const thirdDeepestChildNode = new TreeNode(7); + thirdDeepestChildNode.addChild(8); + thirdDeepestChildNode.addChild(9); + thirdDeepestChildNode.addChild(10); + secondChildNode.addChild(thirdDeepestChildNode); + firstChildNode.addChild(secondChildNode); + treeRoot.addChild(firstChildNode); + treeRoot.addChild(11); + treeRoot.addChild(12); + + const exteriorNode = new TreeNode(15); + + expect(treeRoot.getPath(exteriorNode)).toEqual(null); +}); + +it('walk() should iterate over every item once in the right order', () => { + /** + * 1 + * | 2 + * | | 3 + * | | 4 + * | | 5 + * | | 6 + * | | | 7 + * | | | | 8 + * | | | | 9 + * | | | | 10 + * | 11 + * | 12 + */ + + const treeRoot = new TreeNode(1); + const firstChildNode = new TreeNode(2); + firstChildNode.addChild(3); + firstChildNode.addChild(4); + firstChildNode.addChild(5); + const secondChildNode = new TreeNode(6); + const thirdDeepestChildNode = new TreeNode(7); + thirdDeepestChildNode.addChild(8); + thirdDeepestChildNode.addChild(9); + thirdDeepestChildNode.addChild(10); + secondChildNode.addChild(thirdDeepestChildNode); + firstChildNode.addChild(secondChildNode); + treeRoot.addChild(firstChildNode); + treeRoot.addChild(11); + treeRoot.addChild(12); + + let i = 0; + + treeRoot.walk(value => expect(value).toBe(++i)); +}); + +it('walk() should iterate over every item and compute the right deep on each node', () => { + /** + * C0 + * / + * B0 – C1 + * / + * A – B1 – C2 + * \ + * C3 – D + */ + + const nodeA = new TreeNode('A'); + const nodeB0 = new TreeNode('B0'); + const nodeB1 = new TreeNode('B1'); + const nodeC3 = new TreeNode('C3'); + const depthMap = { A: 0, B0: 1, B1: 1, C0: 2, C1: 2, C2: 2, C3: 2, D: 3 }; + nodeA.addChild(nodeB0); + nodeA.addChild(nodeB0); + nodeA.addChild(nodeB1); + nodeB0.addChild('C0'); + nodeB0.addChild('C1'); + nodeB1.addChild('C2'); + nodeB1.addChild(nodeC3); + nodeC3.addChild('D'); + nodeA.walk((value, node, depth) => expect(depth).toBe(depthMap[value])); +}); diff --git a/packages/jaeger-ui-components/src/utils/color-generator.test.js b/packages/jaeger-ui-components/src/utils/color-generator.test.js new file mode 100644 index 00000000000..25c407a2467 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/color-generator.test.js @@ -0,0 +1,37 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import colorGenerator from './color-generator'; + +it('gives the same color for the same key', () => { + colorGenerator.clear(); + const colorOne = colorGenerator.getColorByKey('serviceA'); + const colorTwo = colorGenerator.getColorByKey('serviceA'); + expect(colorOne).toBe(colorTwo); +}); + +it('gives different colors for each for each key', () => { + colorGenerator.clear(); + const colorOne = colorGenerator.getColorByKey('serviceA'); + const colorTwo = colorGenerator.getColorByKey('serviceB'); + expect(colorOne).not.toBe(colorTwo); +}); + +it('should clear cache', () => { + colorGenerator.clear(); + const colorOne = colorGenerator.getColorByKey('serviceA'); + colorGenerator.clear(); + const colorTwo = colorGenerator.getColorByKey('serviceB'); + expect(colorOne).toBe(colorTwo); +}); diff --git a/packages/jaeger-ui-components/src/utils/color-generator.tsx b/packages/jaeger-ui-components/src/utils/color-generator.tsx new file mode 100644 index 00000000000..40177c6d275 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/color-generator.tsx @@ -0,0 +1,98 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const COLORS_HEX = [ + '#17B8BE', + '#F8DCA1', + '#B7885E', + '#FFCB99', + '#F89570', + '#829AE3', + '#E79FD5', + '#1E96BE', + '#89DAC1', + '#B3AD9E', + '#12939A', + '#DDB27C', + '#88572C', + '#FF9833', + '#EF5D28', + '#162A65', + '#DA70BF', + '#125C77', + '#4DC19C', + '#776E57', +]; + +// TS needs the precise return type +function strToRgb(s: string): [number, number, number] { + if (s.length !== 7) { + return [0, 0, 0]; + } + const r = s.slice(1, 3); + const g = s.slice(3, 5); + const b = s.slice(5); + return [parseInt(r, 16), parseInt(g, 16), parseInt(b, 16)]; +} + +export class ColorGenerator { + colorsHex: string[]; + colorsRgb: Array<[number, number, number]>; + cache: Map; + currentIdx: number; + + constructor(colorsHex: string[] = COLORS_HEX) { + this.colorsHex = colorsHex; + this.colorsRgb = colorsHex.map(strToRgb); + this.cache = new Map(); + this.currentIdx = 0; + } + + _getColorIndex(key: string): number { + let i = this.cache.get(key); + if (i == null) { + i = this.currentIdx; + this.cache.set(key, this.currentIdx); + this.currentIdx = ++this.currentIdx % this.colorsHex.length; + } + return i; + } + + /** + * Will assign a color to an arbitrary key. + * If the key has been used already, it will + * use the same color. + */ + getColorByKey(key: string) { + const i = this._getColorIndex(key); + return this.colorsHex[i]; + } + + /** + * Retrieve the RGB values associated with a key. Adds the key and associates + * it with a color if the key is not recognized. + * @return {number[]} An array of three ints [0, 255] representing a color. + */ + getRgbColorByKey(key: string): [number, number, number] { + const i = this._getColorIndex(key); + return this.colorsRgb[i]; + } + + clear() { + this.cache.clear(); + this.currentIdx = 0; + } +} + +export default new ColorGenerator(); diff --git a/packages/jaeger-ui-components/src/utils/config/get-config.tsx b/packages/jaeger-ui-components/src/utils/config/get-config.tsx new file mode 100644 index 00000000000..1a940638c9a --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/config/get-config.tsx @@ -0,0 +1,29 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import _get from 'lodash/get'; + +import defaultConfig from '../../constants/default-config'; + +/** + * Merge the embedded config from the query service (if present) with the + * default config from `../../constants/default-config`. + */ +export default function getConfig() { + return defaultConfig; +} + +export function getConfigValue(path: string) { + return _get(getConfig(), path); +} diff --git a/packages/jaeger-ui-components/src/utils/date.tsx b/packages/jaeger-ui-components/src/utils/date.tsx new file mode 100644 index 00000000000..ad5805b79b7 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/date.tsx @@ -0,0 +1,123 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import moment from 'moment'; +import _round from 'lodash/round'; + +import { toFloatPrecision } from './number'; + +const TODAY = 'Today'; +const YESTERDAY = 'Yesterday'; + +export const STANDARD_DATE_FORMAT = 'YYYY-MM-DD'; +export const STANDARD_TIME_FORMAT = 'HH:mm'; +export const STANDARD_DATETIME_FORMAT = 'MMMM D YYYY, HH:mm:ss.SSS'; +export const ONE_MILLISECOND = 1000; +export const ONE_SECOND = 1000 * ONE_MILLISECOND; +export const DEFAULT_MS_PRECISION = Math.log10(ONE_MILLISECOND); + +/** + * @param {number} timestamp + * @param {number} initialTimestamp + * @param {number} totalDuration + * @return {number} 0-100 percentage + */ +export function getPercentageOfDuration(duration: number, totalDuration: number) { + return (duration / totalDuration) * 100; +} + +const quantizeDuration = (duration: number, floatPrecision: number, conversionFactor: number) => + toFloatPrecision(duration / conversionFactor, floatPrecision) * conversionFactor; + +/** + * @param {number} duration (in microseconds) + * @return {string} formatted, unit-labelled string with time in milliseconds + */ +export function formatDate(duration: number) { + return moment(duration / ONE_MILLISECOND).format(STANDARD_DATE_FORMAT); +} + +/** + * @param {number} duration (in microseconds) + * @return {string} formatted, unit-labelled string with time in milliseconds + */ +export function formatTime(duration: number) { + return moment(duration / ONE_MILLISECOND).format(STANDARD_TIME_FORMAT); +} + +/** + * @param {number} duration (in microseconds) + * @return {string} formatted, unit-labelled string with time in milliseconds + */ +export function formatDatetime(duration: number) { + return moment(duration / ONE_MILLISECOND).format(STANDARD_DATETIME_FORMAT); +} + +/** + * @param {number} duration (in microseconds) + * @return {string} formatted, unit-labelled string with time in milliseconds + */ +export function formatMillisecondTime(duration: number) { + const targetDuration = quantizeDuration(duration, DEFAULT_MS_PRECISION, ONE_MILLISECOND); + return `${moment.duration(targetDuration / ONE_MILLISECOND).asMilliseconds()}ms`; +} + +/** + * @param {number} duration (in microseconds) + * @return {string} formatted, unit-labelled string with time in seconds + */ +export function formatSecondTime(duration: number) { + const targetDuration = quantizeDuration(duration, DEFAULT_MS_PRECISION, ONE_SECOND); + return `${moment.duration(targetDuration / ONE_MILLISECOND).asSeconds()}s`; +} + +/** + * Humanizes the duration based on the inputUnit + * + * Example: + * 5000ms => 5s + * 1000μs => 1ms + */ +export function formatDuration(duration: number, inputUnit = 'microseconds'): string { + let d = duration; + if (inputUnit === 'microseconds') { + d = duration / 1000; + } + let units = 'ms'; + if (d >= 1000) { + units = 's'; + d /= 1000; + } + return _round(d, 2) + units; +} + +export function formatRelativeDate(value: any, fullMonthName = false) { + const m = moment.isMoment(value) ? value : moment(value); + const monthFormat = fullMonthName ? 'MMMM' : 'MMM'; + const dt = new Date(); + if (dt.getFullYear() !== m.year()) { + return m.format(`${monthFormat} D, YYYY`); + } + const mMonth = m.month(); + const mDate = m.date(); + const date = dt.getDate(); + if (mMonth === dt.getMonth() && mDate === date) { + return TODAY; + } + dt.setDate(date - 1); + if (mMonth === dt.getMonth() && mDate === dt.getDate()) { + return YESTERDAY; + } + return m.format(`${monthFormat} D`); +} diff --git a/packages/jaeger-ui-components/src/utils/number.tsx b/packages/jaeger-ui-components/src/utils/number.tsx new file mode 100644 index 00000000000..0f9cd7f2cfa --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/number.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * given a number and a desired precision for the floating + * side, return the number at the new precision. + * + * toFloatPrecision(3.55, 1) // 3.5 + * toFloatPrecision(0.04422, 2) // 0.04 + * toFloatPrecision(6.24e6, 2) // 6240000.00 + * + * does not support numbers that use "e" notation on toString. + * + * @param {number} number + * @param {number} precision + * @return {number} number at new floating precision + */ +export function toFloatPrecision(number: number, precision: number): number { + const log10Length = Math.floor(Math.log10(Math.abs(number))) + 1; + const targetPrecision = precision + log10Length; + + if (targetPrecision <= 0) { + return Math.trunc(number); + } + + return Number(number.toPrecision(targetPrecision)); +} diff --git a/packages/jaeger-ui-components/src/utils/sort.js b/packages/jaeger-ui-components/src/utils/sort.js new file mode 100644 index 00000000000..b75b23b1492 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/sort.js @@ -0,0 +1,41 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export function localeStringComparator(itemA, itemB) { + return itemA.localeCompare(itemB); +} + +export function numberSortComparator(itemA, itemB) { + return itemA - itemB; +} + +export function classNameForSortDir(dir) { + return `sorted ${dir === 1 ? 'ascending' : 'descending'}`; +} + +export function getNewSortForClick(prevSort, column) { + const { defaultDir = 1 } = column; + + return { + key: column.name, + dir: prevSort.key === column.name ? -1 * prevSort.dir : defaultDir, + }; +} + +export function createSortClickHandler(column, currentSortKey, currentSortDir, updateSort) { + return function onClickSortingElement() { + const { key, dir } = getNewSortForClick({ key: currentSortKey, dir: currentSortDir }, column); + updateSort(key, dir); + }; +} diff --git a/packages/jaeger-ui-components/src/utils/sort.test.js b/packages/jaeger-ui-components/src/utils/sort.test.js new file mode 100644 index 00000000000..5f49a279dd9 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/sort.test.js @@ -0,0 +1,109 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import sinon from 'sinon'; + +import * as sortUtils from './sort'; + +it('localeStringComparator() provides a case-insensitive sort', () => { + const arr = ['Z', 'ab', 'AC']; + expect(arr.slice().sort()).toEqual(['AC', 'Z', 'ab']); + expect(arr.slice().sort(sortUtils.localeStringComparator)).toEqual(['ab', 'AC', 'Z']); +}); + +it('localeStringComparator() should properly sort a list of strings', () => { + const arr = ['allen', 'Gustav', 'paul', 'Tim', 'abernathy', 'tucker', 'Steve', 'mike', 'John', 'Paul']; + expect(arr.sort(sortUtils.localeStringComparator)).toEqual([ + 'abernathy', + 'allen', + 'Gustav', + 'John', + 'mike', + 'paul', + 'Paul', + 'Steve', + 'Tim', + 'tucker', + ]); +}); + +it('numberSortComparator() should properly sort a list of numbers', () => { + const arr = [3, -1.1, 4, -1, 9, 4, 2, Infinity, 0, 0]; + + expect(arr.sort(sortUtils.numberSortComparator)).toEqual([-1.1, -1, 0, 0, 2, 3, 4, 4, 9, Infinity]); +}); + +it('classNameForSortDir() should return the proper asc classes', () => { + expect(sortUtils.classNameForSortDir(1)).toBe('sorted ascending'); +}); + +it('classNameForSortDir() should return the proper desc classes', () => { + expect(sortUtils.classNameForSortDir(-1)).toBe('sorted descending'); +}); + +it('getNewSortForClick() should sort to the defaultDir if new column', () => { + // no defaultDir provided + expect(sortUtils.getNewSortForClick({ key: 'alpha', dir: 1 }, { name: 'beta' })).toEqual({ + key: 'beta', + dir: 1, + }); + + // defaultDir provided + expect(sortUtils.getNewSortForClick({ key: 'alpha', dir: 1 }, { name: 'beta', defaultDir: -1 })).toEqual({ + key: 'beta', + dir: -1, + }); +}); + +it('getNewSortForClick() should toggle direction if same column', () => { + expect(sortUtils.getNewSortForClick({ key: 'alpha', dir: 1 }, { name: 'alpha' })).toEqual({ + key: 'alpha', + dir: -1, + }); + + expect(sortUtils.getNewSortForClick({ key: 'alpha', dir: -1 }, { name: 'alpha' })).toEqual({ + key: 'alpha', + dir: 1, + }); +}); + +it('createSortClickHandler() should return a function', () => { + const column = { name: 'alpha' }; + const currentSortKey = 'alpha'; + const currentSortDir = 1; + const updateSort = sinon.spy(); + + expect(typeof sortUtils.createSortClickHandler(column, currentSortKey, currentSortDir, updateSort)).toBe( + 'function' + ); +}); + +it('createSortClickHandler() should call updateSort with the new sort vals', () => { + const column = { name: 'alpha' }; + const prevSort = { key: 'alpha', dir: 1 }; + const currentSortKey = prevSort.key; + const currentSortDir = prevSort.dir; + const updateSort = sinon.spy(); + + const clickHandler = sortUtils.createSortClickHandler(column, currentSortKey, currentSortDir, updateSort); + + clickHandler(); + + expect( + updateSort.calledWith( + sortUtils.getNewSortForClick(prevSort, column).key, + sortUtils.getNewSortForClick(prevSort, column).dir + ) + ).toBeTruthy(); +}); diff --git a/packages/jaeger-ui-components/src/utils/span-ancestor-ids.test.js b/packages/jaeger-ui-components/src/utils/span-ancestor-ids.test.js new file mode 100644 index 00000000000..0714d141b06 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/span-ancestor-ids.test.js @@ -0,0 +1,96 @@ +// Copyright (c) 2018 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import spanAncestorIdsSpy from './span-ancestor-ids'; + +describe('spanAncestorIdsSpy', () => { + const ownSpanID = 'ownSpanID'; + const firstParentSpanID = 'firstParentSpanID'; + const firstParentFirstGrandparentSpanID = 'firstParentFirstGrandparentSpanID'; + const firstParentSecondGrandparentSpanID = 'firstParentSecondGrandparentSpanID'; + const secondParentSpanID = 'secondParentSpanID'; + const rootSpanID = 'rootSpanID'; + const span = { + references: [ + { + span: { + spanID: firstParentSpanID, + references: [ + { + span: { + spanID: firstParentFirstGrandparentSpanID, + references: [ + { + span: { + spanID: rootSpanID, + }, + }, + ], + }, + refType: 'not an ancestor ref type', + }, + { + span: { + spanID: firstParentSecondGrandparentSpanID, + references: [ + { + span: { + spanID: rootSpanID, + }, + refType: 'FOLLOWS_FROM', + }, + ], + }, + refType: 'CHILD_OF', + }, + ], + }, + refType: 'CHILD_OF', + }, + { + span: { + spanID: secondParentSpanID, + }, + refType: 'CHILD_OF', + }, + ], + spanID: ownSpanID, + }; + const expectedAncestorIds = [firstParentSpanID, firstParentSecondGrandparentSpanID, rootSpanID]; + + it('returns an empty array if given falsy span', () => { + expect(spanAncestorIdsSpy(null)).toEqual([]); + }); + + it('returns an empty array if span has no references', () => { + const spanWithoutReferences = { + spanID: 'parentlessSpanID', + references: [], + }; + + expect(spanAncestorIdsSpy(spanWithoutReferences)).toEqual([]); + }); + + it('returns all unique spanIDs from first valid CHILD_OF or FOLLOWS_FROM reference up to the root span', () => { + expect(spanAncestorIdsSpy(span)).toEqual(expectedAncestorIds); + }); + + it('ignores references without a span', () => { + const spanWithSomeEmptyReferences = { + ...span, + references: [{ refType: 'CHILD_OF' }, { refType: 'FOLLOWS_FROM', span: {} }, ...span.references], + }; + expect(spanAncestorIdsSpy(spanWithSomeEmptyReferences)).toEqual(expectedAncestorIds); + }); +}); diff --git a/packages/jaeger-ui-components/src/utils/span-ancestor-ids.tsx b/packages/jaeger-ui-components/src/utils/span-ancestor-ids.tsx new file mode 100644 index 00000000000..eb9aa435540 --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/span-ancestor-ids.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import _find from 'lodash/find'; +import _get from 'lodash/get'; + +import { TNil } from '../types'; +import { Span } from '../types/trace'; + +function getFirstAncestor(span: Span): Span | TNil { + return _get( + _find( + span.references, + ({ span: ref, refType }) => ref && ref.spanID && (refType === 'CHILD_OF' || refType === 'FOLLOWS_FROM') + ), + 'span' + ); +} + +export default function spanAncestorIds(span: Span | TNil): string[] { + const ancestorIDs: string[] = []; + if (!span) { + return ancestorIDs; + } + let ref = getFirstAncestor(span); + while (ref) { + ancestorIDs.push(ref.spanID); + ref = getFirstAncestor(ref); + } + return ancestorIDs; +} diff --git a/packages/jaeger-ui-components/src/utils/test/requestAnimationFrame.js b/packages/jaeger-ui-components/src/utils/test/requestAnimationFrame.js new file mode 100644 index 00000000000..f1087661c2d --- /dev/null +++ b/packages/jaeger-ui-components/src/utils/test/requestAnimationFrame.js @@ -0,0 +1,40 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const DEFAULT_ELAPSE = 0; + +export default function requestAnimationFrame(callback) { + return setTimeout(callback, DEFAULT_ELAPSE); +} + +export function cancelAnimationFrame(id) { + return clearTimeout(id); +} + +export function polyfill(target, msElapse = DEFAULT_ELAPSE) { + const _target = target || global; + if (!_target.requestAnimationFrame) { + if (msElapse === DEFAULT_ELAPSE) { + // eslint-disable-next-line no-param-reassign + _target.requestAnimationFrame = requestAnimationFrame; + } else { + // eslint-disable-next-line no-param-reassign, no-shadow + _target.requestAnimationFrame = callback => setTimeout(callback, msElapse); + } + } + if (!_target.cancelAnimationFrame) { + // eslint-disable-next-line no-param-reassign + _target.cancelAnimationFrame = cancelAnimationFrame; + } +} diff --git a/packages/jaeger-ui-components/tsconfig.json b/packages/jaeger-ui-components/tsconfig.json new file mode 100644 index 00000000000..8508b52fc8d --- /dev/null +++ b/packages/jaeger-ui-components/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@grafana/tsconfig", + "include": ["src/**/*.ts*", "typings"] +} diff --git a/packages/jaeger-ui-components/typings/custom.d.ts b/packages/jaeger-ui-components/typings/custom.d.ts new file mode 100644 index 00000000000..dd70a67124d --- /dev/null +++ b/packages/jaeger-ui-components/typings/custom.d.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// For inlined envvars +declare const process: { + env: { + NODE_ENV: string; + REACT_APP_GA_DEBUG?: string; + REACT_APP_VSN_STATE?: string; + }; +}; + +declare module 'combokeys' { + export default class Combokeys { + constructor(element: HTMLElement); + bind: (binding: string | string[], handler: CombokeysHandler) => void; + reset: () => void; + } +} + +declare module 'react-helmet'; +declare module 'json-markup'; +declare module 'react-vis-force'; +declare module 'tween-functions'; diff --git a/packages/jaeger-ui-components/typings/index.d.ts b/packages/jaeger-ui-components/typings/index.d.ts new file mode 100644 index 00000000000..acad0a02e07 --- /dev/null +++ b/packages/jaeger-ui-components/typings/index.d.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 545f664bcd6..8717d30e3fd 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -7,54 +7,108 @@ import { DataQueryError, DataQueryRequest, CoreApp, + MutableDataFrame, } from '@grafana/data'; import { getFirstNonQueryRowSpecificError } from 'app/core/utils/explore'; import { ExploreId } from 'app/types/explore'; import { shallow } from 'enzyme'; +import AutoSizer from 'react-virtualized-auto-sizer'; import { Explore, ExploreProps } from './Explore'; import { scanStopAction } from './state/actionTypes'; import { toggleGraph } from './state/actions'; import { Provider } from 'react-redux'; import { configureStore } from 'app/store/configureStore'; +import { SecondaryActions } from './SecondaryActions'; +import { TraceView } from './TraceView'; -const setup = (renderMethod: any, propOverrides?: object) => { - const props: ExploreProps = { - changeSize: jest.fn(), - datasourceInstance: { - meta: { - metrics: true, - logs: true, - }, - components: { - ExploreStartPage: {}, - }, - } as DataSourceApi, - datasourceMissing: false, - exploreId: ExploreId.left, - initializeExplore: jest.fn(), - initialized: true, - modifyQueries: jest.fn(), - update: { - datasource: false, - queries: false, - range: false, - mode: false, - ui: false, +const dummyProps: ExploreProps = { + changeSize: jest.fn(), + datasourceInstance: { + meta: { + metrics: true, + logs: true, }, - refreshExplore: jest.fn(), - scanning: false, - scanRange: { - from: '0', - to: '0', + components: { + ExploreStartPage: {}, }, - scanStart: jest.fn(), - scanStopAction: scanStopAction, - setQueries: jest.fn(), - split: false, - queryKeys: [], - initialDatasource: 'test', - initialQueries: [], - initialRange: { + } as DataSourceApi, + datasourceMissing: false, + exploreId: ExploreId.left, + initializeExplore: jest.fn(), + initialized: true, + modifyQueries: jest.fn(), + update: { + datasource: false, + queries: false, + range: false, + mode: false, + ui: false, + }, + refreshExplore: jest.fn(), + scanning: false, + scanRange: { + from: '0', + to: '0', + }, + scanStart: jest.fn(), + scanStopAction: scanStopAction, + setQueries: jest.fn(), + split: false, + queryKeys: [], + initialDatasource: 'test', + initialQueries: [], + initialRange: { + from: toUtc('2019-01-01 10:00:00'), + to: toUtc('2019-01-01 16:00:00'), + raw: { + from: 'now-6h', + to: 'now', + }, + }, + mode: ExploreMode.Metrics, + initialUI: { + showingTable: false, + showingGraph: false, + showingLogs: false, + }, + isLive: false, + syncedTimes: false, + updateTimeRange: jest.fn(), + graphResult: [], + loading: false, + absoluteRange: { + from: 0, + to: 0, + }, + showingGraph: false, + showingTable: false, + timeZone: 'UTC', + onHiddenSeriesChanged: jest.fn(), + toggleGraph: toggleGraph, + queryResponse: { + state: LoadingState.NotStarted, + series: [], + request: ({ + requestId: '1', + dashboardId: 0, + interval: '1s', + panelId: 1, + scopedVars: { + apps: { + value: 'value', + }, + }, + targets: [ + { + refId: 'A', + }, + ], + timezone: 'UTC', + app: CoreApp.Explore, + startTime: 0, + } as unknown) as DataQueryRequest, + error: {} as DataQueryError, + timeRange: { from: toUtc('2019-01-01 10:00:00'), to: toUtc('2019-01-01 16:00:00'), raw: { @@ -62,68 +116,21 @@ const setup = (renderMethod: any, propOverrides?: object) => { to: 'now', }, }, - mode: ExploreMode.Metrics, - initialUI: { - showingTable: false, - showingGraph: false, - showingLogs: false, - }, - isLive: false, - syncedTimes: false, - updateTimeRange: jest.fn(), - graphResult: [], - loading: false, - absoluteRange: { - from: 0, - to: 0, - }, - showingGraph: false, - showingTable: false, - timeZone: 'UTC', - onHiddenSeriesChanged: jest.fn(), - toggleGraph: toggleGraph, - queryResponse: { - state: LoadingState.NotStarted, - series: [], - request: ({ - requestId: '1', - dashboardId: 0, - interval: '1s', - panelId: 1, - scopedVars: { - apps: { - value: 'value', - }, - }, - targets: [ - { - refId: 'A', - }, - ], - timezone: 'UTC', - app: CoreApp.Explore, - startTime: 0, - } as unknown) as DataQueryRequest, - error: {} as DataQueryError, - timeRange: { - from: toUtc('2019-01-01 10:00:00'), - to: toUtc('2019-01-01 16:00:00'), - raw: { - from: 'now-6h', - to: 'now', - }, - }, - }, - originPanelId: 1, - addQueryRow: jest.fn(), - }; + }, + originPanelId: 1, + addQueryRow: jest.fn(), +}; +const setup = (renderMethod: any, propOverrides?: Partial) => { const store = configureStore(); - - Object.assign(props, propOverrides); return renderMethod( - + ); }; @@ -145,6 +152,40 @@ describe('Explore', () => { expect(wrapper).toMatchSnapshot(); }); + it('renders SecondaryActions and add row button', () => { + const wrapper = shallow(); + expect(wrapper.find(SecondaryActions)).toHaveLength(1); + expect(wrapper.find(SecondaryActions).props().addQueryRowButtonHidden).toBe(false); + }); + + it('does not show add row button if mode is tracing', () => { + const wrapper = shallow(); + expect(wrapper.find(SecondaryActions).props().addQueryRowButtonHidden).toBe(true); + }); + + it('renders TraceView if tracing mode', () => { + const wrapper = shallow( + + ); + const autoSizer = shallow( + wrapper + .find(AutoSizer) + .props() + .children({ width: 100, height: 100 }) as React.ReactElement + ); + expect(autoSizer.find(TraceView).length).toBe(1); + }); + it('should filter out a query-row-specific error when looking for non-query-row-specific errors', async () => { const queryErrors = setupErrors(true); const queryError = getFirstNonQueryRowSpecificError(queryErrors); diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 17a7a108cb1..e047025dd1a 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -59,6 +59,8 @@ import { getTimeZone } from '../profile/state/selectors'; import { ErrorContainer } from './ErrorContainer'; import { scanStopAction } from './state/actionTypes'; import { ExploreGraphPanel } from './ExploreGraphPanel'; +import { TraceView } from './TraceView'; +import { SecondaryActions } from './SecondaryActions'; const getStyles = stylesFactory(() => { return { @@ -319,27 +321,14 @@ export class Explore extends React.PureComponent { {datasourceInstance && (
-
- - -
+ {({ width }) => { @@ -400,18 +389,12 @@ export class Explore extends React.PureComponent { onStopScanning={this.onStopScanning} /> )} - {mode === ExploreMode.Tracing && ( -
- {queryResponse && - !!queryResponse.series.length && - queryResponse.series[0].fields[0].values.get(0) && ( -