Explore: Add trace UI to show traces from tracing datasources (#23047)

* Add integration with Jeager
Add Jaeger datasource and modify derived fields in loki to allow for opening a trace in Jager in separate split.
Modifies build so that this branch docker images are pushed to docker hub
Add a traceui dir with docker-compose and provision files for demoing.:wq

* Enable docker logger plugin to send logs to loki

* Add placeholder zipkin datasource

* Fixed rebase issues, added enhanceDataFrame to non-legacy code path

* Trace selector for jaeger query field

* Fix logs default mode for Loki

* Fix loading jaeger query field services on split

* Updated grafana image in traceui/compose file

* Fix prettier error

* Hide behind feature flag, clean up unused code.

* Fix tests

* Fix tests

* Cleanup code and review feedback

* Remove traceui directory

* Remove circle build changes

* Fix feature toggles object

* Fix merge issues

* Add trace ui in Explore

* WIP

* WIP

* WIP

* Make jaeger datasource return trace data instead of link

* Allow js in jest tests

* Return data from Jaeger datasource

* Take yarn.lock from master

* Fix missing component

* Update yarn lock

* Fix some ts and lint errors

* Fix merge

* Fix type errors

* Make tests pass again

* Add tests

* Fix es5 compatibility

Co-authored-by: David Kaltschmidt <david.kaltschmidt@gmail.com>
This commit is contained in:
Andrej Ocenas
2020-04-02 13:34:16 +02:00
committed by GitHub
parent a40c258544
commit a4d4dd325f
149 changed files with 16275 additions and 193 deletions

View File

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

View File

@@ -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<ButtonCascaderProps> = props => {
const { onChange, loadData, ...rest } = props;
return (
<RCCascader
onChange={onChangeCascader(onChange)}
loadData={onLoadDataCascader(loadData)}
popupClassName={getStyles().popup}
{...rest}
expandIcon={null}
>

View File

@@ -0,0 +1,6 @@
{
"extends": ["@grafana/eslint-config"],
"rules": {
"no-restricted-imports": [2, "^@grafana/runtime.*", "^@grafana/ui.*"]
}
}

View File

@@ -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"
}
}

View File

@@ -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();
});
});
});

View File

@@ -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<string> | TNil;
getCollapsedChildren: () => Set<string> | 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<string>} childrenAreHidden The set of Spans known to have hidden
* children, either because it is
* collapsed or has a collapsed parent.
* @param {Map<string, Span | TNil} spansMap Mapping from spanID to Span.
* @returns {{ isHidden: boolean, parentIds: Set<string> }}
*/
function isSpanHidden(span: Span, childrenAreHidden: Set<string>, spansMap: Map<string, Span | TNil>) {
const parentIDs = new Set<string>();
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<string, Span> = 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;
}
}

View File

@@ -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<Theme>;
export type Theme = {
borderStyle: string;
};
export const defaultTheme: Theme = {
borderStyle: '1px solid #bbb',
};
const ThemeContext = React.createContext<ThemeOptions | undefined>(undefined);
ThemeContext.displayName = 'ThemeContext';
export const ThemeProvider = ThemeContext.Provider;
type ThemeConsumerProps = {
children: (theme: Theme) => React.ReactNode;
};
export function ThemeConsumer(props: ThemeConsumerProps) {
return (
<ThemeContext.Consumer>
{(value: ThemeOptions | undefined) => {
const mergedTheme: Theme = value
? {
...defaultTheme,
...value,
}
: defaultTheme;
return props.children(mergedTheme);
}}
</ThemeContext.Consumer>
);
}
type WrappedWithThemeComponent<Props> = React.ComponentType<Omit<Props, 'theme'>> & {
wrapped: React.ComponentType<Props>;
};
export const withTheme = <Props extends { theme: Theme }, Statics extends {} = {}>(
Component: React.ComponentType<Props>
): WrappedWithThemeComponent<Props> => {
let WithTheme: React.ComponentType<Omit<Props, 'theme'>> = props => {
return (
<ThemeConsumer>
{(theme: Theme) => (
<Component
{...({
...props,
theme,
} as Props & { theme: Theme })}
/>
)}
</ThemeConsumer>
);
};
WithTheme.displayName = `WithTheme(${Component.displayName})`;
WithTheme = hoistNonReactStatics<React.ComponentType<Omit<Props, 'theme'>>, React.ComponentType<Props>>(
WithTheme,
Component
);
(WithTheme as WrappedWithThemeComponent<Props>).wrapped = Component;
return WithTheme as WrappedWithThemeComponent<Props>;
};
export const createStyle = <Fn extends (this: any, ...newArgs: any[]) => ReturnType<Fn>>(fn: Fn) => {
return memoizeOne(fn);
};

View File

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

View File

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

View File

@@ -0,0 +1,101 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ListView> shallow tests matches a snapshot 1`] = `
<div
onScroll={[Function]}
style={
Object {
"height": "100%",
"overflowY": "auto",
"position": "relative",
}
}
>
<div
style={
Object {
"height": 1640,
"position": "relative",
}
}
>
<div
className="SomeClassName"
style={
Object {
"margin": 0,
"padding": 0,
"position": "absolute",
"top": 0,
}
}
>
<Item
data-item-key="0"
key="0"
style={
Object {
"height": 2,
"position": "absolute",
"top": 0,
}
}
>
0
</Item>
<Item
data-item-key="1"
key="1"
style={
Object {
"height": 4,
"position": "absolute",
"top": 2,
}
}
>
1
</Item>
<Item
data-item-key="2"
key="2"
style={
Object {
"height": 6,
"position": "absolute",
"top": 6,
}
}
>
2
</Item>
<Item
data-item-key="3"
key="3"
style={
Object {
"height": 8,
"position": "absolute",
"top": 12,
}
}
>
3
</Item>
<Item
data-item-key="4"
key="4"
style={
Object {
"height": 10,
"position": "absolute",
"top": 20,
}
}
>
4
</Item>
</div>
</div>
</div>
`;

View File

@@ -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('<ListView>', () => {
// 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 <div {...rest}>{children}</div>;
}
function renderItem(itemKey, styles, itemIndex, attrs) {
return (
<Item key={itemKey} style={styles} {...attrs}>
{itemIndex}
</Item>
);
}
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(<ListView {...props} />);
});
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(<ListView {...props} />);
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(<ListView {...wsProps} />);
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);
});
});
});
});

View File

@@ -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<string, string | number>,
index: number,
attributes: Record<string, string>
) => 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<TListViewProps> {
/**
* 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<string, number>;
/**
* 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 (
<div {...wrapperProps}>
<div style={scrollerStyle}>
<div
style={{
position: 'absolute',
top: 0,
margin: 0,
padding: 0,
}}
className={this.props.itemsWrapperClassName}
ref={this._initItemHolder}
>
{items}
</div>
</div>
</div>
);
}
}

View File

@@ -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(<ReferencesButton {...props} />);
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(<ReferencesButton {...props} />);
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 }) => <div>{children}</div>,
})
);
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}`);
});
});

View File

@@ -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<TReferencesButtonProps> {
referencesList = (references: SpanReference[]) => {
const styles = getStyles();
return (
<UIMenu>
{references.map(ref => {
const { span, spanID } = ref;
return (
<UIMenuItem key={`${spanID}`}>
<ReferenceLink reference={ref} focusSpan={this.props.focusSpan} className={styles.TraceRefLink}>
{span
? `${span.process.serviceName}:${span.operationName} - ${ref.spanID}`
: `(another trace) - ${ref.spanID}`}
{!span && <NewWindowIcon className={styles.NewWindowIcon} />}
</ReferenceLink>
</UIMenuItem>
);
})}
</UIMenu>
);
};
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 (
<UITooltip {...tooltipProps}>
<UIDropdown overlay={this.referencesList(references)} placement="bottomRight" trigger={['click']}>
<a className={styles.MultiParent}>{children}</a>
</UIDropdown>
</UITooltip>
);
}
const ref = references[0];
return (
<UITooltip {...tooltipProps}>
<ReferenceLink reference={ref} focusSpan={focusSpan} className={styles.MultiParent}>
{children}
</ReferenceLink>
</UITooltip>
);
}
}

View File

@@ -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('<SpanBar>', () => {
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(
<UIElementsContext.Provider value={{ Popover: () => '' }}>
<SpanBar {...props} />
</UIElementsContext.Provider>
);
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(
<UIElementsContext.Provider value={{ Popover: () => '' }}>
<SpanBar {...props} />
</UIElementsContext.Provider>
);
expect(wrapper.find(UIPopover).length).toEqual(2);
});
});

View File

@@ -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<any>) => void;
onClick?: (evt: React.MouseEvent<any>) => 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 (
<div
className={cx(styles.wrapper, className)}
onClick={onClick}
onMouseOut={setShortLabel}
onMouseOver={setLongLabel}
aria-hidden
data-test-id="SpanBar--wrapper"
>
<div
aria-label={label}
className={styles.bar}
style={{
background: color,
left: toPercent(viewStart),
width: toPercent(viewEnd - viewStart),
}}
>
<div className={cx(styles.label, labelClassName)} data-test-id="SpanBar--label">
{label}
</div>
</div>
<div>
{Object.keys(logGroups).map(positionKey => (
<UIPopover
key={positionKey}
arrowPointAtCenter
overlayClassName={styles.logHint}
placement="topLeft"
content={
<AccordianLogs interactive={false} isOpen logs={logGroups[positionKey]} timestamp={traceStartTime} />
}
>
<div className={styles.logMarker} style={{ left: positionKey }} />
</UIPopover>
))}
</div>
{rpc && (
<div
className={styles.rpc}
style={{
background: rpc.color,
left: toPercent(rpc.viewStart),
width: toPercent(rpc.viewEnd - rpc.viewStart),
}}
/>
)}
</div>
);
}
export default compose<TInnerProps, TOuterProps>(
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);

View File

@@ -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('<SpanBarRow>', () => {
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(<SpanBarRow {...props} />);
});
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(<SpanBarRow {...props} span={span} />);
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(<SpanBarRow {...props} span={span} />);
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(<SpanBarRow {...props} span={span} />);
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');
});
});

View File

@@ -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<string>;
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<SpanBarRowProps> {
static defaultProps: Partial<SpanBarRowProps> = {
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 (
<TimelineRow
className={cx(
styles.row,
{
[styles.rowExpanded]: isDetailExpanded,
[styles.rowMatchingFilter]: isMatchingFilter,
[styles.rowExpandedAndMatchingFilter]: isMatchingFilter && isDetailExpanded,
[styles.rowClippingLeft]: clippingLeft,
[styles.rowClippingRight]: clippingRight,
},
className
)}
>
<TimelineRow.Cell className={styles.nameColumn} width={columnDivision}>
<div className={cx(styles.nameWrapper, { [styles.nameWrapperMatchingFilter]: isMatchingFilter })}>
<SpanTreeOffset
childrenVisible={isChildrenExpanded}
span={span}
onClick={isParent ? this._childrenToggle : undefined}
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
/>
<a
className={cx(styles.name, { [styles.nameDetailExpanded]: isDetailExpanded })}
aria-checked={isDetailExpanded}
onClick={this._detailToggle}
role="switch"
style={{ borderColor: color }}
tabIndex={0}
>
<span
className={cx(styles.svcName, {
[styles.svcNameChildrenCollapsed]: isParent && !isChildrenExpanded,
})}
>
{showErrorIcon && <IoAlert className={styles.errorIcon} />}
{serviceName}{' '}
{rpc && (
<span>
<IoArrowRightA /> <i className={styles.rpcColorMarker} style={{ background: rpc.color }} />
{rpc.serviceName}
</span>
)}
</span>
<small className={styles.endpointName}>{rpc ? rpc.operationName : operationName}</small>
</a>
{span.references && span.references.length > 1 && (
<ReferencesButton
references={span.references}
tooltipText="Contains multiple references"
focusSpan={focusSpan}
>
<IoNetwork />
</ReferencesButton>
)}
{span.subsidiarilyReferencedBy && span.subsidiarilyReferencedBy.length > 0 && (
<ReferencesButton
references={span.subsidiarilyReferencedBy}
tooltipText={`This span is referenced by ${
span.subsidiarilyReferencedBy.length === 1 ? 'another span' : 'multiple other spans'
}`}
focusSpan={focusSpan}
>
<MdFileUpload />
</ReferencesButton>
)}
</div>
</TimelineRow.Cell>
<TimelineRow.Cell
className={cx(styles.view, {
[styles.viewExpanded]: isDetailExpanded,
[styles.viewExpandedAndMatchingFilter]: isMatchingFilter && isDetailExpanded,
})}
data-test-id="span-view"
style={{ cursor: 'pointer' }}
width={1 - columnDivision}
onClick={this._detailToggle}
>
<Ticks numTicks={numTicks} />
<SpanBar
rpc={rpc}
viewStart={viewStart}
viewEnd={viewEnd}
getViewedBounds={getViewedBounds}
color={color}
shortLabel={label}
longLabel={longLabel}
traceStartTime={traceStartTime}
span={span}
labelClassName={`${styles.spanBarLabel} ${hintClassName}`}
className={styles.spanBar}
/>
</TimelineRow.Cell>
</TimelineRow>
);
}
}

View File

@@ -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';

View File

@@ -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('<KeyValuesSummary>', () => {
let wrapper;
const props = { data: tags };
beforeEach(() => {
wrapper = shallow(<KeyValuesSummary {...props} />);
});
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('<AccordianKeyValues>', () => {
let wrapper;
const props = {
compact: false,
data: tags,
highContrast: false,
isOpen: false,
label: 'le-label',
onToggle: jest.fn(),
};
beforeEach(() => {
wrapper = shallow(<AccordianKeyValues {...props} />);
});
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);
});
});

View File

@@ -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 (
<ul className={styles.summary}>
{data.map((item, i) => (
// `i` is necessary in the key because item.key can repeat
<li className={styles.summaryItem} key={`${item.key}-${i}`}>
<span className={styles.summaryLabel}>{item.key}</span>
<span className={styles.summaryDelim}>=</span>
{String(item.value)}
</li>
))}
</ul>
);
}
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 ? <IoIosArrowDown className={iconCls} /> : <IoIosArrowRight className={iconCls} />;
headerProps = {
'aria-checked': isOpen,
onClick: isEmpty ? null : onToggle,
role: 'switch',
};
}
return (
<div className={cx(className, uTxEllipsis)}>
<div
className={cx(styles.header, {
[styles.headerEmpty]: isEmpty,
[styles.headerHighContrast]: highContrast && !isEmpty,
})}
{...headerProps}
data-test-id="AccordianKeyValues--header"
>
{arrow}
<strong data-test={markers.LABEL}>
{label}
{isOpen || ':'}
</strong>
{!isOpen && <KeyValuesSummary data={data} />}
</div>
{isOpen && <KeyValuesTable data={data} linksGetter={linksGetter} />}
</div>
);
}
AccordianKeyValues.defaultProps = {
className: null,
highContrast: false,
interactive: true,
onToggle: null,
};

View File

@@ -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('<AccordianLogs>', () => {
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(<AccordianLogs {...props} />);
});
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]));
});
});
});

View File

@@ -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<Log>;
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 ? <IoIosArrowDown className={uAlignIcon} /> : <IoIosArrowRight className="u-align-icon" />;
HeaderComponent = 'a';
headerProps = {
'aria-checked': isOpen,
onClick: onToggle,
role: 'switch',
};
}
const styles = getStyles();
return (
<div className={styles.AccordianLogs}>
<HeaderComponent className={styles.header} {...headerProps}>
{arrow} <strong>Logs</strong> ({logs.length})
</HeaderComponent>
{isOpen && (
<div className={styles.content}>
{_sortBy(logs, 'timestamp').map((log, i) => (
<AccordianKeyValues
// `i` is necessary in the key because timestamps can repeat
key={`${log.timestamp}-${i}`}
className={i < logs.length - 1 ? ubMb1 : null}
data={log.fields || []}
highContrast
interactive={interactive}
isOpen={openedItems ? openedItems.has(log) : false}
label={`${formatDuration(log.timestamp - timestamp)}`}
linksGetter={linksGetter}
onToggle={interactive && onItemToggle ? () => onItemToggle(log) : null}
/>
))}
<small className={styles.footer}>Log timestamps are relative to the start time of the full trace.</small>
</div>
)}
</div>
);
}
AccordianLogs.defaultProps = {
interactive: true,
linksGetter: undefined,
onItemToggle: undefined,
onToggle: undefined,
openedItems: undefined,
};

View File

@@ -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('<AccordianReferences>', () => {
let wrapper;
const props = {
compact: false,
data: references,
highContrast: false,
isOpen: false,
onToggle: jest.fn(),
focusSpan: jest.fn(),
};
beforeEach(() => {
wrapper = shallow(<AccordianReferences {...props} />);
});
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('<References>', () => {
let wrapper;
const props = {
data: references,
focusSpan: jest.fn(),
};
beforeEach(() => {
wrapper = shallow(<References {...props} />);
});
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 >');
}
});
});
});

View File

@@ -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 (
<div className={cx(styles.ReferencesList)}>
<ul className={styles.list}>
{data.map(reference => {
return (
<li className={styles.item} key={`${reference.spanID}`}>
<ReferenceLink reference={reference} focusSpan={focusSpan}>
<span className={styles.itemContent}>
{reference.span ? (
<span>
<span className="span-svc-name">{reference.span.process.serviceName}</span>
<small className="endpoint-name">{reference.span.operationName}</small>
</span>
) : (
<span className="span-svc-name">&lt; span in another trace &gt;</span>
)}
<small className={styles.debugInfo}>
<span className={styles.debugLabel} data-label="Reference Type:">
{reference.refType}
</span>
<span className={styles.debugLabel} data-label="SpanID:">
{reference.spanID}
</span>
</small>
</span>
</ReferenceLink>
</li>
);
})}
</ul>
</div>
);
}
export default class AccordianReferences extends React.PureComponent<AccordianReferencesProps> {
static defaultProps: Partial<AccordianReferencesProps> = {
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 ? <IoIosArrowDown className={iconCls} /> : <IoIosArrowRight className={iconCls} />;
headerProps = {
'aria-checked': isOpen,
onClick: isEmpty ? null : onToggle,
role: 'switch',
};
}
return (
<div>
<div {...headerProps}>
{arrow}
<strong>
<span>References</span>
</strong>{' '}
({data.length})
</div>
{isOpen && <References data={data} focusSpan={focusSpan} />}
</div>
);
}
}

View File

@@ -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('<AccordianText>', () => {
let wrapper;
const props = {
compact: false,
data: warnings,
highContrast: false,
isOpen: false,
label: 'le-label',
onToggle: jest.fn(),
};
beforeEach(() => {
wrapper = shallow(<AccordianText {...props} />);
});
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);
});
});

View File

@@ -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 ? <IoIosArrowDown className={iconCls} /> : <IoIosArrowRight className={iconCls} />;
headerProps = {
'aria-checked': isOpen,
onClick: isEmpty ? null : onToggle,
role: 'switch',
};
}
const styles = getStyles();
return (
<div className={className || ''}>
<div className={cx(styles.header, headerClassName)} {...headerProps} data-test-id="AccordianText--header">
{arrow} <strong>{label}</strong> ({data.length})
</div>
{isOpen && <TextList data={data} />}
</div>
);
}
AccordianText.defaultProps = {
className: null,
highContrast: false,
interactive: true,
onToggle: null,
};

View File

@@ -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<Log> };
isWarningsOpen: boolean;
isReferencesOpen: boolean;
constructor(oldState?: DetailState) {
const {
isTagsOpen,
isProcessOpen,
isReferencesOpen,
isWarningsOpen,
logs,
}: DetailState | Record<string, undefined> = 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;
}
}

View File

@@ -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(
<LinkValue href={href} title={title}>
{childrenText}
</LinkValue>
);
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('<KeyValuesTable>', () => {
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(<KeyValuesTable data={data} />);
});
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 }) => <div>{children}</div> }));
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 <CopyIcon /> 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}"`);
}
});
});
});

View File

@@ -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 (
<a href={props.href} title={props.title} target="_blank" rel="noopener noreferrer">
{props.children} <UIIcon className={styles.linkIcon} type="export" />
</a>
);
};
LinkValue.defaultProps = {
title: '',
};
const linkValueList = (links: Link[]) => (
<UIMenu>
{links.map(({ text, url }, index) => (
// `index` is necessary in the key because url can repeat
<UIMenuItem key={`${url}-${index}`}>
<LinkValue href={url}>{text}</LinkValue>
</UIMenuItem>
))}
</UIMenu>
);
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 (
<div className={cx(styles.KeyValueTable)} data-test-id="KeyValueTable">
<table className={uWidth100}>
<tbody className={styles.body}>
{data.map((row, i) => {
const markup = {
__html: jsonMarkup(parseIfComplexJson(row.value)),
};
const jsonTable = <div className={ubInlineBlock} dangerouslySetInnerHTML={markup} />;
const links = linksGetter ? linksGetter(data, i) : null;
let valueMarkup;
if (links && links.length === 1) {
valueMarkup = (
<div>
<LinkValue href={links[0].url} title={links[0].text}>
{jsonTable}
</LinkValue>
</div>
);
} else if (links && links.length > 1) {
valueMarkup = (
<div>
<UIDropdown overlay={linkValueList(links)} placement="bottomRight" trigger={['click']}>
<a>
{jsonTable} <UIIcon className={styles.linkIcon} type="profile" />
</a>
</UIDropdown>
</div>
);
} else {
valueMarkup = jsonTable;
}
return (
// `i` is necessary in the key because row.key can repeat
<tr className={styles.row} key={`${row.key}-${i}`}>
<td className={styles.keyColumn} data-test-id="KeyValueTable--keyColumn">
{row.key}
</td>
<td>{valueMarkup}</td>
<td className={styles.copyColumn}>
<CopyIcon
className={styles.copyIcon}
copyText={JSON.stringify(row, null, 2)}
tooltipTitle="Copy JSON"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -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('<TextList>', () => {
let wrapper;
const data = [{ key: 'span.kind', value: 'client' }, { key: 'omg', value: 'mos-def' }];
beforeEach(() => {
wrapper = shallow(<TextList data={data} />);
});
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);
});
});

View File

@@ -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 (
<div className={cx(styles.TextList)} data-test-id="TextList">
<ul className={styles.List}>
{data.map((row, i) => {
return (
// `i` is necessary in the key because row.key can repeat
<li className={styles.item} key={`${i}`}>
{row}
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -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('<SpanDetail>', () => {
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(<SpanDetail {...props} />);
});
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 = <AccordianKeyValues data={span.tags} label="Tags" isOpen={detailState.isTagsOpen} />;
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 = (
<AccordianKeyValues data={span.process.tags} label="Process" isOpen={detailState.isProcessOpen} />
);
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 = (
<AccordianLogs
logs={span.logs}
isOpen={detailState.logs.isOpen}
openedItems={detailState.logs.openedItems}
timestamp={traceStartTime}
/>
);
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);
});
});

View File

@@ -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 (
<div>
<div className={cx(ubFlex, ubItemsCenter)}>
<h2 className={cx(ubFlexAuto, ubM0)}>{operationName}</h2>
<LabeledList className={ubTxRightAlign} dividerClassName={styles.divider} items={overviewItems} />
</div>
<UIDivider className={cx(styles.divider, ubMy1)} />
<div>
<div>
<AccordianKeyValues
data={tags}
label="Tags"
linksGetter={linksGetter}
isOpen={isTagsOpen}
onToggle={() => tagsToggle(spanID)}
/>
{process.tags && (
<AccordianKeyValues
className={ubMb1}
data={process.tags}
label="Process"
linksGetter={linksGetter}
isOpen={isProcessOpen}
onToggle={() => processToggle(spanID)}
/>
)}
</div>
{logs && logs.length > 0 && (
<AccordianLogs
linksGetter={linksGetter}
logs={logs}
isOpen={logsState.isOpen}
openedItems={logsState.openedItems}
onToggle={() => logsToggle(spanID)}
onItemToggle={logItem => logItemToggle(spanID, logItem)}
timestamp={traceStartTime}
/>
)}
{warnings && warnings.length > 0 && (
<AccordianText
className={styles.AccordianWarnings}
headerClassName={styles.AccordianWarningsHeader}
label={<span className={styles.AccordianWarningsLabel}>Warnings</span>}
data={warnings}
isOpen={isWarningsOpen}
onToggle={() => warningsToggle(spanID)}
/>
)}
{references && references.length > 1 && (
<AccordianReferences
data={references}
isOpen={isReferencesOpen}
onToggle={() => referencesToggle(spanID)}
focusSpan={focusSpan}
/>
)}
<small className={styles.debugInfo}>
<span className={styles.debugLabel} data-label="SpanID:" /> {spanID}
<CopyIcon
copyText={deepLinkCopyText}
icon="link"
placement="topRight"
tooltipTitle="Copy deep link to this span"
/>
</small>
</div>
</div>
);
}

View File

@@ -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('<SpanDetailRow>', () => {
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(<SpanDetailRow {...props} />);
});
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 = <SpanTreeOffset span={props.span} showChildrenIcon={false} />;
expect(wrapper.contains(spanTreeOffset)).toBe(true);
});
it('renders the SpanDetail', () => {
const spanDetail = (
<SpanDetail
detailState={props.detailState}
linksGetter={wrapper.instance()._linksGetter}
logItemToggle={props.logItemToggle}
logsToggle={props.logsToggle}
processToggle={props.processToggle}
span={props.span}
tagsToggle={props.tagsToggle}
traceStartTime={props.traceStartTime}
/>
);
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);
});
});

View File

@@ -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<string>;
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
};
export default class SpanDetailRow extends React.PureComponent<SpanDetailRowProps> {
_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 (
<TimelineRow>
<TimelineRow.Cell width={columnDivision}>
<SpanTreeOffset
span={span}
showChildrenIcon={false}
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
/>
<span>
<span
className={styles.expandedAccent}
aria-checked="true"
onClick={this._detailToggle}
role="switch"
style={{ borderColor: color }}
data-test-id="detail-row-expanded-accent"
/>
</span>
</TimelineRow.Cell>
<TimelineRow.Cell width={1 - columnDivision}>
<div className={styles.infoWrapper} style={{ borderTopColor: color }}>
<SpanDetail
detailState={detailState}
linksGetter={this._linksGetter}
logItemToggle={logItemToggle}
logsToggle={logsToggle}
processToggle={processToggle}
referencesToggle={referencesToggle}
warningsToggle={warningsToggle}
span={span}
tagsToggle={tagsToggle}
traceStartTime={traceStartTime}
focusSpan={focusSpan}
/>
</div>
</TimelineRow.Cell>
</TimelineRow>
);
}
}

View File

@@ -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(<SpanTreeOffset {...props} />);
});
describe('.SpanTreeOffset--indentGuide', () => {
it('renders only one .SpanTreeOffset--indentGuide for entire trace if span has no ancestors', () => {
spanAncestorIdsSpy.mockReturnValue([]);
wrapper = shallow(<SpanTreeOffset {...props} />);
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(<SpanTreeOffset {...props} />);
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);
});
});
});

View File

@@ -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<string>;
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
};
export default class SpanTreeOffset extends React.PureComponent<TProps> {
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<HTMLSpanElement>, 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<HTMLSpanElement>, 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 ? <IoIosArrowDown /> : <IoChevronRight />);
const styles = getStyles();
return (
<span className={cx(styles.SpanTreeOffset, { [styles.SpanTreeOffsetParent]: hasChildren })} {...wrapperProps}>
{this.ancestorIds.map(ancestorId => (
<span
key={ancestorId}
className={cx(styles.indentGuide, {
[styles.indentGuideActive]: this.props.hoverIndentGuideIds.has(ancestorId),
})}
data-ancestor-id={ancestorId}
data-test-id="SpanTreeOffset--indentGuide"
onMouseEnter={event => this.handleMouseEnter(event, ancestorId)}
onMouseLeave={event => this.handleMouseLeave(event, ancestorId)}
/>
))}
{icon && (
<span
className={styles.iconWrapper}
onMouseEnter={event => this.handleMouseEnter(event, spanID)}
onMouseLeave={event => this.handleMouseLeave(event, spanID)}
data-test-id="icon-wrapper"
>
{icon}
</span>
)}
</span>
);
}
}

View File

@@ -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('<Ticks>', () => {
it('renders without exploding', () => {
const wrapper = shallow(<Ticks endTime={200} numTicks={5} showLabels startTime={100} />);
expect(wrapper).toBeDefined();
});
});

View File

@@ -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(
<div
key={portion}
className={styles.tick}
style={{
left: `${portion * 100}%`,
}}
>
{labels && (
<span className={cx(styles.tickLabel, { [styles.tickLabelEndAnchor]: portion >= 1 })}>{labels[i]}</span>
)}
</div>
);
}
return <div className={styles.Ticks}>{ticks}</div>;
}
Ticks.defaultProps = {
endTime: null,
showLabels: null,
startTime: null,
};

View File

@@ -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('<TimelineCollapser>', () => {
it('renders without exploding', () => {
const props = {
onCollapseAll: () => {},
onCollapseOne: () => {},
onExpandAll: () => {},
onExpandOne: () => {},
};
const wrapper = shallow(<TimelineCollapser {...props} />);
expect(wrapper).toBeDefined();
expect(wrapper.find('[data-test-id="TimelineCollapser"]').length).toBe(1);
});
});

View File

@@ -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 <span className={styles.tooltipTitle}>{value}</span>;
}
export default class TimelineCollapser extends React.PureComponent<CollapserProps> {
containerRef: React.RefObject<HTMLDivElement>;
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 (
<div className={styles.TimelineCollapser} ref={this.containerRef} data-test-id="TimelineCollapser">
<UITooltip title={getTitle('Expand +1')} getPopupContainer={this.getContainer}>
<UIIcon type="right" onClick={onExpandOne} className={cx(styles.btn, styles.btnExpanded)} />
</UITooltip>
<UITooltip title={getTitle('Collapse +1')} getPopupContainer={this.getContainer}>
<UIIcon type="right" onClick={onCollapseOne} className={styles.btn} />
</UITooltip>
<UITooltip title={getTitle('Expand All')} getPopupContainer={this.getContainer}>
<UIIcon type="double-right" onClick={onExpandAll} className={cx(styles.btn, styles.btnExpanded)} />
</UITooltip>
<UITooltip title={getTitle('Collapse All')} getPopupContainer={this.getContainer}>
<UIIcon type="double-right" onClick={onCollapseAll} className={styles.btn} />
</UITooltip>
</div>
);
}
}

View File

@@ -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('<TimelineColumnResizer>', () => {
let wrapper;
let instance;
const props = {
min: 0.1,
max: 0.9,
onChange: jest.fn(),
position: 0.5,
};
beforeEach(() => {
props.onChange.mockReset();
wrapper = mount(<TimelineColumnResizer {...props} />);
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)
);
});
});

View File

@@ -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 (
<div className={styles.TimelineColumnResizer} ref={this._setRootElm} data-test-id="TimelineColumnResizer">
<div
className={cx(styles.gripIcon, isDragging && styles.gripIconDragging)}
style={gripStyle}
data-test-id="TimelineColumnResizer--gripIcon"
/>
<div
aria-hidden
className={cx(
styles.dragger,
isDragging && styles.draggerDragging,
isDraggingRight && styles.draggerDraggingRight,
isDraggingLeft && styles.draggerDraggingLeft
)}
onMouseDown={this._dragManager.handleMouseDown}
style={draggerStyle}
data-test-id="TimelineColumnResizer--dragger"
/>
</div>
);
}
}

View File

@@ -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('<TimelineHeaderRow>', () => {
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(<TimelineHeaderRow {...props} />);
});
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 = (
<TimelineViewingLayer
boundsInvalidator={nameColumnWidth}
updateNextViewRangeTime={props.updateNextViewRangeTime}
updateViewRangeTime={props.updateViewRangeTime}
viewRangeTime={props.viewRangeTime}
/>
);
expect(wrapper.containsMatchingElement(elm)).toBe(true);
});
it('renders the Ticks', () => {
const [viewStart, viewEnd] = props.viewRangeTime.current;
const elm = (
<Ticks
numTicks={props.numTicks}
startTime={viewStart * props.duration}
endTime={viewEnd * props.duration}
showLabels
/>
);
expect(wrapper.containsMatchingElement(elm)).toBe(true);
});
it('renders the TimelineColumnResizer', () => {
const elm = (
<TimelineColumnResizer
position={nameColumnWidth}
onChange={props.onColummWidthChange}
min={0.2}
max={0.85}
/>
);
expect(wrapper.containsMatchingElement(elm)).toBe(true);
});
it('renders the TimelineCollapser', () => {
const elm = (
<TimelineCollapser
onCollapseAll={props.onCollapseAll}
onExpandAll={props.onExpandAll}
onCollapseOne={props.onCollapseOne}
onExpandOne={props.onExpandOne}
/>
);
expect(wrapper.containsMatchingElement(elm)).toBe(true);
});
});

View File

@@ -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 (
<TimelineRow className={styles.TimelineHeaderRow} data-test-id="TimelineHeaderRow">
<TimelineRow.Cell className={cx(ubFlex, ubPx2)} width={nameColumnWidth}>
<h3 className={styles.TimelineHeaderRow}>Service &amp; Operation</h3>
<TimelineCollapser
onCollapseAll={onCollapseAll}
onExpandAll={onExpandAll}
onCollapseOne={onCollapseOne}
onExpandOne={onExpandOne}
/>
</TimelineRow.Cell>
<TimelineRow.Cell width={1 - nameColumnWidth}>
<TimelineViewingLayer
boundsInvalidator={nameColumnWidth}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
viewRangeTime={viewRangeTime}
/>
<Ticks numTicks={numTicks} startTime={viewStart * duration} endTime={viewEnd * duration} showLabels />
</TimelineRow.Cell>
<TimelineColumnResizer position={nameColumnWidth} onChange={onColummWidthChange} min={0.2} max={0.85} />
</TimelineRow>
);
}

View File

@@ -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('<TimelineViewingLayer>', () => {
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(<TimelineViewingLayer {...props} />);
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);
});
});
});

View File

@@ -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 (
<div
className={cx(styles.dragged, styles.draggedDraggingLeft, cls)}
style={{ left, width }}
data-test-id="Dragged"
/>
);
}
/**
* `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<TimelineViewingLayerProps> {
_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 (
<div
aria-hidden
className={styles.TimelineViewingLayer}
ref={this._setRoot}
onMouseDown={this._draggerReframe.handleMouseDown}
onMouseLeave={this._draggerReframe.handleMouseLeave}
onMouseMove={this._draggerReframe.handleMouseMove}
data-test-id="TimelineViewingLayer"
>
{cusrorPosition != null && (
<div
className={styles.cursorGuide}
style={{ left: cusrorPosition }}
data-test-id="TimelineViewingLayer--cursorGuide"
/>
)}
{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)}
</div>
);
}
}

View File

@@ -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';

View File

@@ -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<HTMLDivElement> {
children: React.ReactNode;
className?: string;
width: number;
style?: {};
}
export default function TimelineRow(props: TTimelineRowProps) {
const { children, className = '', ...rest } = props;
const styles = getStyles();
return (
<div className={cx(styles.flexRow, className)} {...rest}>
{children}
</div>
);
}
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 (
<div className={cx(ubRelative, className)} style={mergedStyle} {...(rest as any)}>
{children}
</div>
);
}
TimelineRowCell.defaultProps = { className: '', style: {} };
TimelineRow.Cell = TimelineRowCell;

View File

@@ -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('<VirtualizedTraceViewImpl>', () => {
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(<VirtualizedTraceView {...props} />);
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(
<SpanBarRow
className={instance.clippingCssClasses}
columnDivision={props.spanNameColumnWidth}
isChildrenExpanded
isDetailExpanded={false}
isMatchingFilter={false}
numTicks={5}
onDetailToggled={props.detailToggle}
onChildrenToggled={props.childrenToggle}
rpc={undefined}
showErrorIcon={false}
span={span}
/>
)
).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(
<SpanDetailRow
columnDivision={props.spanNameColumnWidth}
onDetailToggled={props.detailToggle}
detailState={detailState}
logItemToggle={props.detailLogItemToggle}
logsToggle={props.detailLogsToggle}
processToggle={props.detailProcessToggle}
span={span}
tagsToggle={props.detailTagsToggle}
/>
)
).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);
});
});
});
});

View File

@@ -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<string> | 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<string>;
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<string>,
detailStates: Map<string, DetailState | TNil>
): 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<VirtualizedTraceViewProps> {
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<keyof VirtualizedTraceViewProps>;
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 (
<div className={styles.row} key={key} style={style} {...attrs}>
<SpanBarRow
clippingLeft={this.clipping.left}
clippingRight={this.clipping.right}
color={color}
columnDivision={spanNameColumnWidth}
isChildrenExpanded={!isCollapsed}
isDetailExpanded={isDetailExpanded}
isMatchingFilter={isMatchingFilter}
numTicks={NUM_TICKS}
onDetailToggled={detailToggle}
onChildrenToggled={childrenToggle}
rpc={rpc}
showErrorIcon={showErrorIcon}
getViewedBounds={this.getViewedBounds}
traceStartTime={trace.startTime}
span={span}
focusSpan={focusSpan}
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
/>
</div>
);
}
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 (
<div className={styles.row} key={key} style={{ ...style, zIndex: 1 }} {...attrs}>
<SpanDetailRow
color={color}
columnDivision={spanNameColumnWidth}
onDetailToggled={detailToggle}
detailState={detailState}
linksGetter={linksGetter}
logItemToggle={detailLogItemToggle}
logsToggle={detailLogsToggle}
processToggle={detailProcessToggle}
referencesToggle={detailReferencesToggle}
warningsToggle={detailWarningsToggle}
span={span}
tagsToggle={detailTagsToggle}
traceStartTime={trace.startTime}
focusSpan={focusSpan}
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
/>
</div>
);
}
render() {
const styles = getStyles();
return (
<div>
<ListView
ref={this.setListView}
dataLength={this.rowStates.length}
itemHeightGetter={this.getRowHeight}
itemRenderer={this.renderRow}
viewBuffer={300}
viewBufferMin={100}
itemsWrapperClassName={styles.rowsWrapper}
getKeyFromIndex={this.getKeyFromIndex}
getIndexFromKey={this.getIndexFromKey}
windowScroller
/>
</div>
);
}
}

View File

@@ -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('<TraceTimelineViewer>', () => {
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(<TraceTimelineViewer {...props} />, 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);
});
});

View File

@@ -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<string> | 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<TProps> {
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 (
<ExternalLinkContext.Provider value={createLinkToExternalSpan}>
<div className={styles.TraceTimelineViewer}>
<TimelineHeaderRow
duration={trace.duration}
nameColumnWidth={traceTimeline.spanNameColumnWidth}
numTicks={NUM_TICKS}
onCollapseAll={this.collapseAll}
onCollapseOne={this.collapseOne}
onColummWidthChange={setSpanNameColumnWidth}
onExpandAll={this.expandAll}
onExpandOne={this.expandOne}
viewRangeTime={viewRange.time}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
/>
<VirtualizedTraceView
{...rest}
{...traceTimeline}
setSpanNameColumnWidth={setSpanNameColumnWidth}
currentViewRangeTime={viewRange.time.current}
/>
</div>
</ExternalLinkContext.Provider>
);
}
}

View File

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

View File

@@ -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();
});
});
});

View File

@@ -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';

View File

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

View File

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

View File

@@ -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('<CopyIcon />', () => {
const props = {
className: 'classNameValue',
copyText: 'copyTextValue',
tooltipTitle: 'tooltipTitleValue',
};
let copySpy;
let wrapper;
beforeAll(() => {
copySpy = jest.spyOn(copy, 'default');
});
beforeEach(() => {
copySpy.mockReset();
wrapper = shallow(<CopyIcon {...props} />);
});
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);
});
});

View File

@@ -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<PropsType, StateType> {
static defaultProps: Partial<PropsType> = {
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 (
<UITooltip
arrowPointAtCenter
mouseLeaveDelay={0.5}
onVisibleChange={this.handleTooltipVisibilityChange}
placement={this.props.placement}
title={this.state.hasCopied ? 'Copied' : this.props.tooltipTitle}
>
<UIButton
className={cx(styles.CopyIcon, this.props.className)}
htmlType="button"
icon={this.props.icon}
onClick={this.handleClick}
/>
</UITooltip>
);
}
}

View File

@@ -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 (
<ul className={cx(styles.LabeledList, className)}>
{items.map(({ key, label, value }, i) => {
const divider = i < items.length - 1 && (
<li className={styles.LabeledListItem} key={`${key}--divider`}>
<UIDivider className={dividerClassName} type="vertical" />
</li>
);
return [
<li className={styles.LabeledListItem} key={key}>
<span className={styles.LabeledListLabel}>{label}</span>
<strong>{value}</strong>
</li>,
divider,
];
})}
</ul>
);
}

View File

@@ -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(<NewWindowIcon {...props} />);
});
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);
});
});

View File

@@ -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 <IoAndroidOpen className={cls} {...rest} />;
}
NewWindowIcon.defaultProps = {
isLarge: false,
};

View File

@@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<CopyIcon /> renders as expected 1`] = `
<UITooltip
arrowPointAtCenter={true}
mouseLeaveDelay={0.5}
onVisibleChange={[Function]}
placement="left"
title="tooltipTitleValue"
>
<UIButton
className="css-oqwzau classNameValue"
htmlType="button"
icon="copy"
onClick={[Function]}
/>
</UITooltip>
`;

View File

@@ -0,0 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NewWindowIcon renders as expected 1`] = `
<IoAndroidOpen
className=""
notIsLarge="not is large"
/>
`;

View File

@@ -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',
},
];

View File

@@ -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 = '<trace-without-root-span>' as '<trace-without-root-span>';
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,
};

View File

@@ -0,0 +1,5 @@
{
"rules": {
"import/no-extraneous-dependencies": 0
}
}

View File

@@ -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, {});
},
});

View File

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

View File

@@ -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<string, { binding: string | string[]; label: string }> = {
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;

View File

@@ -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<any>) => void)
| ((event: React.KeyboardEvent<any>, 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();
}

View File

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

View File

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

View File

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

View File

@@ -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: [] })),
});

View File

@@ -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<string, TDdgOperation>;
};
export type TDdgOperation = {
name: string;
pathElems: PathElem[];
service: TDdgService;
};
export type TDdgServiceMap = Map<string, TDdgService>;
export type TDdgPath = {
focalIdx: number;
members: PathElem[];
traceIDs: string[];
};
export type TDdgDistanceToPathElems = Map<number, PathElem[]>;
export type TDdgModel = {
distanceToPathElems: TDdgDistanceToPathElems;
hash: string;
paths: TDdgPath[];
services: TDdgServiceMap;
visIdxToPathElem: PathElem[];
};

View File

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

View File

@@ -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<string>();
str.replace(parameterRegExp, (match, name) => {
names.add(name);
return match;
});
return Array.from(names);
}
function stringSupplant(str: string, encodeFn: (unencoded: any) => string, map: Record<string, any>) {
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<string, any> = {};
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<string, any> = {};
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<KeyValuePair, Link[]>) {
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());

View File

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

View File

@@ -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"']);
});
});

View File

@@ -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<string, string> = new Map<string, string>();
const tags: KeyValuePair[] = spanTags.reduce<KeyValuePair[]>((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<string, Span>();
// 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<string, number> = {};
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,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)],
}))
);

View File

@@ -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]]);
});

View File

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

View File

@@ -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)),
})
);

View File

@@ -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,
},
]);
});

View File

@@ -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<number, number>;
};
type TDdgState = Record<string, TDdgStateEntry>;
// eslint-disable-next-line no-undef
export default TDdgState;

View File

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

View File

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

View File

@@ -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<string>;
detailStates: Map<string, DetailState>;
hoverIndentGuideIds: Set<string>;
shouldScrollToFirstUiFindMatch: boolean;
spanNameColumnWidth: number;
traceID: string | TNil;
};
// eslint-disable-next-line no-undef
export default TTraceTimeline;

View File

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

View File

@@ -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<string, TraceArchive>;

View File

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

View File

@@ -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

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