Commit a4d4dd32 by Andrej Ocenas Committed by GitHub

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>
parent a40c2585
module.exports = {
verbose: false,
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
'^.+\\.(ts|tsx|js|jsx)$': 'ts-jest',
},
moduleDirectories: ['node_modules', 'public'],
roots: ['<rootDir>/public/app', '<rootDir>/public/test', '<rootDir>/packages', '<rootDir>/scripts'],
......
......@@ -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
}
......
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}
>
......
{
"extends": ["@grafana/eslint-config"],
"rules": {
"no-restricted-imports": [2, "^@grafana/runtime.*", "^@grafana/ui.*"]
}
}
{
"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"
}
}
// 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;
}
}
// 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);
};
// 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);
});
});
});
// 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;
}
}
// 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>
`;
// 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);
});
});
});
});
// 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}`);
});
});
// 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>
);
}
}
// 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);
});
});
// 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);
// 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');
});
});
// 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';
// 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);
});
});
// 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,
};
// 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]));
});
});
});
// 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,
};
// 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 >');
}
});
});
});
// 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>
);
}
}
// 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);
});
});
// 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,
};
// 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;
}
}
// 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}"`);
}
});
});
});
// 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>
);
}
// 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);
});
});
// 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>
);
}
// 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);
});
});
// 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>
);
}
// 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);
});
});
// 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>
);
}
}
// 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);
});
});
});
// 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>
);
}
}
// 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();
});
});
// 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,
};
// 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);
});
});
// 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>
);
}
}
// 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)
);
});
});
// 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>
);
}
}
// 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);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment