mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Explore: Add trace UI to show traces from tracing datasources (#23047)
* Add integration with Jeager Add Jaeger datasource and modify derived fields in loki to allow for opening a trace in Jager in separate split. Modifies build so that this branch docker images are pushed to docker hub Add a traceui dir with docker-compose and provision files for demoing.:wq * Enable docker logger plugin to send logs to loki * Add placeholder zipkin datasource * Fixed rebase issues, added enhanceDataFrame to non-legacy code path * Trace selector for jaeger query field * Fix logs default mode for Loki * Fix loading jaeger query field services on split * Updated grafana image in traceui/compose file * Fix prettier error * Hide behind feature flag, clean up unused code. * Fix tests * Fix tests * Cleanup code and review feedback * Remove traceui directory * Remove circle build changes * Fix feature toggles object * Fix merge issues * Add trace ui in Explore * WIP * WIP * WIP * Make jaeger datasource return trace data instead of link * Allow js in jest tests * Return data from Jaeger datasource * Take yarn.lock from master * Fix missing component * Update yarn lock * Fix some ts and lint errors * Fix merge * Fix type errors * Make tests pass again * Add tests * Fix es5 compatibility Co-authored-by: David Kaltschmidt <david.kaltschmidt@gmail.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { css } from 'emotion';
|
||||
|
||||
// @ts-ignore
|
||||
import RCCascader from 'rc-cascader';
|
||||
import { CascaderOption } from '../Cascader/Cascader';
|
||||
import { onChangeCascader, onLoadDataCascader } from '../Cascader/optionMappings';
|
||||
import { stylesFactory } from '../../themes';
|
||||
|
||||
export interface ButtonCascaderProps {
|
||||
options: CascaderOption[];
|
||||
@@ -18,12 +20,22 @@ export interface ButtonCascaderProps {
|
||||
onPopupVisibleChange?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
const getStyles = stylesFactory(() => {
|
||||
return {
|
||||
popup: css`
|
||||
label: popup;
|
||||
z-index: 100;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
export const ButtonCascader: React.FC<ButtonCascaderProps> = props => {
|
||||
const { onChange, loadData, ...rest } = props;
|
||||
return (
|
||||
<RCCascader
|
||||
onChange={onChangeCascader(onChange)}
|
||||
loadData={onLoadDataCascader(loadData)}
|
||||
popupClassName={getStyles().popup}
|
||||
{...rest}
|
||||
expandIcon={null}
|
||||
>
|
||||
|
||||
6
packages/jaeger-ui-components/.eslintrc
Normal file
6
packages/jaeger-ui-components/.eslintrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": ["@grafana/eslint-config"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [2, "^@grafana/runtime.*", "^@grafana/ui.*"]
|
||||
}
|
||||
}
|
||||
41
packages/jaeger-ui-components/package.json
Normal file
41
packages/jaeger-ui-components/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@jaegertracing/jaeger-ui-components",
|
||||
"version": "0.0.1",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"enzyme": "^3.8.0",
|
||||
"enzyme-adapter-react-16": "^1.2.0",
|
||||
"typescript": "3.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/classnames": "^2.2.7",
|
||||
"@types/deep-freeze": "^0.1.1",
|
||||
"@types/hoist-non-react-statics": "^3.3.1",
|
||||
"@types/lodash": "^4.14.123",
|
||||
"@types/moment": "^2.13.0",
|
||||
"@types/react-icons": "2.2.7",
|
||||
"@types/recompose": "^0.30.7",
|
||||
"chance": "^1.0.10",
|
||||
"classnames": "^2.2.5",
|
||||
"combokeys": "^3.0.0",
|
||||
"copy-to-clipboard": "^3.1.0",
|
||||
"deep-freeze": "^0.0.1",
|
||||
"emotion": "^10.0.27",
|
||||
"fuzzy": "^0.1.3",
|
||||
"hoist-non-react-statics": "^3.3.2",
|
||||
"json-markup": "^1.1.0",
|
||||
"lodash": "^4.17.4",
|
||||
"lru-memoize": "^1.1.0",
|
||||
"memoize-one": "^5.0.0",
|
||||
"moment": "^2.18.1",
|
||||
"react": "^16.3.2",
|
||||
"react-icons": "2.2.7",
|
||||
"recompose": "^0.25.0",
|
||||
"tween-functions": "^1.2.0"
|
||||
}
|
||||
}
|
||||
286
packages/jaeger-ui-components/src/ScrollManager.test.js
Normal file
286
packages/jaeger-ui-components/src/ScrollManager.test.js
Normal file
@@ -0,0 +1,286 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
/* eslint-disable import/first */
|
||||
jest.mock('./scroll-page');
|
||||
|
||||
import { scrollBy, scrollTo } from './scroll-page';
|
||||
import ScrollManager from './ScrollManager';
|
||||
|
||||
const SPAN_HEIGHT = 2;
|
||||
|
||||
function getTrace() {
|
||||
const spans = [];
|
||||
const trace = {
|
||||
spans,
|
||||
duration: 2000,
|
||||
startTime: 1000,
|
||||
};
|
||||
for (let i = 0; i < 10; i++) {
|
||||
spans.push({ duration: 1, startTime: 1000, spanID: i + 1 });
|
||||
}
|
||||
return trace;
|
||||
}
|
||||
|
||||
function getAccessors() {
|
||||
return {
|
||||
getViewRange: jest.fn(() => [0, 1]),
|
||||
getSearchedSpanIDs: jest.fn(),
|
||||
getCollapsedChildren: jest.fn(),
|
||||
getViewHeight: jest.fn(() => SPAN_HEIGHT * 2),
|
||||
getBottomRowIndexVisible: jest.fn(),
|
||||
getTopRowIndexVisible: jest.fn(),
|
||||
getRowPosition: jest.fn(),
|
||||
mapRowIndexToSpanIndex: jest.fn(n => n),
|
||||
mapSpanIndexToRowIndex: jest.fn(n => n),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ScrollManager', () => {
|
||||
let trace;
|
||||
let accessors;
|
||||
let manager;
|
||||
|
||||
beforeEach(() => {
|
||||
scrollBy.mockReset();
|
||||
scrollTo.mockReset();
|
||||
trace = getTrace();
|
||||
accessors = getAccessors();
|
||||
manager = new ScrollManager(trace, { scrollBy, scrollTo });
|
||||
manager.setAccessors(accessors);
|
||||
});
|
||||
|
||||
it('saves the accessors', () => {
|
||||
const n = Math.random();
|
||||
manager.setAccessors(n);
|
||||
expect(manager._accessors).toBe(n);
|
||||
});
|
||||
|
||||
describe('_scrollPast()', () => {
|
||||
it('throws if accessors is not set', () => {
|
||||
manager.setAccessors(null);
|
||||
expect(manager._scrollPast).toThrow();
|
||||
});
|
||||
|
||||
it('is a noop if an invalid rowPosition is returned by the accessors', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
const oldWarn = console.warn;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn = () => {};
|
||||
manager._scrollPast(null, null);
|
||||
expect(accessors.getRowPosition.mock.calls.length).toBe(1);
|
||||
expect(accessors.getViewHeight.mock.calls.length).toBe(0);
|
||||
expect(scrollTo.mock.calls.length).toBe(0);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn = oldWarn;
|
||||
});
|
||||
|
||||
it('scrolls up with direction is `-1`', () => {
|
||||
const y = 10;
|
||||
const expectTo = y - 0.5 * accessors.getViewHeight();
|
||||
accessors.getRowPosition.mockReturnValue({ y, height: SPAN_HEIGHT });
|
||||
manager._scrollPast(NaN, -1);
|
||||
expect(scrollTo.mock.calls).toEqual([[expectTo]]);
|
||||
});
|
||||
|
||||
it('scrolls down with direction `1`', () => {
|
||||
const y = 10;
|
||||
const vh = accessors.getViewHeight();
|
||||
const expectTo = y + SPAN_HEIGHT - 0.5 * vh;
|
||||
accessors.getRowPosition.mockReturnValue({ y, height: SPAN_HEIGHT });
|
||||
manager._scrollPast(NaN, 1);
|
||||
expect(scrollTo.mock.calls).toEqual([[expectTo]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_scrollToVisibleSpan()', () => {
|
||||
function getRefs(spanID) {
|
||||
return [{ refType: 'CHILD_OF', spanID }];
|
||||
}
|
||||
let scrollPastMock;
|
||||
|
||||
beforeEach(() => {
|
||||
scrollPastMock = jest.fn();
|
||||
manager._scrollPast = scrollPastMock;
|
||||
});
|
||||
it('throws if accessors is not set', () => {
|
||||
manager.setAccessors(null);
|
||||
expect(manager._scrollToVisibleSpan).toThrow();
|
||||
});
|
||||
it('exits if the trace is not set', () => {
|
||||
manager.setTrace(null);
|
||||
manager._scrollToVisibleSpan();
|
||||
expect(scrollPastMock.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('does nothing if already at the boundary', () => {
|
||||
accessors.getTopRowIndexVisible.mockReturnValue(0);
|
||||
accessors.getBottomRowIndexVisible.mockReturnValue(trace.spans.length - 1);
|
||||
manager._scrollToVisibleSpan(-1);
|
||||
expect(scrollPastMock.mock.calls.length).toBe(0);
|
||||
manager._scrollToVisibleSpan(1);
|
||||
expect(scrollPastMock.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('centers the current top or bottom span', () => {
|
||||
accessors.getTopRowIndexVisible.mockReturnValue(5);
|
||||
accessors.getBottomRowIndexVisible.mockReturnValue(5);
|
||||
manager._scrollToVisibleSpan(-1);
|
||||
expect(scrollPastMock).lastCalledWith(5, -1);
|
||||
manager._scrollToVisibleSpan(1);
|
||||
expect(scrollPastMock).lastCalledWith(5, 1);
|
||||
});
|
||||
|
||||
it('skips spans that are out of view', () => {
|
||||
trace.spans[4].startTime = trace.startTime + trace.duration * 0.5;
|
||||
accessors.getViewRange = () => [0.4, 0.6];
|
||||
accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1);
|
||||
accessors.getBottomRowIndexVisible.mockReturnValue(0);
|
||||
manager._scrollToVisibleSpan(1);
|
||||
expect(scrollPastMock).lastCalledWith(4, 1);
|
||||
manager._scrollToVisibleSpan(-1);
|
||||
expect(scrollPastMock).lastCalledWith(4, -1);
|
||||
});
|
||||
|
||||
it('skips spans that do not match the text search', () => {
|
||||
accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1);
|
||||
accessors.getBottomRowIndexVisible.mockReturnValue(0);
|
||||
accessors.getSearchedSpanIDs = () => new Set([trace.spans[4].spanID]);
|
||||
manager._scrollToVisibleSpan(1);
|
||||
expect(scrollPastMock).lastCalledWith(4, 1);
|
||||
manager._scrollToVisibleSpan(-1);
|
||||
expect(scrollPastMock).lastCalledWith(4, -1);
|
||||
});
|
||||
|
||||
it('scrolls to boundary when scrolling away from closest spanID in findMatches', () => {
|
||||
const closetFindMatchesSpanID = 4;
|
||||
accessors.getTopRowIndexVisible.mockReturnValue(closetFindMatchesSpanID - 1);
|
||||
accessors.getBottomRowIndexVisible.mockReturnValue(closetFindMatchesSpanID + 1);
|
||||
accessors.getSearchedSpanIDs = () => new Set([trace.spans[closetFindMatchesSpanID].spanID]);
|
||||
|
||||
manager._scrollToVisibleSpan(1);
|
||||
expect(scrollPastMock).lastCalledWith(trace.spans.length - 1, 1);
|
||||
|
||||
manager._scrollToVisibleSpan(-1);
|
||||
expect(scrollPastMock).lastCalledWith(0, -1);
|
||||
});
|
||||
|
||||
it('scrolls to last visible row when boundary is hidden', () => {
|
||||
const parentOfLastRowWithHiddenChildrenIndex = trace.spans.length - 2;
|
||||
accessors.getBottomRowIndexVisible.mockReturnValue(0);
|
||||
accessors.getCollapsedChildren = () =>
|
||||
new Set([trace.spans[parentOfLastRowWithHiddenChildrenIndex].spanID]);
|
||||
accessors.getSearchedSpanIDs = () => new Set([trace.spans[0].spanID]);
|
||||
trace.spans[trace.spans.length - 1].references = getRefs(
|
||||
trace.spans[parentOfLastRowWithHiddenChildrenIndex].spanID
|
||||
);
|
||||
|
||||
manager._scrollToVisibleSpan(1);
|
||||
expect(scrollPastMock).lastCalledWith(parentOfLastRowWithHiddenChildrenIndex, 1);
|
||||
});
|
||||
|
||||
describe('scrollToNextVisibleSpan() and scrollToPrevVisibleSpan()', () => {
|
||||
beforeEach(() => {
|
||||
// change spans so 0 and 4 are top-level and their children are collapsed
|
||||
const spans = trace.spans;
|
||||
let parentID;
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
switch (i) {
|
||||
case 0:
|
||||
case 4:
|
||||
parentID = spans[i].spanID;
|
||||
break;
|
||||
default:
|
||||
spans[i].references = getRefs(parentID);
|
||||
}
|
||||
}
|
||||
// set which spans are "in-view" and which have collapsed children
|
||||
accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1);
|
||||
accessors.getBottomRowIndexVisible.mockReturnValue(0);
|
||||
accessors.getCollapsedChildren.mockReturnValue(new Set([spans[0].spanID, spans[4].spanID]));
|
||||
});
|
||||
|
||||
it('skips spans that are hidden because their parent is collapsed', () => {
|
||||
manager.scrollToNextVisibleSpan();
|
||||
expect(scrollPastMock).lastCalledWith(4, 1);
|
||||
manager.scrollToPrevVisibleSpan();
|
||||
expect(scrollPastMock).lastCalledWith(4, -1);
|
||||
});
|
||||
|
||||
it('ignores references with unknown types', () => {
|
||||
// modify spans[2] so that it has an unknown refType
|
||||
const spans = trace.spans;
|
||||
spans[2].references = [{ refType: 'OTHER' }];
|
||||
manager.scrollToNextVisibleSpan();
|
||||
expect(scrollPastMock).lastCalledWith(2, 1);
|
||||
manager.scrollToPrevVisibleSpan();
|
||||
expect(scrollPastMock).lastCalledWith(4, -1);
|
||||
});
|
||||
|
||||
it('handles more than one level of ancestry', () => {
|
||||
// modify spans[2] so that it has an unknown refType
|
||||
const spans = trace.spans;
|
||||
spans[2].references = getRefs(spans[1].spanID);
|
||||
manager.scrollToNextVisibleSpan();
|
||||
expect(scrollPastMock).lastCalledWith(4, 1);
|
||||
manager.scrollToPrevVisibleSpan();
|
||||
expect(scrollPastMock).lastCalledWith(4, -1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollToFirstVisibleSpan', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(manager, '_scrollToVisibleSpan').mockImplementationOnce();
|
||||
});
|
||||
|
||||
it('calls _scrollToVisibleSpan searching downwards from first span', () => {
|
||||
manager.scrollToFirstVisibleSpan();
|
||||
expect(manager._scrollToVisibleSpan).toHaveBeenCalledWith(1, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollPageDown() and scrollPageUp()', () => {
|
||||
it('scrolls by +/~ viewHeight when invoked', () => {
|
||||
manager.scrollPageDown();
|
||||
expect(scrollBy).lastCalledWith(0.95 * accessors.getViewHeight(), true);
|
||||
manager.scrollPageUp();
|
||||
expect(scrollBy).lastCalledWith(-0.95 * accessors.getViewHeight(), true);
|
||||
});
|
||||
|
||||
it('is a no-op if _accessors or _scroller is not defined', () => {
|
||||
manager._accessors = null;
|
||||
manager.scrollPageDown();
|
||||
manager.scrollPageUp();
|
||||
expect(scrollBy.mock.calls.length).toBe(0);
|
||||
manager._accessors = accessors;
|
||||
manager._scroller = null;
|
||||
manager.scrollPageDown();
|
||||
manager.scrollPageUp();
|
||||
expect(scrollBy.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy()', () => {
|
||||
it('disposes', () => {
|
||||
expect(manager._trace).toBeDefined();
|
||||
expect(manager._accessors).toBeDefined();
|
||||
expect(manager._scroller).toBeDefined();
|
||||
manager.destroy();
|
||||
expect(manager._trace).not.toBeDefined();
|
||||
expect(manager._accessors).not.toBeDefined();
|
||||
expect(manager._scroller).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
274
packages/jaeger-ui-components/src/ScrollManager.tsx
Normal file
274
packages/jaeger-ui-components/src/ScrollManager.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { TNil } from './types';
|
||||
import { Span, SpanReference, Trace } from './types/trace';
|
||||
|
||||
/**
|
||||
* `Accessors` is necessary because `ScrollManager` needs to be created by
|
||||
* `TracePage` so it can be passed into the keyboard shortcut manager. But,
|
||||
* `ScrollManager` needs to know about the state of `ListView` and `Positions`,
|
||||
* which are very low-level. And, storing their state info in redux or
|
||||
* `TracePage#state` would be inefficient because the state info only rarely
|
||||
* needs to be accessed (when a keyboard shortcut is triggered). `Accessors`
|
||||
* allows that state info to be accessed in a loosely coupled fashion on an
|
||||
* as-needed basis.
|
||||
*/
|
||||
export type Accessors = {
|
||||
getViewRange: () => [number, number];
|
||||
getSearchedSpanIDs: () => Set<string> | TNil;
|
||||
getCollapsedChildren: () => Set<string> | TNil;
|
||||
getViewHeight: () => number;
|
||||
getBottomRowIndexVisible: () => number;
|
||||
getTopRowIndexVisible: () => number;
|
||||
getRowPosition: (rowIndex: number) => { height: number; y: number };
|
||||
mapRowIndexToSpanIndex: (rowIndex: number) => number;
|
||||
mapSpanIndexToRowIndex: (spanIndex: number) => number;
|
||||
};
|
||||
|
||||
interface IScroller {
|
||||
scrollTo: (rowIndex: number) => void;
|
||||
// TODO arg names throughout
|
||||
scrollBy: (rowIndex: number, opt?: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `{ isHidden: true, ... }` if one of the parents of `span` is
|
||||
* collapsed, e.g. has children hidden.
|
||||
*
|
||||
* @param {Span} span The Span to check for.
|
||||
* @param {Set<string>} childrenAreHidden The set of Spans known to have hidden
|
||||
* children, either because it is
|
||||
* collapsed or has a collapsed parent.
|
||||
* @param {Map<string, Span | TNil} spansMap Mapping from spanID to Span.
|
||||
* @returns {{ isHidden: boolean, parentIds: Set<string> }}
|
||||
*/
|
||||
function isSpanHidden(span: Span, childrenAreHidden: Set<string>, spansMap: Map<string, Span | TNil>) {
|
||||
const parentIDs = new Set<string>();
|
||||
let { references }: { references: SpanReference[] | TNil } = span;
|
||||
let parentID: undefined | string;
|
||||
const checkRef = (ref: SpanReference) => {
|
||||
if (ref.refType === 'CHILD_OF' || ref.refType === 'FOLLOWS_FROM') {
|
||||
parentID = ref.spanID;
|
||||
parentIDs.add(parentID);
|
||||
return childrenAreHidden.has(parentID);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
while (Array.isArray(references) && references.length) {
|
||||
const isHidden = references.some(checkRef);
|
||||
if (isHidden) {
|
||||
return { isHidden, parentIDs };
|
||||
}
|
||||
if (!parentID) {
|
||||
break;
|
||||
}
|
||||
const parent = spansMap.get(parentID);
|
||||
parentID = undefined;
|
||||
references = parent && parent.references;
|
||||
}
|
||||
return { parentIDs, isHidden: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* ScrollManager is intended for scrolling the TracePage. Has two modes, paging
|
||||
* and scrolling to the previous or next visible span.
|
||||
*/
|
||||
export default class ScrollManager {
|
||||
_trace: Trace | TNil;
|
||||
_scroller: IScroller;
|
||||
_accessors: Accessors | TNil;
|
||||
|
||||
constructor(trace: Trace | TNil, scroller: IScroller) {
|
||||
this._trace = trace;
|
||||
this._scroller = scroller;
|
||||
this._accessors = undefined;
|
||||
}
|
||||
|
||||
_scrollPast(rowIndex: number, direction: 1 | -1) {
|
||||
const xrs = this._accessors;
|
||||
/* istanbul ignore next */
|
||||
if (!xrs) {
|
||||
throw new Error('Accessors not set');
|
||||
}
|
||||
const isUp = direction < 0;
|
||||
const position = xrs.getRowPosition(rowIndex);
|
||||
if (!position) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Invalid row index');
|
||||
return;
|
||||
}
|
||||
let { y } = position;
|
||||
const vh = xrs.getViewHeight();
|
||||
if (!isUp) {
|
||||
y += position.height;
|
||||
// scrollTop is based on the top of the window
|
||||
y -= vh;
|
||||
}
|
||||
y += direction * 0.5 * vh;
|
||||
this._scroller.scrollTo(y);
|
||||
}
|
||||
|
||||
_scrollToVisibleSpan(direction: 1 | -1, startRow?: number) {
|
||||
const xrs = this._accessors;
|
||||
/* istanbul ignore next */
|
||||
if (!xrs) {
|
||||
throw new Error('Accessors not set');
|
||||
}
|
||||
if (!this._trace) {
|
||||
return;
|
||||
}
|
||||
const { duration, spans, startTime: traceStartTime } = this._trace;
|
||||
const isUp = direction < 0;
|
||||
let boundaryRow: number;
|
||||
if (startRow != null) {
|
||||
boundaryRow = startRow;
|
||||
} else if (isUp) {
|
||||
boundaryRow = xrs.getTopRowIndexVisible();
|
||||
} else {
|
||||
boundaryRow = xrs.getBottomRowIndexVisible();
|
||||
}
|
||||
const spanIndex = xrs.mapRowIndexToSpanIndex(boundaryRow);
|
||||
if ((spanIndex === 0 && isUp) || (spanIndex === spans.length - 1 && !isUp)) {
|
||||
return;
|
||||
}
|
||||
// fullViewSpanIndex is one row inside the view window unless already at the top or bottom
|
||||
let fullViewSpanIndex = spanIndex;
|
||||
if (spanIndex !== 0 && spanIndex !== spans.length - 1) {
|
||||
fullViewSpanIndex -= direction;
|
||||
}
|
||||
const [viewStart, viewEnd] = xrs.getViewRange();
|
||||
const checkVisibility = viewStart !== 0 || viewEnd !== 1;
|
||||
// use NaN as fallback to make flow happy
|
||||
const startTime = checkVisibility ? traceStartTime + duration * viewStart : NaN;
|
||||
const endTime = checkVisibility ? traceStartTime + duration * viewEnd : NaN;
|
||||
const findMatches = xrs.getSearchedSpanIDs();
|
||||
const _collapsed = xrs.getCollapsedChildren();
|
||||
const childrenAreHidden = _collapsed ? new Set(_collapsed) : null;
|
||||
// use empty Map as fallback to make flow happy
|
||||
const spansMap: Map<string, Span> = childrenAreHidden
|
||||
? new Map(spans.map(s => [s.spanID, s] as [string, Span]))
|
||||
: new Map();
|
||||
const boundary = direction < 0 ? -1 : spans.length;
|
||||
let nextSpanIndex: number | undefined;
|
||||
for (let i = fullViewSpanIndex + direction; i !== boundary; i += direction) {
|
||||
const span = spans[i];
|
||||
const { duration: spanDuration, spanID, startTime: spanStartTime } = span;
|
||||
const spanEndTime = spanStartTime + spanDuration;
|
||||
if (checkVisibility && (spanStartTime > endTime || spanEndTime < startTime)) {
|
||||
// span is not visible within the view range
|
||||
continue;
|
||||
}
|
||||
if (findMatches && !findMatches.has(spanID)) {
|
||||
// skip to search matches (when searching)
|
||||
continue;
|
||||
}
|
||||
if (childrenAreHidden) {
|
||||
// make sure the span is not collapsed
|
||||
const { isHidden, parentIDs } = isSpanHidden(span, childrenAreHidden, spansMap);
|
||||
if (isHidden) {
|
||||
parentIDs.forEach(id => childrenAreHidden.add(id));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
nextSpanIndex = i;
|
||||
break;
|
||||
}
|
||||
if (!nextSpanIndex || nextSpanIndex === boundary) {
|
||||
// might as well scroll to the top or bottom
|
||||
nextSpanIndex = boundary - direction;
|
||||
|
||||
// If there are hidden children, scroll to the last visible span
|
||||
if (childrenAreHidden) {
|
||||
let isFallbackHidden: boolean;
|
||||
do {
|
||||
const { isHidden, parentIDs } = isSpanHidden(spans[nextSpanIndex], childrenAreHidden, spansMap);
|
||||
if (isHidden) {
|
||||
parentIDs.forEach(id => childrenAreHidden.add(id));
|
||||
nextSpanIndex--;
|
||||
}
|
||||
isFallbackHidden = isHidden;
|
||||
} while (isFallbackHidden);
|
||||
}
|
||||
}
|
||||
const nextRow = xrs.mapSpanIndexToRowIndex(nextSpanIndex);
|
||||
this._scrollPast(nextRow, direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sometimes the ScrollManager is created before the trace is loaded. This
|
||||
* setter allows the trace to be set asynchronously.
|
||||
*/
|
||||
setTrace(trace: Trace | TNil) {
|
||||
this._trace = trace;
|
||||
}
|
||||
|
||||
/**
|
||||
* `setAccessors` is bound in the ctor, so it can be passed as a prop to
|
||||
* children components.
|
||||
*/
|
||||
setAccessors = (accessors: Accessors) => {
|
||||
this._accessors = accessors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scrolls around one page down (0.95x). It is bounds in the ctor, so it can
|
||||
* be used as a keyboard shortcut handler.
|
||||
*/
|
||||
scrollPageDown = () => {
|
||||
if (!this._scroller || !this._accessors) {
|
||||
return;
|
||||
}
|
||||
this._scroller.scrollBy(0.95 * this._accessors.getViewHeight(), true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scrolls around one page up (0.95x). It is bounds in the ctor, so it can
|
||||
* be used as a keyboard shortcut handler.
|
||||
*/
|
||||
scrollPageUp = () => {
|
||||
if (!this._scroller || !this._accessors) {
|
||||
return;
|
||||
}
|
||||
this._scroller.scrollBy(-0.95 * this._accessors.getViewHeight(), true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scrolls to the next visible span, ignoring spans that do not match the
|
||||
* text filter, if there is one. It is bounds in the ctor, so it can
|
||||
* be used as a keyboard shortcut handler.
|
||||
*/
|
||||
scrollToNextVisibleSpan = () => {
|
||||
this._scrollToVisibleSpan(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scrolls to the previous visible span, ignoring spans that do not match the
|
||||
* text filter, if there is one. It is bounds in the ctor, so it can
|
||||
* be used as a keyboard shortcut handler.
|
||||
*/
|
||||
scrollToPrevVisibleSpan = () => {
|
||||
this._scrollToVisibleSpan(-1);
|
||||
};
|
||||
|
||||
scrollToFirstVisibleSpan = () => {
|
||||
this._scrollToVisibleSpan(1, 0);
|
||||
};
|
||||
|
||||
destroy() {
|
||||
this._trace = undefined;
|
||||
this._scroller = undefined as any;
|
||||
this._accessors = undefined;
|
||||
}
|
||||
}
|
||||
86
packages/jaeger-ui-components/src/Theme.tsx
Normal file
86
packages/jaeger-ui-components/src/Theme.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import hoistNonReactStatics from 'hoist-non-react-statics';
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
export type ThemeOptions = Partial<Theme>;
|
||||
|
||||
export type Theme = {
|
||||
borderStyle: string;
|
||||
};
|
||||
|
||||
export const defaultTheme: Theme = {
|
||||
borderStyle: '1px solid #bbb',
|
||||
};
|
||||
|
||||
const ThemeContext = React.createContext<ThemeOptions | undefined>(undefined);
|
||||
ThemeContext.displayName = 'ThemeContext';
|
||||
|
||||
export const ThemeProvider = ThemeContext.Provider;
|
||||
|
||||
type ThemeConsumerProps = {
|
||||
children: (theme: Theme) => React.ReactNode;
|
||||
};
|
||||
export function ThemeConsumer(props: ThemeConsumerProps) {
|
||||
return (
|
||||
<ThemeContext.Consumer>
|
||||
{(value: ThemeOptions | undefined) => {
|
||||
const mergedTheme: Theme = value
|
||||
? {
|
||||
...defaultTheme,
|
||||
...value,
|
||||
}
|
||||
: defaultTheme;
|
||||
return props.children(mergedTheme);
|
||||
}}
|
||||
</ThemeContext.Consumer>
|
||||
);
|
||||
}
|
||||
|
||||
type WrappedWithThemeComponent<Props> = React.ComponentType<Omit<Props, 'theme'>> & {
|
||||
wrapped: React.ComponentType<Props>;
|
||||
};
|
||||
|
||||
export const withTheme = <Props extends { theme: Theme }, Statics extends {} = {}>(
|
||||
Component: React.ComponentType<Props>
|
||||
): WrappedWithThemeComponent<Props> => {
|
||||
let WithTheme: React.ComponentType<Omit<Props, 'theme'>> = props => {
|
||||
return (
|
||||
<ThemeConsumer>
|
||||
{(theme: Theme) => (
|
||||
<Component
|
||||
{...({
|
||||
...props,
|
||||
theme,
|
||||
} as Props & { theme: Theme })}
|
||||
/>
|
||||
)}
|
||||
</ThemeConsumer>
|
||||
);
|
||||
};
|
||||
|
||||
WithTheme.displayName = `WithTheme(${Component.displayName})`;
|
||||
WithTheme = hoistNonReactStatics<React.ComponentType<Omit<Props, 'theme'>>, React.ComponentType<Props>>(
|
||||
WithTheme,
|
||||
Component
|
||||
);
|
||||
(WithTheme as WrappedWithThemeComponent<Props>).wrapped = Component;
|
||||
return WithTheme as WrappedWithThemeComponent<Props>;
|
||||
};
|
||||
|
||||
export const createStyle = <Fn extends (this: any, ...newArgs: any[]) => ReturnType<Fn>>(fn: Fn) => {
|
||||
return memoizeOne(fn);
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import Positions from './Positions';
|
||||
|
||||
describe('Positions', () => {
|
||||
const bufferLen = 1;
|
||||
const getHeight = i => i * 2 + 2;
|
||||
let ps;
|
||||
|
||||
beforeEach(() => {
|
||||
ps = new Positions(bufferLen);
|
||||
ps.profileData(10);
|
||||
});
|
||||
|
||||
describe('constructor()', () => {
|
||||
it('intializes member variables correctly', () => {
|
||||
ps = new Positions(1);
|
||||
expect(ps.ys).toEqual([]);
|
||||
expect(ps.heights).toEqual([]);
|
||||
expect(ps.bufferLen).toBe(1);
|
||||
expect(ps.dataLen).toBe(-1);
|
||||
expect(ps.lastI).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('profileData(...)', () => {
|
||||
it('manages increases in data length correctly', () => {
|
||||
expect(ps.dataLen).toBe(10);
|
||||
expect(ps.ys.length).toBe(10);
|
||||
expect(ps.heights.length).toBe(10);
|
||||
expect(ps.lastI).toBe(-1);
|
||||
});
|
||||
|
||||
it('manages decreases in data length correctly', () => {
|
||||
ps.lastI = 9;
|
||||
ps.profileData(5);
|
||||
expect(ps.dataLen).toBe(5);
|
||||
expect(ps.ys.length).toBe(5);
|
||||
expect(ps.heights.length).toBe(5);
|
||||
expect(ps.lastI).toBe(4);
|
||||
});
|
||||
|
||||
it('does nothing when data length is unchanged', () => {
|
||||
expect(ps.dataLen).toBe(10);
|
||||
expect(ps.ys.length).toBe(10);
|
||||
expect(ps.heights.length).toBe(10);
|
||||
expect(ps.lastI).toBe(-1);
|
||||
ps.profileData(10);
|
||||
expect(ps.dataLen).toBe(10);
|
||||
expect(ps.ys.length).toBe(10);
|
||||
expect(ps.heights.length).toBe(10);
|
||||
expect(ps.lastI).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcHeights()', () => {
|
||||
it('updates lastI correctly', () => {
|
||||
ps.calcHeights(1, getHeight);
|
||||
expect(ps.lastI).toBe(bufferLen + 1);
|
||||
});
|
||||
|
||||
it('saves the heights and y-values up to `lastI <= max + bufferLen`', () => {
|
||||
const ys = [0, 2, 6, 12];
|
||||
ys.length = 10;
|
||||
const heights = [2, 4, 6];
|
||||
heights.length = 10;
|
||||
ps.calcHeights(1, getHeight);
|
||||
expect(ps.ys).toEqual(ys);
|
||||
expect(ps.heights).toEqual(heights);
|
||||
});
|
||||
|
||||
it('does nothing when `max + buffer <= lastI`', () => {
|
||||
ps.calcHeights(2, getHeight);
|
||||
const ys = ps.ys.slice();
|
||||
const heights = ps.heights.slice();
|
||||
ps.calcHeights(1, getHeight);
|
||||
expect(ps.ys).toEqual(ys);
|
||||
expect(ps.heights).toEqual(heights);
|
||||
});
|
||||
|
||||
describe('recalculates values up to `max + bufferLen` when `max + buffer <= lastI` and `forcedLastI = 0` is passed', () => {
|
||||
beforeEach(() => {
|
||||
// the initial state for the test
|
||||
ps.calcHeights(2, getHeight);
|
||||
});
|
||||
|
||||
it('test-case has a valid initial state', () => {
|
||||
const initialYs = [0, 2, 6, 12, 20];
|
||||
initialYs.length = 10;
|
||||
const initialHeights = [2, 4, 6, 8];
|
||||
initialHeights.length = 10;
|
||||
expect(ps.ys).toEqual(initialYs);
|
||||
expect(ps.heights).toEqual(initialHeights);
|
||||
expect(ps.lastI).toBe(3);
|
||||
});
|
||||
|
||||
it('recalcualtes the y-values correctly', () => {
|
||||
// recalc a sub-set of the calcualted values using a different getHeight
|
||||
ps.calcHeights(1, () => 2, 0);
|
||||
const ys = [0, 2, 4, 6, 20];
|
||||
ys.length = 10;
|
||||
expect(ps.ys).toEqual(ys);
|
||||
});
|
||||
it('recalcualtes the heights correctly', () => {
|
||||
// recalc a sub-set of the calcualted values using a different getHeight
|
||||
ps.calcHeights(1, () => 2, 0);
|
||||
const heights = [2, 2, 2, 8];
|
||||
heights.length = 10;
|
||||
expect(ps.heights).toEqual(heights);
|
||||
});
|
||||
it('saves lastI correctly', () => {
|
||||
// recalc a sub-set of the calcualted values
|
||||
ps.calcHeights(1, getHeight, 0);
|
||||
expect(ps.lastI).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('limits caclulations to the known data length', () => {
|
||||
ps.calcHeights(999, getHeight);
|
||||
expect(ps.lastI).toBe(ps.dataLen - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcYs()', () => {
|
||||
it('scans forward until `yValue` is met or exceeded', () => {
|
||||
ps.calcYs(11, getHeight);
|
||||
const ys = [0, 2, 6, 12, 20];
|
||||
ys.length = 10;
|
||||
const heights = [2, 4, 6, 8];
|
||||
heights.length = 10;
|
||||
expect(ps.ys).toEqual(ys);
|
||||
expect(ps.heights).toEqual(heights);
|
||||
});
|
||||
|
||||
it('exits early if the known y-values exceed `yValue`', () => {
|
||||
ps.calcYs(11, getHeight);
|
||||
const spy = jest.spyOn(ps, 'calcHeights');
|
||||
ps.calcYs(10, getHeight);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits when exceeds the data length even if yValue is unmet', () => {
|
||||
ps.calcYs(999, getHeight);
|
||||
expect(ps.ys[ps.ys.length - 1]).toBeLessThan(999);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findFloorIndex()', () => {
|
||||
beforeEach(() => {
|
||||
ps.calcYs(11, getHeight);
|
||||
// Note: ps.ys = [0, 2, 6, 12, 20, undefined x 5];
|
||||
});
|
||||
|
||||
it('scans y-values for index that equals or preceeds `yValue`', () => {
|
||||
let i = ps.findFloorIndex(3, getHeight);
|
||||
expect(i).toBe(1);
|
||||
i = ps.findFloorIndex(21, getHeight);
|
||||
expect(i).toBe(4);
|
||||
ps.calcYs(999, getHeight);
|
||||
i = ps.findFloorIndex(11, getHeight);
|
||||
expect(i).toBe(2);
|
||||
i = ps.findFloorIndex(12, getHeight);
|
||||
expect(i).toBe(3);
|
||||
i = ps.findFloorIndex(20, getHeight);
|
||||
expect(i).toBe(4);
|
||||
});
|
||||
|
||||
it('is robust against non-positive y-values', () => {
|
||||
let i = ps.findFloorIndex(0, getHeight);
|
||||
expect(i).toBe(0);
|
||||
i = ps.findFloorIndex(-10, getHeight);
|
||||
expect(i).toBe(0);
|
||||
});
|
||||
|
||||
it('scans no further than dataLen even if `yValue` is unmet', () => {
|
||||
const i = ps.findFloorIndex(999, getHeight);
|
||||
expect(i).toBe(ps.lastI);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEstimatedHeight()', () => {
|
||||
const simpleGetHeight = () => 2;
|
||||
|
||||
beforeEach(() => {
|
||||
ps.calcYs(5, simpleGetHeight);
|
||||
// Note: ps.ys = [0, 2, 4, 6, 8, undefined x 5];
|
||||
});
|
||||
|
||||
it('returns the estimated max height, surpassing known values', () => {
|
||||
const estHeight = ps.getEstimatedHeight();
|
||||
expect(estHeight).toBeGreaterThan(ps.heights[ps.lastI]);
|
||||
});
|
||||
|
||||
it('returns the known max height, if all heights have been calculated', () => {
|
||||
ps.calcYs(999, simpleGetHeight);
|
||||
const totalHeight = ps.getEstimatedHeight();
|
||||
expect(totalHeight).toBeGreaterThan(ps.heights[ps.heights.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmHeight()', () => {
|
||||
const simpleGetHeight = () => 2;
|
||||
|
||||
beforeEach(() => {
|
||||
ps.calcYs(5, simpleGetHeight);
|
||||
// Note: ps.ys = [0, 2, 4, 6, 8, undefined x 5];
|
||||
});
|
||||
|
||||
it('calculates heights up to and including `_i` if necessary', () => {
|
||||
const startNumHeights = ps.heights.filter(Boolean).length;
|
||||
const calcHeightsSpy = jest.spyOn(ps, 'calcHeights');
|
||||
ps.confirmHeight(7, simpleGetHeight);
|
||||
const endNumHeights = ps.heights.filter(Boolean).length;
|
||||
expect(startNumHeights).toBeLessThan(endNumHeights);
|
||||
expect(calcHeightsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invokes `heightGetter` at `_i` to compare result with known height', () => {
|
||||
const getHeightSpy = jest.fn(simpleGetHeight);
|
||||
ps.confirmHeight(ps.lastI - 1, getHeightSpy);
|
||||
expect(getHeightSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cascades difference in observed height vs known height to known y-values', () => {
|
||||
const getLargerHeight = () => simpleGetHeight() + 2;
|
||||
const knownYs = ps.ys.slice();
|
||||
const expectedYValues = knownYs.map(value => (value ? value + 2 : value));
|
||||
ps.confirmHeight(0, getLargerHeight);
|
||||
expect(ps.ys).toEqual(expectedYValues);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
type THeightGetter = (index: number) => number;
|
||||
|
||||
/**
|
||||
* Keeps track of the height and y-position for anything sequenctial where
|
||||
* y-positions follow one-after-another and can be derived from the height of
|
||||
* the prior entries. The height is known from an accessor function parameter
|
||||
* to the methods that require new knowledge the heights.
|
||||
*
|
||||
* @export
|
||||
* @class Positions
|
||||
*/
|
||||
export default class Positions {
|
||||
/**
|
||||
* Indicates how far past the explicitly required height or y-values should
|
||||
* checked.
|
||||
*/
|
||||
bufferLen: number;
|
||||
dataLen: number;
|
||||
heights: number[];
|
||||
/**
|
||||
* `lastI` keeps track of which values have already been visited. In many
|
||||
* scenarios, values do not need to be revisited. But, revisiting is required
|
||||
* when heights have changed, so `lastI` can be forced.
|
||||
*/
|
||||
lastI: number;
|
||||
ys: number[];
|
||||
|
||||
constructor(bufferLen: number) {
|
||||
this.ys = [];
|
||||
this.heights = [];
|
||||
this.bufferLen = bufferLen;
|
||||
this.dataLen = -1;
|
||||
this.lastI = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to make sure the length of y-values and heights is consistent with
|
||||
* the context; in particular `lastI` needs to remain valid.
|
||||
*/
|
||||
profileData(dataLength: number) {
|
||||
if (dataLength !== this.dataLen) {
|
||||
this.dataLen = dataLength;
|
||||
this.ys.length = dataLength;
|
||||
this.heights.length = dataLength;
|
||||
if (this.lastI >= dataLength) {
|
||||
this.lastI = dataLength - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and save the heights and y-values, based on `heightGetter`, from
|
||||
* `lastI` until the`max` index; the starting point (`lastI`) can be forced
|
||||
* via the `forcedLastI` parameter.
|
||||
* @param {number=} forcedLastI
|
||||
*/
|
||||
calcHeights(max: number, heightGetter: THeightGetter, forcedLastI?: number) {
|
||||
if (forcedLastI != null) {
|
||||
this.lastI = forcedLastI;
|
||||
}
|
||||
let _max = max + this.bufferLen;
|
||||
if (_max <= this.lastI) {
|
||||
return;
|
||||
}
|
||||
if (_max >= this.heights.length) {
|
||||
_max = this.heights.length - 1;
|
||||
}
|
||||
let i = this.lastI;
|
||||
if (this.lastI === -1) {
|
||||
i = 0;
|
||||
this.ys[0] = 0;
|
||||
}
|
||||
while (i <= _max) {
|
||||
// eslint-disable-next-line no-multi-assign
|
||||
const h = (this.heights[i] = heightGetter(i));
|
||||
this.ys[i + 1] = this.ys[i] + h;
|
||||
i++;
|
||||
}
|
||||
this.lastI = _max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the height and y-values from `lastI` up to `yValue`.
|
||||
*/
|
||||
calcYs(yValue: number, heightGetter: THeightGetter) {
|
||||
while ((this.ys[this.lastI] == null || yValue > this.ys[this.lastI]) && this.lastI < this.dataLen - 1) {
|
||||
this.calcHeights(this.lastI, heightGetter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest height for index `_i`. If it's in new terretory
|
||||
* (_i > lastI), find the heights (and y-values) leading up to it. If it's in
|
||||
* known territory (_i <= lastI) and the height is different than what is
|
||||
* known, recalculate subsequent y values, but don't confirm the heights of
|
||||
* those items, just update based on the difference.
|
||||
*/
|
||||
confirmHeight(_i: number, heightGetter: THeightGetter) {
|
||||
let i = _i;
|
||||
if (i > this.lastI) {
|
||||
this.calcHeights(i, heightGetter);
|
||||
return;
|
||||
}
|
||||
const h = heightGetter(i);
|
||||
if (h === this.heights[i]) {
|
||||
return;
|
||||
}
|
||||
const chg = h - this.heights[i];
|
||||
this.heights[i] = h;
|
||||
// shift the y positions by `chg` for all known y positions
|
||||
while (++i <= this.lastI) {
|
||||
this.ys[i] += chg;
|
||||
}
|
||||
if (this.ys[this.lastI + 1] != null) {
|
||||
this.ys[this.lastI + 1] += chg;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a target y-value (`yValue`), find the closest index (in the `.ys`
|
||||
* array) that is prior to the y-value; e.g. map from y-value to index in
|
||||
* `.ys`.
|
||||
*/
|
||||
findFloorIndex(yValue: number, heightGetter: THeightGetter): number {
|
||||
this.calcYs(yValue, heightGetter);
|
||||
|
||||
let imin = 0;
|
||||
let imax = this.lastI;
|
||||
|
||||
if (this.ys.length < 2 || yValue < this.ys[1]) {
|
||||
return 0;
|
||||
}
|
||||
if (yValue > this.ys[imax]) {
|
||||
return imax;
|
||||
}
|
||||
let i;
|
||||
while (imin < imax) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
i = (imin + 0.5 * (imax - imin)) | 0;
|
||||
if (yValue > this.ys[i]) {
|
||||
if (yValue <= this.ys[i + 1]) {
|
||||
return i;
|
||||
}
|
||||
imin = i;
|
||||
} else if (yValue < this.ys[i]) {
|
||||
if (yValue >= this.ys[i - 1]) {
|
||||
return i - 1;
|
||||
}
|
||||
imax = i;
|
||||
} else {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
throw new Error(`unable to find floor index for y=${yValue}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the `y` and `height` for a given row.
|
||||
*
|
||||
* @returns {{ height: number, y: number }}
|
||||
*/
|
||||
getRowPosition(index: number, heightGetter: THeightGetter) {
|
||||
this.confirmHeight(index, heightGetter);
|
||||
return {
|
||||
height: this.heights[index],
|
||||
y: this.ys[index],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the estimated height of the whole shebang by extrapolating based on
|
||||
* the average known height.
|
||||
*/
|
||||
getEstimatedHeight(): number {
|
||||
const known = this.ys[this.lastI] + this.heights[this.lastI];
|
||||
if (this.lastI >= this.dataLen - 1) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return known | 0;
|
||||
}
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return ((known / (this.lastI + 1)) * this.heights.length) | 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<ListView> shallow tests matches a snapshot 1`] = `
|
||||
<div
|
||||
onScroll={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "100%",
|
||||
"overflowY": "auto",
|
||||
"position": "relative",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"height": 1640,
|
||||
"position": "relative",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="SomeClassName"
|
||||
style={
|
||||
Object {
|
||||
"margin": 0,
|
||||
"padding": 0,
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Item
|
||||
data-item-key="0"
|
||||
key="0"
|
||||
style={
|
||||
Object {
|
||||
"height": 2,
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
0
|
||||
</Item>
|
||||
<Item
|
||||
data-item-key="1"
|
||||
key="1"
|
||||
style={
|
||||
Object {
|
||||
"height": 4,
|
||||
"position": "absolute",
|
||||
"top": 2,
|
||||
}
|
||||
}
|
||||
>
|
||||
1
|
||||
</Item>
|
||||
<Item
|
||||
data-item-key="2"
|
||||
key="2"
|
||||
style={
|
||||
Object {
|
||||
"height": 6,
|
||||
"position": "absolute",
|
||||
"top": 6,
|
||||
}
|
||||
}
|
||||
>
|
||||
2
|
||||
</Item>
|
||||
<Item
|
||||
data-item-key="3"
|
||||
key="3"
|
||||
style={
|
||||
Object {
|
||||
"height": 8,
|
||||
"position": "absolute",
|
||||
"top": 12,
|
||||
}
|
||||
}
|
||||
>
|
||||
3
|
||||
</Item>
|
||||
<Item
|
||||
data-item-key="4"
|
||||
key="4"
|
||||
style={
|
||||
Object {
|
||||
"height": 10,
|
||||
"position": "absolute",
|
||||
"top": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
4
|
||||
</Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
|
||||
import ListView from './index';
|
||||
import { polyfill as polyfillAnimationFrame } from '../../utils/test/requestAnimationFrame';
|
||||
|
||||
// Util to get list of all callbacks added to an event emitter by event type.
|
||||
// jest adds "error" event listeners to window, this util makes it easier to
|
||||
// ignore those calls.
|
||||
function getListenersByType(mockFn) {
|
||||
const rv = {};
|
||||
mockFn.calls.forEach(([eventType, callback]) => {
|
||||
if (!rv[eventType]) {
|
||||
rv[eventType] = [callback];
|
||||
} else {
|
||||
rv[eventType].push(callback);
|
||||
}
|
||||
});
|
||||
return rv;
|
||||
}
|
||||
|
||||
describe('<ListView>', () => {
|
||||
// polyfill window.requestAnimationFrame (and cancel) into jsDom's window
|
||||
polyfillAnimationFrame(window);
|
||||
|
||||
const DATA_LENGTH = 40;
|
||||
|
||||
function getHeight(index) {
|
||||
return index * 2 + 2;
|
||||
}
|
||||
|
||||
function Item(props) {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const { children, ...rest } = props;
|
||||
return <div {...rest}>{children}</div>;
|
||||
}
|
||||
|
||||
function renderItem(itemKey, styles, itemIndex, attrs) {
|
||||
return (
|
||||
<Item key={itemKey} style={styles} {...attrs}>
|
||||
{itemIndex}
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
let wrapper;
|
||||
let instance;
|
||||
|
||||
const props = {
|
||||
dataLength: DATA_LENGTH,
|
||||
getIndexFromKey: Number,
|
||||
getKeyFromIndex: String,
|
||||
initialDraw: 5,
|
||||
itemHeightGetter: getHeight,
|
||||
itemRenderer: renderItem,
|
||||
itemsWrapperClassName: 'SomeClassName',
|
||||
viewBuffer: 10,
|
||||
viewBufferMin: 5,
|
||||
windowScroller: false,
|
||||
};
|
||||
|
||||
describe('shallow tests', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<ListView {...props} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('matches a snapshot', () => {
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('initialDraw sets the number of items initially drawn', () => {
|
||||
expect(wrapper.find(Item).length).toBe(props.initialDraw);
|
||||
});
|
||||
|
||||
it('sets the height of the items according to the height func', () => {
|
||||
const items = wrapper.find(Item);
|
||||
const expectedHeights = [];
|
||||
const heights = items.map((node, i) => {
|
||||
expectedHeights.push(getHeight(i));
|
||||
return node.prop('style').height;
|
||||
});
|
||||
expect(heights.length).toBe(props.initialDraw);
|
||||
expect(heights).toEqual(expectedHeights);
|
||||
});
|
||||
|
||||
it('saves the currently drawn indexes to _startIndexDrawn and _endIndexDrawn', () => {
|
||||
const inst = wrapper.instance();
|
||||
expect(inst._startIndexDrawn).toBe(0);
|
||||
expect(inst._endIndexDrawn).toBe(props.initialDraw - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mount tests', () => {
|
||||
describe('accessor functions', () => {
|
||||
const clientHeight = 2;
|
||||
const scrollTop = 3;
|
||||
|
||||
let oldRender;
|
||||
let oldInitWrapper;
|
||||
const initWrapperMock = jest.fn(elm => {
|
||||
if (elm != null) {
|
||||
// jsDom requires `defineProperties` instead of just setting the props
|
||||
Object.defineProperties(elm, {
|
||||
clientHeight: {
|
||||
get: () => clientHeight,
|
||||
},
|
||||
scrollTop: {
|
||||
get: () => scrollTop,
|
||||
},
|
||||
});
|
||||
}
|
||||
oldInitWrapper.call(this, elm);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
oldRender = ListView.prototype.render;
|
||||
// `_initWrapper` is not on the prototype, so it needs to be mocked
|
||||
// on each instance, use `render()` as a hook to do that
|
||||
ListView.prototype.render = function altRender() {
|
||||
if (this._initWrapper !== initWrapperMock) {
|
||||
oldInitWrapper = this._initWrapper;
|
||||
this._initWrapper = initWrapperMock;
|
||||
}
|
||||
return oldRender.call(this);
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
ListView.prototype.render = oldRender;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
initWrapperMock.mockClear();
|
||||
wrapper = mount(<ListView {...props} />);
|
||||
instance = wrapper.instance();
|
||||
});
|
||||
|
||||
it('getViewHeight() returns the viewHeight', () => {
|
||||
expect(instance.getViewHeight()).toBe(clientHeight);
|
||||
});
|
||||
|
||||
it('getBottomVisibleIndex() returns a number', () => {
|
||||
const n = instance.getBottomVisibleIndex();
|
||||
expect(Number.isNaN(n)).toBe(false);
|
||||
expect(n).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it('getTopVisibleIndex() returns a number', () => {
|
||||
const n = instance.getTopVisibleIndex();
|
||||
expect(Number.isNaN(n)).toBe(false);
|
||||
expect(n).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it('getRowPosition() returns a number', () => {
|
||||
const { height, y } = instance.getRowPosition(2);
|
||||
expect(height).toEqual(expect.any(Number));
|
||||
expect(y).toEqual(expect.any(Number));
|
||||
});
|
||||
});
|
||||
|
||||
describe('windowScroller', () => {
|
||||
let windowAddListenerSpy;
|
||||
let windowRmListenerSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
windowAddListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
windowRmListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
const wsProps = { ...props, windowScroller: true };
|
||||
wrapper = mount(<ListView {...wsProps} />);
|
||||
instance = wrapper.instance();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
windowAddListenerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('adds the onScroll listener to the window element after the component mounts', () => {
|
||||
const eventListeners = getListenersByType(windowAddListenerSpy.mock);
|
||||
expect(eventListeners.scroll).toEqual([instance._onScroll]);
|
||||
});
|
||||
|
||||
it('removes the onScroll listener from window when unmounting', () => {
|
||||
// jest adds "error" event listeners to window, ignore those calls
|
||||
let eventListeners = getListenersByType(windowRmListenerSpy.mock);
|
||||
expect(eventListeners.scroll).not.toBeDefined();
|
||||
wrapper.unmount();
|
||||
eventListeners = getListenersByType(windowRmListenerSpy.mock);
|
||||
expect(eventListeners.scroll).toEqual([instance._onScroll]);
|
||||
});
|
||||
|
||||
it('calls _positionList when the document is scrolled', done => {
|
||||
const event = new Event('scroll');
|
||||
const fn = jest.spyOn(instance, '_positionList');
|
||||
expect(instance._isScrolledOrResized).toBe(false);
|
||||
window.dispatchEvent(event);
|
||||
expect(instance._isScrolledOrResized).toBe(true);
|
||||
window.requestAnimationFrame(() => {
|
||||
expect(fn).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the root HTML element to determine if the view has changed', () => {
|
||||
const htmlElm = instance._htmlElm;
|
||||
expect(htmlElm).toBeTruthy();
|
||||
const spyFns = {
|
||||
clientHeight: jest.fn(() => instance._viewHeight + 1),
|
||||
scrollTop: jest.fn(() => instance._scrollTop + 1),
|
||||
};
|
||||
Object.defineProperties(htmlElm, {
|
||||
clientHeight: {
|
||||
get: spyFns.clientHeight,
|
||||
},
|
||||
scrollTop: {
|
||||
get: spyFns.scrollTop,
|
||||
},
|
||||
});
|
||||
const hasChanged = instance._isViewChanged();
|
||||
expect(spyFns.clientHeight).toHaveBeenCalled();
|
||||
expect(spyFns.scrollTop).toHaveBeenCalled();
|
||||
expect(hasChanged).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,476 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import Positions from './Positions';
|
||||
import { TNil } from '../../types';
|
||||
|
||||
type TWrapperProps = {
|
||||
style: React.CSSProperties;
|
||||
ref: (elm: HTMLDivElement) => void;
|
||||
onScroll?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef
|
||||
*/
|
||||
type TListViewProps = {
|
||||
/**
|
||||
* Number of elements in the list.
|
||||
*/
|
||||
dataLength: number;
|
||||
/**
|
||||
* Convert item index (number) to the key (string). ListView uses both indexes
|
||||
* and keys to handle the addtion of new rows.
|
||||
*/
|
||||
getIndexFromKey: (key: string) => number;
|
||||
/**
|
||||
* Convert item key (string) to the index (number). ListView uses both indexes
|
||||
* and keys to handle the addtion of new rows.
|
||||
*/
|
||||
getKeyFromIndex: (index: number) => string;
|
||||
/**
|
||||
* Number of items to draw and add to the DOM, initially.
|
||||
*/
|
||||
initialDraw?: number;
|
||||
/**
|
||||
* The parent provides fallback height measurements when there is not a
|
||||
* rendered element to measure.
|
||||
*/
|
||||
itemHeightGetter: (index: number, key: string) => number;
|
||||
/**
|
||||
* Function that renders an item; rendered items are added directly to the
|
||||
* DOM, they are not wrapped in list item wrapper HTMLElement.
|
||||
*/
|
||||
// itemRenderer(itemKey, style, i, attrs)
|
||||
itemRenderer: (
|
||||
itemKey: string,
|
||||
style: Record<string, string | number>,
|
||||
index: number,
|
||||
attributes: Record<string, string>
|
||||
) => React.ReactNode;
|
||||
/**
|
||||
* `className` for the HTMLElement that holds the items.
|
||||
*/
|
||||
itemsWrapperClassName?: string;
|
||||
/**
|
||||
* When adding new items to the DOM, this is the number of items to add above
|
||||
* and below the current view. E.g. if list is 100 items and is srcolled
|
||||
* halfway down (so items [46, 55] are in view), then when a new range of
|
||||
* items is rendered, it will render items `46 - viewBuffer` to
|
||||
* `55 + viewBuffer`.
|
||||
*/
|
||||
viewBuffer: number;
|
||||
/**
|
||||
* The minimum number of items offscreen in either direction; e.g. at least
|
||||
* `viewBuffer` number of items must be off screen above and below the
|
||||
* current view, or more items will be rendered.
|
||||
*/
|
||||
viewBufferMin: number;
|
||||
/**
|
||||
* When `true`, expect `_wrapperElm` to have `overflow: visible` and to,
|
||||
* essentially, be tall to the point the entire page will will end up
|
||||
* scrolling as a result of the ListView. Similar to react-virtualized
|
||||
* window scroller.
|
||||
*
|
||||
* - Ref: https://bvaughn.github.io/react-virtualized/#/components/WindowScroller
|
||||
* - Ref:https://github.com/bvaughn/react-virtualized/blob/497e2a1942529560681d65a9ef9f5e9c9c9a49ba/docs/WindowScroller.md
|
||||
*/
|
||||
windowScroller?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_INITIAL_DRAW = 300;
|
||||
|
||||
/**
|
||||
* Virtualized list view component, for the most part, only renders the window
|
||||
* of items that are in-view with some buffer before and after. Listens for
|
||||
* scroll events and updates which items are rendered. See react-virtualized
|
||||
* for a suite of components with similar, but generalized, functinality.
|
||||
* https://github.com/bvaughn/react-virtualized
|
||||
*
|
||||
* Note: Presently, ListView cannot be a PureComponent. This is because ListView
|
||||
* is sensitive to the underlying state that drives the list items, but it
|
||||
* doesn't actually receive that state. So, a render may still be required even
|
||||
* if ListView's props are unchanged.
|
||||
*
|
||||
* @export
|
||||
* @class ListView
|
||||
*/
|
||||
export default class ListView extends React.Component<TListViewProps> {
|
||||
/**
|
||||
* Keeps track of the height and y-value of items, by item index, in the
|
||||
* ListView.
|
||||
*/
|
||||
_yPositions: Positions;
|
||||
/**
|
||||
* Keep track of the known / measured heights of the rendered items; populated
|
||||
* with values through observation and keyed on the item key, not the item
|
||||
* index.
|
||||
*/
|
||||
_knownHeights: Map<string, number>;
|
||||
/**
|
||||
* The start index of the items currently drawn.
|
||||
*/
|
||||
_startIndexDrawn: number;
|
||||
/**
|
||||
* The end index of the items currently drawn.
|
||||
*/
|
||||
_endIndexDrawn: number;
|
||||
/**
|
||||
* The start index of the items currently in view.
|
||||
*/
|
||||
_startIndex: number;
|
||||
/**
|
||||
* The end index of the items currently in view.
|
||||
*/
|
||||
_endIndex: number;
|
||||
/**
|
||||
* Height of the visual window, e.g. height of the scroller element.
|
||||
*/
|
||||
_viewHeight: number;
|
||||
/**
|
||||
* `scrollTop` of the current scroll position.
|
||||
*/
|
||||
_scrollTop: number;
|
||||
/**
|
||||
* Used to keep track of whether or not a re-calculation of what should be
|
||||
* drawn / viewable has been scheduled.
|
||||
*/
|
||||
_isScrolledOrResized: boolean;
|
||||
/**
|
||||
* If `windowScroller` is true, this notes how far down the page the scroller
|
||||
* is located. (Note: repositioning and below-the-fold views are untested)
|
||||
*/
|
||||
_htmlTopOffset: number;
|
||||
_windowScrollListenerAdded: boolean;
|
||||
_htmlElm: HTMLElement;
|
||||
/**
|
||||
* HTMLElement holding the scroller.
|
||||
*/
|
||||
_wrapperElm: HTMLElement | TNil;
|
||||
/**
|
||||
* HTMLElement holding the rendered items.
|
||||
*/
|
||||
_itemHolderElm: HTMLElement | TNil;
|
||||
|
||||
static defaultProps = {
|
||||
initialDraw: DEFAULT_INITIAL_DRAW,
|
||||
itemsWrapperClassName: '',
|
||||
windowScroller: false,
|
||||
};
|
||||
|
||||
constructor(props: TListViewProps) {
|
||||
super(props);
|
||||
|
||||
this._yPositions = new Positions(200);
|
||||
// _knownHeights is (item-key -> observed height) of list items
|
||||
this._knownHeights = new Map();
|
||||
|
||||
this._startIndexDrawn = 2 ** 20;
|
||||
this._endIndexDrawn = -(2 ** 20);
|
||||
this._startIndex = 0;
|
||||
this._endIndex = 0;
|
||||
this._viewHeight = -1;
|
||||
this._scrollTop = -1;
|
||||
this._isScrolledOrResized = false;
|
||||
|
||||
this._htmlTopOffset = -1;
|
||||
this._windowScrollListenerAdded = false;
|
||||
// _htmlElm is only relevant if props.windowScroller is true
|
||||
this._htmlElm = document.documentElement as any;
|
||||
this._wrapperElm = undefined;
|
||||
this._itemHolderElm = undefined;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.windowScroller) {
|
||||
if (this._wrapperElm) {
|
||||
const { top } = this._wrapperElm.getBoundingClientRect();
|
||||
this._htmlTopOffset = top + this._htmlElm.scrollTop;
|
||||
}
|
||||
window.addEventListener('scroll', this._onScroll);
|
||||
this._windowScrollListenerAdded = true;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this._itemHolderElm) {
|
||||
this._scanItemHeights();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._windowScrollListenerAdded) {
|
||||
window.removeEventListener('scroll', this._onScroll);
|
||||
}
|
||||
}
|
||||
|
||||
getViewHeight = () => this._viewHeight;
|
||||
|
||||
/**
|
||||
* Get the index of the item at the bottom of the current view.
|
||||
*/
|
||||
getBottomVisibleIndex = (): number => {
|
||||
const bottomY = this._scrollTop + this._viewHeight;
|
||||
return this._yPositions.findFloorIndex(bottomY, this._getHeight);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the index of the item at the top of the current view.
|
||||
*/
|
||||
getTopVisibleIndex = (): number => this._yPositions.findFloorIndex(this._scrollTop, this._getHeight);
|
||||
|
||||
getRowPosition = (index: number): { height: number; y: number } =>
|
||||
this._yPositions.getRowPosition(index, this._getHeight);
|
||||
|
||||
/**
|
||||
* Scroll event listener that schedules a remeasuring of which items should be
|
||||
* rendered.
|
||||
*/
|
||||
_onScroll = () => {
|
||||
if (!this._isScrolledOrResized) {
|
||||
this._isScrolledOrResized = true;
|
||||
window.requestAnimationFrame(this._positionList);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true is the view height (scroll window) or scroll position have
|
||||
* changed.
|
||||
*/
|
||||
_isViewChanged() {
|
||||
if (!this._wrapperElm) {
|
||||
return false;
|
||||
}
|
||||
const useRoot = this.props.windowScroller;
|
||||
const clientHeight = useRoot ? this._htmlElm.clientHeight : this._wrapperElm.clientHeight;
|
||||
const scrollTop = useRoot ? this._htmlElm.scrollTop : this._wrapperElm.scrollTop;
|
||||
return clientHeight !== this._viewHeight || scrollTop !== this._scrollTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate _startIndex and _endIndex, e.g. which items are in view.
|
||||
*/
|
||||
_calcViewIndexes() {
|
||||
const useRoot = this.props.windowScroller;
|
||||
// funky if statement is to satisfy flow
|
||||
if (!useRoot) {
|
||||
/* istanbul ignore next */
|
||||
if (!this._wrapperElm) {
|
||||
this._viewHeight = -1;
|
||||
this._startIndex = 0;
|
||||
this._endIndex = 0;
|
||||
return;
|
||||
}
|
||||
this._viewHeight = this._wrapperElm.clientHeight;
|
||||
this._scrollTop = this._wrapperElm.scrollTop;
|
||||
} else {
|
||||
this._viewHeight = window.innerHeight - this._htmlTopOffset;
|
||||
this._scrollTop = window.scrollY;
|
||||
}
|
||||
const yStart = this._scrollTop;
|
||||
const yEnd = this._scrollTop + this._viewHeight;
|
||||
this._startIndex = this._yPositions.findFloorIndex(yStart, this._getHeight);
|
||||
this._endIndex = this._yPositions.findFloorIndex(yEnd, this._getHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checked to see if the currently rendered items are sufficient, if not,
|
||||
* force an update to trigger more items to be rendered.
|
||||
*/
|
||||
_positionList = () => {
|
||||
this._isScrolledOrResized = false;
|
||||
if (!this._wrapperElm) {
|
||||
return;
|
||||
}
|
||||
this._calcViewIndexes();
|
||||
// indexes drawn should be padded by at least props.viewBufferMin
|
||||
const maxStart = this.props.viewBufferMin > this._startIndex ? 0 : this._startIndex - this.props.viewBufferMin;
|
||||
const minEnd =
|
||||
this.props.viewBufferMin < this.props.dataLength - this._endIndex
|
||||
? this._endIndex + this.props.viewBufferMin
|
||||
: this.props.dataLength - 1;
|
||||
if (maxStart < this._startIndexDrawn || minEnd > this._endIndexDrawn) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
_initWrapper = (elm: HTMLElement | TNil) => {
|
||||
this._wrapperElm = elm;
|
||||
if (!this.props.windowScroller && elm) {
|
||||
this._viewHeight = elm.clientHeight;
|
||||
}
|
||||
};
|
||||
|
||||
_initItemHolder = (elm: HTMLElement | TNil) => {
|
||||
this._itemHolderElm = elm;
|
||||
this._scanItemHeights();
|
||||
};
|
||||
|
||||
/**
|
||||
* Go through all items that are rendered and save their height based on their
|
||||
* item-key (which is on a data-* attribute). If any new or adjusted heights
|
||||
* are found, re-measure the current known y-positions (via .yPositions).
|
||||
*/
|
||||
_scanItemHeights = () => {
|
||||
const getIndexFromKey = this.props.getIndexFromKey;
|
||||
if (!this._itemHolderElm) {
|
||||
return;
|
||||
}
|
||||
// note the keys for the first and last altered heights, the `yPositions`
|
||||
// needs to be updated
|
||||
let lowDirtyKey = null;
|
||||
let highDirtyKey = null;
|
||||
let isDirty = false;
|
||||
// iterating childNodes is faster than children
|
||||
// https://jsperf.com/large-htmlcollection-vs-large-nodelist
|
||||
const nodes = this._itemHolderElm.childNodes;
|
||||
const max = nodes.length;
|
||||
for (let i = 0; i < max; i++) {
|
||||
const node: HTMLElement = nodes[i] as any;
|
||||
// use `.getAttribute(...)` instead of `.dataset` for jest / JSDOM
|
||||
const itemKey = node.getAttribute('data-item-key');
|
||||
if (!itemKey) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('itemKey not found');
|
||||
continue;
|
||||
}
|
||||
// measure the first child, if it's available, otherwise the node itself
|
||||
// (likely not transferable to other contexts, and instead is specific to
|
||||
// how we have the items rendered)
|
||||
const measureSrc: Element = node.firstElementChild || node;
|
||||
const observed = measureSrc.clientHeight;
|
||||
const known = this._knownHeights.get(itemKey);
|
||||
if (observed !== known) {
|
||||
this._knownHeights.set(itemKey, observed);
|
||||
if (!isDirty) {
|
||||
isDirty = true;
|
||||
// eslint-disable-next-line no-multi-assign
|
||||
lowDirtyKey = highDirtyKey = itemKey;
|
||||
} else {
|
||||
highDirtyKey = itemKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lowDirtyKey != null && highDirtyKey != null) {
|
||||
// update yPositions, then redraw
|
||||
const imin = getIndexFromKey(lowDirtyKey);
|
||||
const imax = highDirtyKey === lowDirtyKey ? imin : getIndexFromKey(highDirtyKey);
|
||||
this._yPositions.calcHeights(imax, this._getHeight, imin);
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the height of the element at index `i`; first check the known heigths,
|
||||
* fallbck to `.props.itemHeightGetter(...)`.
|
||||
*/
|
||||
_getHeight = (i: number) => {
|
||||
const key = this.props.getKeyFromIndex(i);
|
||||
const known = this._knownHeights.get(key);
|
||||
// known !== known iff known is NaN
|
||||
// eslint-disable-next-line no-self-compare
|
||||
if (known != null && known === known) {
|
||||
return known;
|
||||
}
|
||||
return this.props.itemHeightGetter(i, key);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
dataLength,
|
||||
getKeyFromIndex,
|
||||
initialDraw = DEFAULT_INITIAL_DRAW,
|
||||
itemRenderer,
|
||||
viewBuffer,
|
||||
viewBufferMin,
|
||||
} = this.props;
|
||||
const heightGetter = this._getHeight;
|
||||
const items = [];
|
||||
let start;
|
||||
let end;
|
||||
|
||||
this._yPositions.profileData(dataLength);
|
||||
|
||||
if (!this._wrapperElm) {
|
||||
start = 0;
|
||||
end = (initialDraw < dataLength ? initialDraw : dataLength) - 1;
|
||||
} else {
|
||||
if (this._isViewChanged()) {
|
||||
this._calcViewIndexes();
|
||||
}
|
||||
const maxStart = viewBufferMin > this._startIndex ? 0 : this._startIndex - viewBufferMin;
|
||||
const minEnd = viewBufferMin < dataLength - this._endIndex ? this._endIndex + viewBufferMin : dataLength - 1;
|
||||
if (maxStart < this._startIndexDrawn || minEnd > this._endIndexDrawn) {
|
||||
start = viewBuffer > this._startIndex ? 0 : this._startIndex - viewBuffer;
|
||||
end = this._endIndex + viewBuffer;
|
||||
if (end >= dataLength) {
|
||||
end = dataLength - 1;
|
||||
}
|
||||
} else {
|
||||
start = this._startIndexDrawn;
|
||||
end = this._endIndexDrawn > dataLength - 1 ? dataLength - 1 : this._endIndexDrawn;
|
||||
}
|
||||
}
|
||||
|
||||
this._yPositions.calcHeights(end, heightGetter, start || -1);
|
||||
this._startIndexDrawn = start;
|
||||
this._endIndexDrawn = end;
|
||||
|
||||
items.length = end - start + 1;
|
||||
for (let i = start; i <= end; i++) {
|
||||
const { y: top, height } = this._yPositions.getRowPosition(i, heightGetter);
|
||||
const style = {
|
||||
height,
|
||||
top,
|
||||
position: 'absolute',
|
||||
};
|
||||
const itemKey = getKeyFromIndex(i);
|
||||
const attrs = { 'data-item-key': itemKey };
|
||||
items.push(itemRenderer(itemKey, style, i, attrs));
|
||||
}
|
||||
const wrapperProps: TWrapperProps = {
|
||||
style: { position: 'relative' },
|
||||
ref: this._initWrapper,
|
||||
};
|
||||
if (!this.props.windowScroller) {
|
||||
wrapperProps.onScroll = this._onScroll;
|
||||
wrapperProps.style.height = '100%';
|
||||
wrapperProps.style.overflowY = 'auto';
|
||||
}
|
||||
const scrollerStyle = {
|
||||
position: 'relative' as 'relative',
|
||||
height: this._yPositions.getEstimatedHeight(),
|
||||
};
|
||||
return (
|
||||
<div {...wrapperProps}>
|
||||
<div style={scrollerStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
className={this.props.itemsWrapperClassName}
|
||||
ref={this._initItemHolder}
|
||||
>
|
||||
{items}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2019 The Jaeger Authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import ReferencesButton, { getStyles } from './ReferencesButton';
|
||||
import transformTraceData from '../model/transform-trace-data';
|
||||
import traceGenerator from '../demo/trace-generators';
|
||||
import ReferenceLink from '../url/ReferenceLink';
|
||||
import { UIDropdown, UIMenuItem, UITooltip } from '../uiElementsContext';
|
||||
|
||||
describe(ReferencesButton, () => {
|
||||
const trace = transformTraceData(traceGenerator.trace({ numberOfSpans: 10 }));
|
||||
const oneReference = trace.spans[1].references;
|
||||
|
||||
const moreReferences = oneReference.slice();
|
||||
const externalSpanID = 'extSpan';
|
||||
|
||||
moreReferences.push(
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
traceID: trace.traceID,
|
||||
spanID: trace.spans[2].spanID,
|
||||
span: trace.spans[2],
|
||||
},
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
traceID: 'otherTrace',
|
||||
spanID: externalSpanID,
|
||||
}
|
||||
);
|
||||
|
||||
const baseProps = {
|
||||
focusSpan: () => {},
|
||||
};
|
||||
|
||||
it('renders single reference', () => {
|
||||
const props = { ...baseProps, references: oneReference };
|
||||
const wrapper = shallow(<ReferencesButton {...props} />);
|
||||
const dropdown = wrapper.find(UIDropdown);
|
||||
const refLink = wrapper.find(ReferenceLink);
|
||||
const tooltip = wrapper.find(UITooltip);
|
||||
const styles = getStyles();
|
||||
|
||||
expect(dropdown.length).toBe(0);
|
||||
expect(refLink.length).toBe(1);
|
||||
expect(refLink.prop('reference')).toBe(oneReference[0]);
|
||||
expect(refLink.first().props().className).toBe(styles.MultiParent);
|
||||
expect(tooltip.length).toBe(1);
|
||||
expect(tooltip.prop('title')).toBe(props.tooltipText);
|
||||
});
|
||||
|
||||
it('renders multiple references', () => {
|
||||
const props = { ...baseProps, references: moreReferences };
|
||||
const wrapper = shallow(<ReferencesButton {...props} />);
|
||||
const dropdown = wrapper.find(UIDropdown);
|
||||
expect(dropdown.length).toBe(1);
|
||||
// We have some wrappers here that dynamically inject specific component so we need to traverse a bit
|
||||
// here
|
||||
const menuInstance = shallow(
|
||||
shallow(dropdown.first().props().overlay).prop('children')({
|
||||
// eslint-disable-next-line react/prop-types
|
||||
Menu: ({ children }) => <div>{children}</div>,
|
||||
})
|
||||
);
|
||||
const submenuItems = menuInstance.find(UIMenuItem);
|
||||
expect(submenuItems.length).toBe(3);
|
||||
submenuItems.forEach((submenuItem, i) => {
|
||||
expect(submenuItem.find(ReferenceLink).prop('reference')).toBe(moreReferences[i]);
|
||||
});
|
||||
expect(
|
||||
submenuItems
|
||||
.at(2)
|
||||
.find(ReferenceLink)
|
||||
.childAt(0)
|
||||
.text()
|
||||
).toBe(`(another trace) - ${moreReferences[2].spanID}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2019 The Jaeger Authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import NewWindowIcon from '../common/NewWindowIcon';
|
||||
import { SpanReference } from '../types/trace';
|
||||
import { UITooltip, UIDropdown, UIMenuItem, UIMenu, TooltipPlacement } from '../uiElementsContext';
|
||||
|
||||
import ReferenceLink from '../url/ReferenceLink';
|
||||
import { createStyle } from '../Theme';
|
||||
|
||||
export const getStyles = createStyle(() => {
|
||||
return {
|
||||
MultiParent: css`
|
||||
padding: 0 5px;
|
||||
color: #000;
|
||||
& ~ & {
|
||||
margin-left: 5px;
|
||||
}
|
||||
`,
|
||||
TraceRefLink: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`,
|
||||
NewWindowIcon: css`
|
||||
margin: 0.2em 0 0;
|
||||
`,
|
||||
tooltip: css`
|
||||
max-width: none;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type TReferencesButtonProps = {
|
||||
references: SpanReference[];
|
||||
children: React.ReactNode;
|
||||
tooltipText: string;
|
||||
focusSpan: (spanID: string) => void;
|
||||
};
|
||||
|
||||
export default class ReferencesButton extends React.PureComponent<TReferencesButtonProps> {
|
||||
referencesList = (references: SpanReference[]) => {
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<UIMenu>
|
||||
{references.map(ref => {
|
||||
const { span, spanID } = ref;
|
||||
return (
|
||||
<UIMenuItem key={`${spanID}`}>
|
||||
<ReferenceLink reference={ref} focusSpan={this.props.focusSpan} className={styles.TraceRefLink}>
|
||||
{span
|
||||
? `${span.process.serviceName}:${span.operationName} - ${ref.spanID}`
|
||||
: `(another trace) - ${ref.spanID}`}
|
||||
{!span && <NewWindowIcon className={styles.NewWindowIcon} />}
|
||||
</ReferenceLink>
|
||||
</UIMenuItem>
|
||||
);
|
||||
})}
|
||||
</UIMenu>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { references, children, tooltipText, focusSpan } = this.props;
|
||||
const styles = getStyles();
|
||||
|
||||
const tooltipProps = {
|
||||
arrowPointAtCenter: true,
|
||||
mouseLeaveDelay: 0.5,
|
||||
placement: 'bottom' as TooltipPlacement,
|
||||
title: tooltipText,
|
||||
overlayClassName: styles.tooltip,
|
||||
};
|
||||
|
||||
if (references.length > 1) {
|
||||
return (
|
||||
<UITooltip {...tooltipProps}>
|
||||
<UIDropdown overlay={this.referencesList(references)} placement="bottomRight" trigger={['click']}>
|
||||
<a className={styles.MultiParent}>{children}</a>
|
||||
</UIDropdown>
|
||||
</UITooltip>
|
||||
);
|
||||
}
|
||||
const ref = references[0];
|
||||
return (
|
||||
<UITooltip {...tooltipProps}>
|
||||
<ReferenceLink reference={ref} focusSpan={focusSpan} className={styles.MultiParent}>
|
||||
{children}
|
||||
</ReferenceLink>
|
||||
</UITooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import UIElementsContext, { UIPopover } from '../uiElementsContext';
|
||||
|
||||
import SpanBar from './SpanBar';
|
||||
|
||||
describe('<SpanBar>', () => {
|
||||
const shortLabel = 'omg-so-awesome';
|
||||
const longLabel = 'omg-awesome-long-label';
|
||||
|
||||
const props = {
|
||||
longLabel,
|
||||
shortLabel,
|
||||
color: '#fff',
|
||||
hintSide: 'right',
|
||||
viewEnd: 1,
|
||||
viewStart: 0,
|
||||
getViewedBounds: s => {
|
||||
// Log entries
|
||||
if (s === 10) {
|
||||
return { start: 0.1, end: 0.1 };
|
||||
}
|
||||
if (s === 20) {
|
||||
return { start: 0.2, end: 0.2 };
|
||||
}
|
||||
return { error: 'error' };
|
||||
},
|
||||
rpc: {
|
||||
viewStart: 0.25,
|
||||
viewEnd: 0.75,
|
||||
color: '#000',
|
||||
},
|
||||
tracestartTime: 0,
|
||||
span: {
|
||||
logs: [
|
||||
{
|
||||
timestamp: 10,
|
||||
fields: [{ key: 'message', value: 'oh the log message' }, { key: 'something', value: 'else' }],
|
||||
},
|
||||
{
|
||||
timestamp: 10,
|
||||
fields: [
|
||||
{ key: 'message', value: 'oh the second log message' },
|
||||
{ key: 'something', value: 'different' },
|
||||
],
|
||||
},
|
||||
{
|
||||
timestamp: 20,
|
||||
fields: [{ key: 'message', value: 'oh the next log message' }, { key: 'more', value: 'stuff' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('renders without exploding', () => {
|
||||
const wrapper = mount(
|
||||
<UIElementsContext.Provider value={{ Popover: () => '' }}>
|
||||
<SpanBar {...props} />
|
||||
</UIElementsContext.Provider>
|
||||
);
|
||||
expect(wrapper).toBeDefined();
|
||||
const { onMouseOver, onMouseOut } = wrapper.find('[data-test-id="SpanBar--wrapper"]').props();
|
||||
const labelElm = wrapper.find('[data-test-id="SpanBar--label"]');
|
||||
expect(labelElm.text()).toBe(shortLabel);
|
||||
onMouseOver();
|
||||
expect(labelElm.text()).toBe(longLabel);
|
||||
onMouseOut();
|
||||
expect(labelElm.text()).toBe(shortLabel);
|
||||
});
|
||||
|
||||
it('log markers count', () => {
|
||||
// 3 log entries, two grouped together with the same timestamp
|
||||
const wrapper = mount(
|
||||
<UIElementsContext.Provider value={{ Popover: () => '' }}>
|
||||
<SpanBar {...props} />
|
||||
</UIElementsContext.Provider>
|
||||
);
|
||||
expect(wrapper.find(UIPopover).length).toEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import _groupBy from 'lodash/groupBy';
|
||||
import { onlyUpdateForKeys, compose, withState, withProps } from 'recompose';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import AccordianLogs from './SpanDetail/AccordianLogs';
|
||||
|
||||
import { ViewedBoundsFunctionType } from './utils';
|
||||
import { TNil } from '../types';
|
||||
import { Span } from '../types/trace';
|
||||
import { UIPopover } from '../uiElementsContext';
|
||||
import { createStyle } from '../Theme';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
wrapper: css`
|
||||
label: wrapper;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
`,
|
||||
bar: css`
|
||||
label: bar;
|
||||
border-radius: 3px;
|
||||
min-width: 2px;
|
||||
position: absolute;
|
||||
height: 36%;
|
||||
top: 32%;
|
||||
`,
|
||||
rpc: css`
|
||||
label: rpc;
|
||||
position: absolute;
|
||||
top: 35%;
|
||||
bottom: 35%;
|
||||
z-index: 1;
|
||||
`,
|
||||
label: css`
|
||||
label: label;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
line-height: 1em;
|
||||
white-space: nowrap;
|
||||
padding: 0 0.5em;
|
||||
position: absolute;
|
||||
`,
|
||||
logMarker: css`
|
||||
label: logMarker;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
height: 60%;
|
||||
min-width: 1px;
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
&:hover {
|
||||
background-color: #000;
|
||||
}
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
&::after {
|
||||
left: 0;
|
||||
}
|
||||
`,
|
||||
logHint: css`
|
||||
label: logHint;
|
||||
pointer-events: none;
|
||||
// TODO won't work with different UI elements injected
|
||||
& .ant-popover-inner-content {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type TCommonProps = {
|
||||
color: string;
|
||||
// onClick: (evt: React.MouseEvent<any>) => void;
|
||||
onClick?: (evt: React.MouseEvent<any>) => void;
|
||||
viewEnd: number;
|
||||
viewStart: number;
|
||||
getViewedBounds: ViewedBoundsFunctionType;
|
||||
rpc:
|
||||
| {
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
color: string;
|
||||
}
|
||||
| TNil;
|
||||
traceStartTime: number;
|
||||
span: Span;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
};
|
||||
|
||||
type TInnerProps = {
|
||||
label: string;
|
||||
setLongLabel: () => void;
|
||||
setShortLabel: () => void;
|
||||
} & TCommonProps;
|
||||
|
||||
type TOuterProps = {
|
||||
longLabel: string;
|
||||
shortLabel: string;
|
||||
} & TCommonProps;
|
||||
|
||||
function toPercent(value: number) {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function SpanBar(props: TInnerProps) {
|
||||
const {
|
||||
viewEnd,
|
||||
viewStart,
|
||||
getViewedBounds,
|
||||
color,
|
||||
label,
|
||||
onClick,
|
||||
setLongLabel,
|
||||
setShortLabel,
|
||||
rpc,
|
||||
traceStartTime,
|
||||
span,
|
||||
className,
|
||||
labelClassName,
|
||||
} = props;
|
||||
// group logs based on timestamps
|
||||
const logGroups = _groupBy(span.logs, log => {
|
||||
const posPercent = getViewedBounds(log.timestamp, log.timestamp).start;
|
||||
// round to the nearest 0.2%
|
||||
return toPercent(Math.round(posPercent * 500) / 500);
|
||||
});
|
||||
const styles = getStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.wrapper, className)}
|
||||
onClick={onClick}
|
||||
onMouseOut={setShortLabel}
|
||||
onMouseOver={setLongLabel}
|
||||
aria-hidden
|
||||
data-test-id="SpanBar--wrapper"
|
||||
>
|
||||
<div
|
||||
aria-label={label}
|
||||
className={styles.bar}
|
||||
style={{
|
||||
background: color,
|
||||
left: toPercent(viewStart),
|
||||
width: toPercent(viewEnd - viewStart),
|
||||
}}
|
||||
>
|
||||
<div className={cx(styles.label, labelClassName)} data-test-id="SpanBar--label">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{Object.keys(logGroups).map(positionKey => (
|
||||
<UIPopover
|
||||
key={positionKey}
|
||||
arrowPointAtCenter
|
||||
overlayClassName={styles.logHint}
|
||||
placement="topLeft"
|
||||
content={
|
||||
<AccordianLogs interactive={false} isOpen logs={logGroups[positionKey]} timestamp={traceStartTime} />
|
||||
}
|
||||
>
|
||||
<div className={styles.logMarker} style={{ left: positionKey }} />
|
||||
</UIPopover>
|
||||
))}
|
||||
</div>
|
||||
{rpc && (
|
||||
<div
|
||||
className={styles.rpc}
|
||||
style={{
|
||||
background: rpc.color,
|
||||
left: toPercent(rpc.viewStart),
|
||||
width: toPercent(rpc.viewEnd - rpc.viewStart),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default compose<TInnerProps, TOuterProps>(
|
||||
withState('label', 'setLabel', (props: { shortLabel: string }) => props.shortLabel),
|
||||
withProps(
|
||||
({
|
||||
setLabel,
|
||||
shortLabel,
|
||||
longLabel,
|
||||
}: {
|
||||
setLabel: (label: string) => void;
|
||||
shortLabel: string;
|
||||
longLabel: string;
|
||||
}) => ({
|
||||
setLongLabel: () => setLabel(longLabel),
|
||||
setShortLabel: () => setLabel(shortLabel),
|
||||
})
|
||||
),
|
||||
onlyUpdateForKeys(['label', 'rpc', 'viewStart', 'viewEnd'])
|
||||
)(SpanBar);
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
|
||||
import SpanBarRow from './SpanBarRow';
|
||||
import SpanTreeOffset from './SpanTreeOffset';
|
||||
import ReferencesButton from './ReferencesButton';
|
||||
|
||||
jest.mock('./SpanTreeOffset');
|
||||
|
||||
describe('<SpanBarRow>', () => {
|
||||
const spanID = 'some-id';
|
||||
const props = {
|
||||
className: 'a-class-name',
|
||||
color: 'color-a',
|
||||
columnDivision: '0.5',
|
||||
isChildrenExpanded: true,
|
||||
isDetailExpanded: false,
|
||||
isFilteredOut: false,
|
||||
onDetailToggled: jest.fn(),
|
||||
onChildrenToggled: jest.fn(),
|
||||
operationName: 'op-name',
|
||||
numTicks: 5,
|
||||
rpc: {
|
||||
viewStart: 0.25,
|
||||
viewEnd: 0.75,
|
||||
color: 'color-b',
|
||||
operationName: 'rpc-op-name',
|
||||
serviceName: 'rpc-service-name',
|
||||
},
|
||||
showErrorIcon: false,
|
||||
getViewedBounds: () => ({ start: 0, end: 1 }),
|
||||
span: {
|
||||
duration: 'test-duration',
|
||||
hasChildren: true,
|
||||
process: {
|
||||
serviceName: 'service-name',
|
||||
},
|
||||
spanID,
|
||||
logs: [],
|
||||
},
|
||||
};
|
||||
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
props.onDetailToggled.mockReset();
|
||||
props.onChildrenToggled.mockReset();
|
||||
wrapper = mount(<SpanBarRow {...props} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('escalates detail toggling', () => {
|
||||
const { onDetailToggled } = props;
|
||||
expect(onDetailToggled.mock.calls.length).toBe(0);
|
||||
wrapper.find('div[data-test-id="span-view"]').prop('onClick')();
|
||||
expect(onDetailToggled.mock.calls).toEqual([[spanID]]);
|
||||
});
|
||||
|
||||
it('escalates children toggling', () => {
|
||||
const { onChildrenToggled } = props;
|
||||
expect(onChildrenToggled.mock.calls.length).toBe(0);
|
||||
wrapper.find(SpanTreeOffset).prop('onClick')();
|
||||
expect(onChildrenToggled.mock.calls).toEqual([[spanID]]);
|
||||
});
|
||||
|
||||
it('render references button', () => {
|
||||
const span = Object.assign(
|
||||
{
|
||||
references: [
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
traceID: 'trace1',
|
||||
spanID: 'span0',
|
||||
span: {
|
||||
spanID: 'span0',
|
||||
},
|
||||
},
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
traceID: 'otherTrace',
|
||||
spanID: 'span1',
|
||||
span: {
|
||||
spanID: 'span1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
props.span
|
||||
);
|
||||
|
||||
const spanRow = shallow(<SpanBarRow {...props} span={span} />);
|
||||
const refButton = spanRow.find(ReferencesButton);
|
||||
expect(refButton.length).toEqual(1);
|
||||
expect(refButton.at(0).props().tooltipText).toEqual('Contains multiple references');
|
||||
});
|
||||
|
||||
it('render referenced to by single span', () => {
|
||||
const span = Object.assign(
|
||||
{
|
||||
subsidiarilyReferencedBy: [
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
traceID: 'trace1',
|
||||
spanID: 'span0',
|
||||
span: {
|
||||
spanID: 'span0',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
props.span
|
||||
);
|
||||
const spanRow = shallow(<SpanBarRow {...props} span={span} />);
|
||||
const refButton = spanRow.find(ReferencesButton);
|
||||
expect(refButton.length).toEqual(1);
|
||||
expect(refButton.at(0).props().tooltipText).toEqual('This span is referenced by another span');
|
||||
});
|
||||
|
||||
it('render referenced to by multiple span', () => {
|
||||
const span = Object.assign(
|
||||
{
|
||||
subsidiarilyReferencedBy: [
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
traceID: 'trace1',
|
||||
spanID: 'span0',
|
||||
span: {
|
||||
spanID: 'span0',
|
||||
},
|
||||
},
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
traceID: 'trace1',
|
||||
spanID: 'span1',
|
||||
span: {
|
||||
spanID: 'span1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
props.span
|
||||
);
|
||||
const spanRow = shallow(<SpanBarRow {...props} span={span} />);
|
||||
const refButton = spanRow.find(ReferencesButton);
|
||||
expect(refButton.length).toEqual(1);
|
||||
expect(refButton.at(0).props().tooltipText).toEqual('This span is referenced by multiple other spans');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import IoAlert from 'react-icons/lib/io/alert';
|
||||
import IoArrowRightA from 'react-icons/lib/io/arrow-right-a';
|
||||
import IoNetwork from 'react-icons/lib/io/network';
|
||||
import MdFileUpload from 'react-icons/lib/md/file-upload';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import ReferencesButton from './ReferencesButton';
|
||||
import TimelineRow from './TimelineRow';
|
||||
import { formatDuration, ViewedBoundsFunctionType } from './utils';
|
||||
import SpanTreeOffset from './SpanTreeOffset';
|
||||
import SpanBar from './SpanBar';
|
||||
import Ticks from './Ticks';
|
||||
|
||||
import { TNil } from '../types';
|
||||
import { Span } from '../types/trace';
|
||||
import { createStyle } from '../Theme';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
const spanBar = css`
|
||||
label: spanBar;
|
||||
`;
|
||||
const spanBarLabel = css`
|
||||
label: spanBarLabel;
|
||||
`;
|
||||
const nameWrapper = css`
|
||||
label: nameWrapper;
|
||||
background: #f8f8f8;
|
||||
line-height: 27px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
&:hover {
|
||||
border-right: 1px solid #bbb;
|
||||
float: left;
|
||||
min-width: calc(100% + 1px);
|
||||
overflow: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
const nameWrapperMatchingFilter = css`
|
||||
label: nameWrapperMatchingFilter;
|
||||
background-color: #fffce4;
|
||||
`;
|
||||
|
||||
const endpointName = css`
|
||||
label: endpointName;
|
||||
color: #808080;
|
||||
`;
|
||||
|
||||
const view = css`
|
||||
label: view;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const viewExpanded = css`
|
||||
label: viewExpanded;
|
||||
background: #f8f8f8;
|
||||
outline: 1px solid #ddd;
|
||||
`;
|
||||
|
||||
const viewExpandedAndMatchingFilter = css`
|
||||
label: viewExpandedAndMatchingFilter;
|
||||
background: #fff3d7;
|
||||
outline: 1px solid #ddd;
|
||||
`;
|
||||
|
||||
const nameColumn = css`
|
||||
label: nameColumn;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
&:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
return {
|
||||
spanBar,
|
||||
spanBarLabel,
|
||||
nameWrapper,
|
||||
nameWrapperMatchingFilter,
|
||||
nameColumn,
|
||||
endpointName,
|
||||
view,
|
||||
viewExpanded,
|
||||
viewExpandedAndMatchingFilter,
|
||||
row: css`
|
||||
label: row;
|
||||
&:hover .${spanBar} {
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover .${spanBarLabel} {
|
||||
color: #000;
|
||||
}
|
||||
&:hover .${nameWrapper} {
|
||||
background: #f8f8f8;
|
||||
background: linear-gradient(90deg, #fafafa, #f8f8f8 75%, #eee);
|
||||
}
|
||||
&:hover .${view} {
|
||||
background-color: #f5f5f5;
|
||||
outline: 1px solid #ddd;
|
||||
}
|
||||
`,
|
||||
rowClippingLeft: css`
|
||||
label: rowClippingLeft;
|
||||
& .${nameColumn}::before {
|
||||
content: ' ';
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
background-image: linear-gradient(to right, rgba(25, 25, 25, 0.25), rgba(32, 32, 32, 0));
|
||||
left: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
`,
|
||||
rowClippingRight: css`
|
||||
label: rowClippingRight;
|
||||
& .${view}::before {
|
||||
content: ' ';
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
background-image: linear-gradient(to left, rgba(25, 25, 25, 0.25), rgba(32, 32, 32, 0));
|
||||
right: 0%;
|
||||
z-index: 1;
|
||||
}
|
||||
`,
|
||||
rowExpanded: css`
|
||||
label: rowExpanded;
|
||||
& .${spanBar} {
|
||||
opacity: 1;
|
||||
}
|
||||
& .${spanBarLabel} {
|
||||
color: #000;
|
||||
}
|
||||
& .${nameWrapper}, &:hover .${nameWrapper} {
|
||||
background: #f0f0f0;
|
||||
box-shadow: 0 1px 0 #ddd;
|
||||
}
|
||||
& .${nameWrapperMatchingFilter} {
|
||||
background: #fff3d7;
|
||||
}
|
||||
&:hover .${view} {
|
||||
background: #eee;
|
||||
}
|
||||
`,
|
||||
rowMatchingFilter: css`
|
||||
label: rowMatchingFilter;
|
||||
background-color: #fffce4;
|
||||
&:hover .${nameWrapper} {
|
||||
background: linear-gradient(90deg, #fff5e1, #fff5e1 75%, #ffe6c9);
|
||||
}
|
||||
&:hover .${view} {
|
||||
background-color: #fff3d7;
|
||||
outline: 1px solid #ddd;
|
||||
}
|
||||
`,
|
||||
|
||||
rowExpandedAndMatchingFilter: css`
|
||||
label: rowExpandedAndMatchingFilter;
|
||||
&:hover .${view} {
|
||||
background: #ffeccf;
|
||||
}
|
||||
`,
|
||||
|
||||
name: css`
|
||||
label: name;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
flex: 1 1 auto;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
padding-left: 4px;
|
||||
padding-right: 0.25em;
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
&::before {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 0;
|
||||
border-left: 4px solid;
|
||||
border-left-color: inherit;
|
||||
}
|
||||
|
||||
/* This is so the hit area of the span-name extends the rest of the width of the span-name column */
|
||||
&::after {
|
||||
background: transparent;
|
||||
bottom: 0;
|
||||
content: ' ';
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1000px;
|
||||
}
|
||||
&:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
&:hover > .${endpointName} {
|
||||
color: #000;
|
||||
}
|
||||
`,
|
||||
nameDetailExpanded: css`
|
||||
label: nameDetailExpanded;
|
||||
&::before {
|
||||
bottom: 0;
|
||||
}
|
||||
`,
|
||||
svcName: css`
|
||||
label: svcName;
|
||||
padding: 0 0.25rem 0 0.5rem;
|
||||
font-size: 1.05em;
|
||||
`,
|
||||
svcNameChildrenCollapsed: css`
|
||||
label: svcNameChildrenCollapsed;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
`,
|
||||
errorIcon: css`
|
||||
label: errorIcon;
|
||||
background: #db2828;
|
||||
border-radius: 6.5px;
|
||||
color: #fff;
|
||||
font-size: 0.85em;
|
||||
margin-right: 0.25rem;
|
||||
padding: 1px;
|
||||
`,
|
||||
rpcColorMarker: css`
|
||||
label: rpcColorMarker;
|
||||
border-radius: 6.5px;
|
||||
display: inline-block;
|
||||
font-size: 0.85em;
|
||||
height: 1em;
|
||||
margin-right: 0.25rem;
|
||||
padding: 1px;
|
||||
width: 1em;
|
||||
vertical-align: middle;
|
||||
`,
|
||||
labelRight: css`
|
||||
label: labelRight;
|
||||
left: 100%;
|
||||
`,
|
||||
labelLeft: css`
|
||||
label: labelLeft;
|
||||
right: 100%;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type SpanBarRowProps = {
|
||||
className?: string;
|
||||
color: string;
|
||||
columnDivision: number;
|
||||
isChildrenExpanded: boolean;
|
||||
isDetailExpanded: boolean;
|
||||
isMatchingFilter: boolean;
|
||||
onDetailToggled: (spanID: string) => void;
|
||||
onChildrenToggled: (spanID: string) => void;
|
||||
numTicks: number;
|
||||
rpc?:
|
||||
| {
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
color: string;
|
||||
operationName: string;
|
||||
serviceName: string;
|
||||
}
|
||||
| TNil;
|
||||
showErrorIcon: boolean;
|
||||
getViewedBounds: ViewedBoundsFunctionType;
|
||||
traceStartTime: number;
|
||||
span: Span;
|
||||
focusSpan: (spanID: string) => void;
|
||||
hoverIndentGuideIds: Set<string>;
|
||||
addHoverIndentGuideId: (spanID: string) => void;
|
||||
removeHoverIndentGuideId: (spanID: string) => void;
|
||||
clippingLeft?: boolean;
|
||||
clippingRight?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* This was originally a stateless function, but changing to a PureComponent
|
||||
* reduced the render time of expanding a span row detail by ~50%. This is
|
||||
* even true in the case where the stateless function has the same prop types as
|
||||
* this class and arrow functions are created in the stateless function as
|
||||
* handlers to the onClick props. E.g. for now, the PureComponent is more
|
||||
* performance than the stateless function.
|
||||
*/
|
||||
export default class SpanBarRow extends React.PureComponent<SpanBarRowProps> {
|
||||
static defaultProps: Partial<SpanBarRowProps> = {
|
||||
className: '',
|
||||
rpc: null,
|
||||
};
|
||||
|
||||
_detailToggle = () => {
|
||||
this.props.onDetailToggled(this.props.span.spanID);
|
||||
};
|
||||
|
||||
_childrenToggle = () => {
|
||||
this.props.onChildrenToggled(this.props.span.spanID);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
color,
|
||||
columnDivision,
|
||||
isChildrenExpanded,
|
||||
isDetailExpanded,
|
||||
isMatchingFilter,
|
||||
numTicks,
|
||||
rpc,
|
||||
showErrorIcon,
|
||||
getViewedBounds,
|
||||
traceStartTime,
|
||||
span,
|
||||
focusSpan,
|
||||
hoverIndentGuideIds,
|
||||
addHoverIndentGuideId,
|
||||
removeHoverIndentGuideId,
|
||||
clippingLeft,
|
||||
clippingRight,
|
||||
} = this.props;
|
||||
const {
|
||||
duration,
|
||||
hasChildren: isParent,
|
||||
operationName,
|
||||
process: { serviceName },
|
||||
} = span;
|
||||
const label = formatDuration(duration);
|
||||
const viewBounds = getViewedBounds(span.startTime, span.startTime + span.duration);
|
||||
const viewStart = viewBounds.start;
|
||||
const viewEnd = viewBounds.end;
|
||||
const styles = getStyles();
|
||||
|
||||
const labelDetail = `${serviceName}::${operationName}`;
|
||||
let longLabel;
|
||||
let hintClassName;
|
||||
if (viewStart > 1 - viewEnd) {
|
||||
longLabel = `${labelDetail} | ${label}`;
|
||||
hintClassName = styles.labelLeft;
|
||||
} else {
|
||||
longLabel = `${label} | ${labelDetail}`;
|
||||
hintClassName = styles.labelRight;
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineRow
|
||||
className={cx(
|
||||
styles.row,
|
||||
{
|
||||
[styles.rowExpanded]: isDetailExpanded,
|
||||
[styles.rowMatchingFilter]: isMatchingFilter,
|
||||
[styles.rowExpandedAndMatchingFilter]: isMatchingFilter && isDetailExpanded,
|
||||
[styles.rowClippingLeft]: clippingLeft,
|
||||
[styles.rowClippingRight]: clippingRight,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<TimelineRow.Cell className={styles.nameColumn} width={columnDivision}>
|
||||
<div className={cx(styles.nameWrapper, { [styles.nameWrapperMatchingFilter]: isMatchingFilter })}>
|
||||
<SpanTreeOffset
|
||||
childrenVisible={isChildrenExpanded}
|
||||
span={span}
|
||||
onClick={isParent ? this._childrenToggle : undefined}
|
||||
hoverIndentGuideIds={hoverIndentGuideIds}
|
||||
addHoverIndentGuideId={addHoverIndentGuideId}
|
||||
removeHoverIndentGuideId={removeHoverIndentGuideId}
|
||||
/>
|
||||
<a
|
||||
className={cx(styles.name, { [styles.nameDetailExpanded]: isDetailExpanded })}
|
||||
aria-checked={isDetailExpanded}
|
||||
onClick={this._detailToggle}
|
||||
role="switch"
|
||||
style={{ borderColor: color }}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span
|
||||
className={cx(styles.svcName, {
|
||||
[styles.svcNameChildrenCollapsed]: isParent && !isChildrenExpanded,
|
||||
})}
|
||||
>
|
||||
{showErrorIcon && <IoAlert className={styles.errorIcon} />}
|
||||
{serviceName}{' '}
|
||||
{rpc && (
|
||||
<span>
|
||||
<IoArrowRightA /> <i className={styles.rpcColorMarker} style={{ background: rpc.color }} />
|
||||
{rpc.serviceName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<small className={styles.endpointName}>{rpc ? rpc.operationName : operationName}</small>
|
||||
</a>
|
||||
{span.references && span.references.length > 1 && (
|
||||
<ReferencesButton
|
||||
references={span.references}
|
||||
tooltipText="Contains multiple references"
|
||||
focusSpan={focusSpan}
|
||||
>
|
||||
<IoNetwork />
|
||||
</ReferencesButton>
|
||||
)}
|
||||
{span.subsidiarilyReferencedBy && span.subsidiarilyReferencedBy.length > 0 && (
|
||||
<ReferencesButton
|
||||
references={span.subsidiarilyReferencedBy}
|
||||
tooltipText={`This span is referenced by ${
|
||||
span.subsidiarilyReferencedBy.length === 1 ? 'another span' : 'multiple other spans'
|
||||
}`}
|
||||
focusSpan={focusSpan}
|
||||
>
|
||||
<MdFileUpload />
|
||||
</ReferencesButton>
|
||||
)}
|
||||
</div>
|
||||
</TimelineRow.Cell>
|
||||
<TimelineRow.Cell
|
||||
className={cx(styles.view, {
|
||||
[styles.viewExpanded]: isDetailExpanded,
|
||||
[styles.viewExpandedAndMatchingFilter]: isMatchingFilter && isDetailExpanded,
|
||||
})}
|
||||
data-test-id="span-view"
|
||||
style={{ cursor: 'pointer' }}
|
||||
width={1 - columnDivision}
|
||||
onClick={this._detailToggle}
|
||||
>
|
||||
<Ticks numTicks={numTicks} />
|
||||
<SpanBar
|
||||
rpc={rpc}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
getViewedBounds={getViewedBounds}
|
||||
color={color}
|
||||
shortLabel={label}
|
||||
longLabel={longLabel}
|
||||
traceStartTime={traceStartTime}
|
||||
span={span}
|
||||
labelClassName={`${styles.spanBarLabel} ${hintClassName}`}
|
||||
className={styles.spanBar}
|
||||
/>
|
||||
</TimelineRow.Cell>
|
||||
</TimelineRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export const LABEL = 'label';
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import AccordianKeyValues, { KeyValuesSummary } from './AccordianKeyValues';
|
||||
import * as markers from './AccordianKeyValues.markers';
|
||||
import KeyValuesTable from './KeyValuesTable';
|
||||
|
||||
const tags = [{ key: 'span.kind', value: 'client' }, { key: 'omg', value: 'mos-def' }];
|
||||
|
||||
describe('<KeyValuesSummary>', () => {
|
||||
let wrapper;
|
||||
|
||||
const props = { data: tags };
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<KeyValuesSummary {...props} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns `null` when props.data is empty', () => {
|
||||
wrapper.setProps({ data: null });
|
||||
expect(wrapper.isEmptyRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('generates a list from `data`', () => {
|
||||
expect(wrapper.find('li').length).toBe(tags.length);
|
||||
});
|
||||
|
||||
it('renders the data as text', () => {
|
||||
const texts = wrapper.find('li').map(node => node.text());
|
||||
const expectedTexts = tags.map(tag => `${tag.key}=${tag.value}`);
|
||||
expect(texts).toEqual(expectedTexts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<AccordianKeyValues>', () => {
|
||||
let wrapper;
|
||||
|
||||
const props = {
|
||||
compact: false,
|
||||
data: tags,
|
||||
highContrast: false,
|
||||
isOpen: false,
|
||||
label: 'le-label',
|
||||
onToggle: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<AccordianKeyValues {...props} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the label', () => {
|
||||
const header = wrapper.find(`[data-test="${markers.LABEL}"]`);
|
||||
expect(header.length).toBe(1);
|
||||
expect(header.text()).toBe(`${props.label}:`);
|
||||
});
|
||||
|
||||
it('renders the summary instead of the table when it is not expanded', () => {
|
||||
const summary = wrapper.find('[data-test-id="AccordianKeyValues--header"]').find(KeyValuesSummary);
|
||||
expect(summary.length).toBe(1);
|
||||
expect(summary.prop('data')).toBe(tags);
|
||||
expect(wrapper.find(KeyValuesTable).length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders the table instead of the summarywhen it is expanded', () => {
|
||||
wrapper.setProps({ isOpen: true });
|
||||
expect(wrapper.find(KeyValuesSummary).length).toBe(0);
|
||||
const table = wrapper.find(KeyValuesTable);
|
||||
expect(table.length).toBe(1);
|
||||
expect(table.prop('data')).toBe(tags);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
|
||||
import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import * as markers from './AccordianKeyValues.markers';
|
||||
import KeyValuesTable from './KeyValuesTable';
|
||||
import { TNil } from '../../types';
|
||||
import { KeyValuePair, Link } from '../../types/trace';
|
||||
import { createStyle } from '../../Theme';
|
||||
import { uAlignIcon, uTxEllipsis } from '../../uberUtilityStyles';
|
||||
|
||||
export const getStyles = createStyle(() => {
|
||||
return {
|
||||
header: css`
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
padding: 0.25em 0.1em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
`,
|
||||
headerEmpty: css`
|
||||
background: none;
|
||||
cursor: initial;
|
||||
`,
|
||||
headerHighContrast: css`
|
||||
&:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
`,
|
||||
emptyIcon: css`
|
||||
color: #aaa;
|
||||
`,
|
||||
summary: css`
|
||||
display: inline;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
`,
|
||||
summaryItem: css`
|
||||
display: inline;
|
||||
margin-left: 0.7em;
|
||||
padding-right: 0.5rem;
|
||||
border-right: 1px solid #ddd;
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
border-right: none;
|
||||
}
|
||||
`,
|
||||
summaryLabel: css`
|
||||
color: #777;
|
||||
`,
|
||||
summaryDelim: css`
|
||||
color: #bbb;
|
||||
padding: 0 0.2em;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type AccordianKeyValuesProps = {
|
||||
className?: string | TNil;
|
||||
data: KeyValuePair[];
|
||||
highContrast?: boolean;
|
||||
interactive?: boolean;
|
||||
isOpen: boolean;
|
||||
label: string;
|
||||
linksGetter: ((pairs: KeyValuePair[], index: number) => Link[]) | TNil;
|
||||
onToggle?: null | (() => void);
|
||||
};
|
||||
|
||||
// export for tests
|
||||
export function KeyValuesSummary(props: { data?: KeyValuePair[] }) {
|
||||
const { data } = props;
|
||||
if (!Array.isArray(data) || !data.length) {
|
||||
return null;
|
||||
}
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<ul className={styles.summary}>
|
||||
{data.map((item, i) => (
|
||||
// `i` is necessary in the key because item.key can repeat
|
||||
<li className={styles.summaryItem} key={`${item.key}-${i}`}>
|
||||
<span className={styles.summaryLabel}>{item.key}</span>
|
||||
<span className={styles.summaryDelim}>=</span>
|
||||
{String(item.value)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
KeyValuesSummary.defaultProps = {
|
||||
data: null,
|
||||
};
|
||||
|
||||
export default function AccordianKeyValues(props: AccordianKeyValuesProps) {
|
||||
const { className, data, highContrast, interactive, isOpen, label, linksGetter, onToggle } = props;
|
||||
const isEmpty = !Array.isArray(data) || !data.length;
|
||||
const styles = getStyles();
|
||||
const iconCls = cx(uAlignIcon, { [styles.emptyIcon]: isEmpty });
|
||||
let arrow: React.ReactNode | null = null;
|
||||
let headerProps: {} | null = null;
|
||||
if (interactive) {
|
||||
arrow = isOpen ? <IoIosArrowDown className={iconCls} /> : <IoIosArrowRight className={iconCls} />;
|
||||
headerProps = {
|
||||
'aria-checked': isOpen,
|
||||
onClick: isEmpty ? null : onToggle,
|
||||
role: 'switch',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(className, uTxEllipsis)}>
|
||||
<div
|
||||
className={cx(styles.header, {
|
||||
[styles.headerEmpty]: isEmpty,
|
||||
[styles.headerHighContrast]: highContrast && !isEmpty,
|
||||
})}
|
||||
{...headerProps}
|
||||
data-test-id="AccordianKeyValues--header"
|
||||
>
|
||||
{arrow}
|
||||
<strong data-test={markers.LABEL}>
|
||||
{label}
|
||||
{isOpen || ':'}
|
||||
</strong>
|
||||
{!isOpen && <KeyValuesSummary data={data} />}
|
||||
</div>
|
||||
{isOpen && <KeyValuesTable data={data} linksGetter={linksGetter} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AccordianKeyValues.defaultProps = {
|
||||
className: null,
|
||||
highContrast: false,
|
||||
interactive: true,
|
||||
onToggle: null,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import AccordianKeyValues from './AccordianKeyValues';
|
||||
import AccordianLogs from './AccordianLogs';
|
||||
|
||||
describe('<AccordianLogs>', () => {
|
||||
let wrapper;
|
||||
|
||||
const logs = [
|
||||
{
|
||||
timestamp: 10,
|
||||
fields: [{ key: 'message', value: 'oh the log message' }, { key: 'something', value: 'else' }],
|
||||
},
|
||||
{
|
||||
timestamp: 20,
|
||||
fields: [{ key: 'message', value: 'oh the next log message' }, { key: 'more', value: 'stuff' }],
|
||||
},
|
||||
];
|
||||
const props = {
|
||||
logs,
|
||||
isOpen: false,
|
||||
onItemToggle: jest.fn(),
|
||||
onToggle: () => {},
|
||||
openedItems: new Set([logs[1]]),
|
||||
timestamp: 5,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props.onItemToggle.mockReset();
|
||||
wrapper = shallow(<AccordianLogs {...props} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows the number of log entries', () => {
|
||||
const regex = new RegExp(`Logs \\(${logs.length}\\)`);
|
||||
expect(wrapper.find('a').text()).toMatch(regex);
|
||||
});
|
||||
|
||||
it('hides log entries when not expanded', () => {
|
||||
expect(wrapper.find(AccordianKeyValues).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows log entries when expanded', () => {
|
||||
expect(wrapper.find(AccordianKeyValues).exists()).toBe(false);
|
||||
wrapper.setProps({ isOpen: true });
|
||||
const logViews = wrapper.find(AccordianKeyValues);
|
||||
expect(logViews.length).toBe(logs.length);
|
||||
|
||||
logViews.forEach((node, i) => {
|
||||
const log = logs[i];
|
||||
expect(node.prop('data')).toBe(log.fields);
|
||||
node.simulate('toggle');
|
||||
expect(props.onItemToggle).toHaveBeenLastCalledWith(log);
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates isOpen to log items correctly', () => {
|
||||
wrapper.setProps({ isOpen: true });
|
||||
const logViews = wrapper.find(AccordianKeyValues);
|
||||
logViews.forEach((node, i) => {
|
||||
expect(node.prop('isOpen')).toBe(props.openedItems.has(logs[i]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import _sortBy from 'lodash/sortBy';
|
||||
import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
|
||||
import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right';
|
||||
import { css } from 'emotion';
|
||||
|
||||
import AccordianKeyValues from './AccordianKeyValues';
|
||||
import { formatDuration } from '../utils';
|
||||
import { TNil } from '../../types';
|
||||
import { Log, KeyValuePair, Link } from '../../types/trace';
|
||||
import { createStyle } from '../../Theme';
|
||||
import { uAlignIcon, ubMb1 } from '../../uberUtilityStyles';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
AccordianLogs: css`
|
||||
border: 1px solid #d8d8d8;
|
||||
position: relative;
|
||||
margin-bottom: 0.25rem;
|
||||
`,
|
||||
header: css`
|
||||
background: #e4e4e4;
|
||||
color: inherit;
|
||||
display: block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
&:hover {
|
||||
background: #dadada;
|
||||
}
|
||||
`,
|
||||
content: css`
|
||||
background: #f0f0f0;
|
||||
border-top: 1px solid #d8d8d8;
|
||||
padding: 0.5rem 0.5rem 0.25rem 0.5rem;
|
||||
`,
|
||||
footer: css`
|
||||
color: #999;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type AccordianLogsProps = {
|
||||
interactive?: boolean;
|
||||
isOpen: boolean;
|
||||
linksGetter: ((pairs: KeyValuePair[], index: number) => Link[]) | TNil;
|
||||
logs: Log[];
|
||||
onItemToggle?: (log: Log) => void;
|
||||
onToggle?: () => void;
|
||||
openedItems?: Set<Log>;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export default function AccordianLogs(props: AccordianLogsProps) {
|
||||
const { interactive, isOpen, linksGetter, logs, openedItems, onItemToggle, onToggle, timestamp } = props;
|
||||
let arrow: React.ReactNode | null = null;
|
||||
let HeaderComponent: 'span' | 'a' = 'span';
|
||||
let headerProps: {} | null = null;
|
||||
if (interactive) {
|
||||
arrow = isOpen ? <IoIosArrowDown className={uAlignIcon} /> : <IoIosArrowRight className="u-align-icon" />;
|
||||
HeaderComponent = 'a';
|
||||
headerProps = {
|
||||
'aria-checked': isOpen,
|
||||
onClick: onToggle,
|
||||
role: 'switch',
|
||||
};
|
||||
}
|
||||
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<div className={styles.AccordianLogs}>
|
||||
<HeaderComponent className={styles.header} {...headerProps}>
|
||||
{arrow} <strong>Logs</strong> ({logs.length})
|
||||
</HeaderComponent>
|
||||
{isOpen && (
|
||||
<div className={styles.content}>
|
||||
{_sortBy(logs, 'timestamp').map((log, i) => (
|
||||
<AccordianKeyValues
|
||||
// `i` is necessary in the key because timestamps can repeat
|
||||
key={`${log.timestamp}-${i}`}
|
||||
className={i < logs.length - 1 ? ubMb1 : null}
|
||||
data={log.fields || []}
|
||||
highContrast
|
||||
interactive={interactive}
|
||||
isOpen={openedItems ? openedItems.has(log) : false}
|
||||
label={`${formatDuration(log.timestamp - timestamp)}`}
|
||||
linksGetter={linksGetter}
|
||||
onToggle={interactive && onItemToggle ? () => onItemToggle(log) : null}
|
||||
/>
|
||||
))}
|
||||
<small className={styles.footer}>Log timestamps are relative to the start time of the full trace.</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AccordianLogs.defaultProps = {
|
||||
interactive: true,
|
||||
linksGetter: undefined,
|
||||
onItemToggle: undefined,
|
||||
onToggle: undefined,
|
||||
openedItems: undefined,
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright (c) 2019 The Jaeger Authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import AccordianReferences, { References } from './AccordianReferences';
|
||||
import ReferenceLink from '../../url/ReferenceLink';
|
||||
|
||||
const traceID = 'trace1';
|
||||
const references = [
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
span: {
|
||||
spanID: 'span1',
|
||||
traceID,
|
||||
operationName: 'op1',
|
||||
process: {
|
||||
serviceName: 'service1',
|
||||
},
|
||||
},
|
||||
spanID: 'span1',
|
||||
traceID,
|
||||
},
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
span: {
|
||||
spanID: 'span3',
|
||||
traceID,
|
||||
operationName: 'op2',
|
||||
process: {
|
||||
serviceName: 'service2',
|
||||
},
|
||||
},
|
||||
spanID: 'span3',
|
||||
traceID,
|
||||
},
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
spanID: 'span5',
|
||||
traceID: 'trace2',
|
||||
},
|
||||
];
|
||||
|
||||
describe('<AccordianReferences>', () => {
|
||||
let wrapper;
|
||||
|
||||
const props = {
|
||||
compact: false,
|
||||
data: references,
|
||||
highContrast: false,
|
||||
isOpen: false,
|
||||
onToggle: jest.fn(),
|
||||
focusSpan: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<AccordianReferences {...props} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the content when it is expanded', () => {
|
||||
wrapper.setProps({ isOpen: true });
|
||||
const content = wrapper.find(References);
|
||||
expect(content.length).toBe(1);
|
||||
expect(content.prop('data')).toBe(references);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<References>', () => {
|
||||
let wrapper;
|
||||
|
||||
const props = {
|
||||
data: references,
|
||||
focusSpan: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<References {...props} />);
|
||||
});
|
||||
|
||||
it('render references list', () => {
|
||||
const refLinks = wrapper.find(ReferenceLink);
|
||||
expect(refLinks.length).toBe(references.length);
|
||||
refLinks.forEach((refLink, i) => {
|
||||
const span = references[i].span;
|
||||
const serviceName = refLink.find('span.span-svc-name').text();
|
||||
if (span && span.traceID === traceID) {
|
||||
const endpointName = refLink.find('small.endpoint-name').text();
|
||||
expect(serviceName).toBe(span.process.serviceName);
|
||||
expect(endpointName).toBe(span.operationName);
|
||||
} else {
|
||||
expect(serviceName).toBe('< span in another trace >');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2019 The Jaeger Authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
|
||||
import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right';
|
||||
import { SpanReference } from '../../types/trace';
|
||||
import ReferenceLink from '../../url/ReferenceLink';
|
||||
|
||||
import { createStyle } from '../../Theme';
|
||||
import { uAlignIcon } from '../../uberUtilityStyles';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
ReferencesList: css`
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 0.7em;
|
||||
max-height: 450px;
|
||||
overflow: auto;
|
||||
`,
|
||||
list: css`
|
||||
width: 100%;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
`,
|
||||
itemContent: css`
|
||||
padding: 0.25rem 0.5rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
`,
|
||||
item: css`
|
||||
&:nth-child(2n) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
`,
|
||||
debugInfo: css`
|
||||
letter-spacing: 0.25px;
|
||||
margin: 0.5em 0 0;
|
||||
`,
|
||||
debugLabel: css`
|
||||
margin: 0 5px 0 5px;
|
||||
&::before {
|
||||
color: #bbb;
|
||||
content: attr(data-label);
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type AccordianReferencesProps = {
|
||||
data: SpanReference[];
|
||||
highContrast?: boolean;
|
||||
interactive?: boolean;
|
||||
isOpen: boolean;
|
||||
onToggle?: null | (() => void);
|
||||
focusSpan: (uiFind: string) => void;
|
||||
};
|
||||
|
||||
type ReferenceItemProps = {
|
||||
data: SpanReference[];
|
||||
focusSpan: (uiFind: string) => void;
|
||||
};
|
||||
|
||||
// export for test
|
||||
export function References(props: ReferenceItemProps) {
|
||||
const { data, focusSpan } = props;
|
||||
const styles = getStyles();
|
||||
|
||||
return (
|
||||
<div className={cx(styles.ReferencesList)}>
|
||||
<ul className={styles.list}>
|
||||
{data.map(reference => {
|
||||
return (
|
||||
<li className={styles.item} key={`${reference.spanID}`}>
|
||||
<ReferenceLink reference={reference} focusSpan={focusSpan}>
|
||||
<span className={styles.itemContent}>
|
||||
{reference.span ? (
|
||||
<span>
|
||||
<span className="span-svc-name">{reference.span.process.serviceName}</span>
|
||||
<small className="endpoint-name">{reference.span.operationName}</small>
|
||||
</span>
|
||||
) : (
|
||||
<span className="span-svc-name">< span in another trace ></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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import AccordianText from './AccordianText';
|
||||
import TextList from './TextList';
|
||||
|
||||
const warnings = ['Duplicated tag', 'Duplicated spanId'];
|
||||
|
||||
describe('<AccordianText>', () => {
|
||||
let wrapper;
|
||||
|
||||
const props = {
|
||||
compact: false,
|
||||
data: warnings,
|
||||
highContrast: false,
|
||||
isOpen: false,
|
||||
label: 'le-label',
|
||||
onToggle: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<AccordianText {...props} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the label', () => {
|
||||
const header = wrapper.find(`[data-test-id="AccordianText--header"] > strong`);
|
||||
expect(header.length).toBe(1);
|
||||
expect(header.text()).toBe(props.label);
|
||||
});
|
||||
|
||||
it('renders the content when it is expanded', () => {
|
||||
wrapper.setProps({ isOpen: true });
|
||||
const content = wrapper.find(TextList);
|
||||
expect(content.length).toBe(1);
|
||||
expect(content.prop('data')).toBe(warnings);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
|
||||
import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right';
|
||||
import TextList from './TextList';
|
||||
import { TNil } from '../../types';
|
||||
import { getStyles as getAccordianKeyValuesStyles } from './AccordianKeyValues';
|
||||
import { createStyle } from '../../Theme';
|
||||
import { uAlignIcon } from '../../uberUtilityStyles';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
header: css`
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
padding: 0.25em 0.1em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type AccordianTextProps = {
|
||||
className?: string | TNil;
|
||||
data: string[];
|
||||
headerClassName?: string | TNil;
|
||||
highContrast?: boolean;
|
||||
interactive?: boolean;
|
||||
isOpen: boolean;
|
||||
label: React.ReactNode;
|
||||
onToggle?: null | (() => void);
|
||||
};
|
||||
|
||||
export default function AccordianText(props: AccordianTextProps) {
|
||||
const { className, data, headerClassName, interactive, isOpen, label, onToggle } = props;
|
||||
const isEmpty = !Array.isArray(data) || !data.length;
|
||||
const accordianKeyValuesStyles = getAccordianKeyValuesStyles();
|
||||
const iconCls = cx(uAlignIcon, { [accordianKeyValuesStyles.emptyIcon]: isEmpty });
|
||||
let arrow: React.ReactNode | null = null;
|
||||
let headerProps: {} | null = null;
|
||||
if (interactive) {
|
||||
arrow = isOpen ? <IoIosArrowDown className={iconCls} /> : <IoIosArrowRight className={iconCls} />;
|
||||
headerProps = {
|
||||
'aria-checked': isOpen,
|
||||
onClick: isEmpty ? null : onToggle,
|
||||
role: 'switch',
|
||||
};
|
||||
}
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<div className={className || ''}>
|
||||
<div className={cx(styles.header, headerClassName)} {...headerProps} data-test-id="AccordianText--header">
|
||||
{arrow} <strong>{label}</strong> ({data.length})
|
||||
</div>
|
||||
{isOpen && <TextList data={data} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AccordianText.defaultProps = {
|
||||
className: null,
|
||||
highContrast: false,
|
||||
interactive: true,
|
||||
onToggle: null,
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Log } from '../../types/trace';
|
||||
|
||||
/**
|
||||
* Which items of a {@link SpanDetail} component are expanded.
|
||||
*/
|
||||
export default class DetailState {
|
||||
isTagsOpen: boolean;
|
||||
isProcessOpen: boolean;
|
||||
logs: { isOpen: boolean; openedItems: Set<Log> };
|
||||
isWarningsOpen: boolean;
|
||||
isReferencesOpen: boolean;
|
||||
|
||||
constructor(oldState?: DetailState) {
|
||||
const {
|
||||
isTagsOpen,
|
||||
isProcessOpen,
|
||||
isReferencesOpen,
|
||||
isWarningsOpen,
|
||||
logs,
|
||||
}: DetailState | Record<string, undefined> = oldState || {};
|
||||
this.isTagsOpen = Boolean(isTagsOpen);
|
||||
this.isProcessOpen = Boolean(isProcessOpen);
|
||||
this.isReferencesOpen = Boolean(isReferencesOpen);
|
||||
this.isWarningsOpen = Boolean(isWarningsOpen);
|
||||
this.logs = {
|
||||
isOpen: Boolean(logs && logs.isOpen),
|
||||
openedItems: logs && logs.openedItems ? new Set(logs.openedItems) : new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
toggleTags() {
|
||||
const next = new DetailState(this);
|
||||
next.isTagsOpen = !this.isTagsOpen;
|
||||
return next;
|
||||
}
|
||||
|
||||
toggleProcess() {
|
||||
const next = new DetailState(this);
|
||||
next.isProcessOpen = !this.isProcessOpen;
|
||||
return next;
|
||||
}
|
||||
|
||||
toggleReferences() {
|
||||
const next = new DetailState(this);
|
||||
next.isReferencesOpen = !this.isReferencesOpen;
|
||||
return next;
|
||||
}
|
||||
|
||||
toggleWarnings() {
|
||||
const next = new DetailState(this);
|
||||
next.isWarningsOpen = !this.isWarningsOpen;
|
||||
return next;
|
||||
}
|
||||
|
||||
toggleLogs() {
|
||||
const next = new DetailState(this);
|
||||
next.logs.isOpen = !this.logs.isOpen;
|
||||
return next;
|
||||
}
|
||||
|
||||
toggleLogItem(logItem: Log) {
|
||||
const next = new DetailState(this);
|
||||
if (next.logs.openedItems.has(logItem)) {
|
||||
next.logs.openedItems.delete(logItem);
|
||||
} else {
|
||||
next.logs.openedItems.add(logItem);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import CopyIcon from '../../common/CopyIcon';
|
||||
|
||||
import KeyValuesTable, { LinkValue, getStyles } from './KeyValuesTable';
|
||||
import { UIDropdown, UIIcon } from '../../uiElementsContext';
|
||||
import {ubInlineBlock} from "../../uberUtilityStyles";
|
||||
|
||||
describe('LinkValue', () => {
|
||||
const title = 'titleValue';
|
||||
const href = 'hrefValue';
|
||||
const childrenText = 'childrenTextValue';
|
||||
const wrapper = shallow(
|
||||
<LinkValue href={href} title={title}>
|
||||
{childrenText}
|
||||
</LinkValue>
|
||||
);
|
||||
|
||||
it('renders as expected', () => {
|
||||
expect(wrapper.find('a').prop('href')).toBe(href);
|
||||
expect(wrapper.find('a').prop('title')).toBe(title);
|
||||
expect(wrapper.find('a').text()).toMatch(/childrenText/);
|
||||
});
|
||||
|
||||
it('renders correct Icon', () => {
|
||||
const styles = getStyles();
|
||||
expect(wrapper.find(UIIcon).hasClass(styles.linkIcon)).toBe(true);
|
||||
expect(wrapper.find(UIIcon).prop('type')).toBe('export');
|
||||
});
|
||||
});
|
||||
|
||||
describe('<KeyValuesTable>', () => {
|
||||
let wrapper;
|
||||
|
||||
const data = [
|
||||
{ key: 'span.kind', value: 'client' },
|
||||
{ key: 'omg', value: 'mos-def' },
|
||||
{ key: 'numericString', value: '12345678901234567890' },
|
||||
{ key: 'jsonkey', value: JSON.stringify({ hello: 'world' }) },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<KeyValuesTable data={data} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper.find('[data-test-id="KeyValueTable"]').length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders a table row for each data element', () => {
|
||||
const trs = wrapper.find('tr');
|
||||
expect(trs.length).toBe(data.length);
|
||||
trs.forEach((tr, i) => {
|
||||
expect(tr.find('[data-test-id="KeyValueTable--keyColumn"]').text()).toMatch(data[i].key);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a single link correctly', () => {
|
||||
wrapper.setProps({
|
||||
linksGetter: (array, i) =>
|
||||
array[i].key === 'span.kind'
|
||||
? [
|
||||
{
|
||||
url: `http://example.com/?kind=${encodeURIComponent(array[i].value)}`,
|
||||
text: `More info about ${array[i].value}`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
|
||||
const anchor = wrapper.find(LinkValue);
|
||||
expect(anchor).toHaveLength(1);
|
||||
expect(anchor.prop('href')).toBe('http://example.com/?kind=client');
|
||||
expect(anchor.prop('title')).toBe('More info about client');
|
||||
expect(
|
||||
anchor
|
||||
.closest('tr')
|
||||
.find('td')
|
||||
.first()
|
||||
.text()
|
||||
).toBe('span.kind');
|
||||
});
|
||||
|
||||
it('renders multiple links correctly', () => {
|
||||
wrapper.setProps({
|
||||
linksGetter: (array, i) =>
|
||||
array[i].key === 'span.kind'
|
||||
? [
|
||||
{ url: `http://example.com/1?kind=${encodeURIComponent(array[i].value)}`, text: 'Example 1' },
|
||||
{ url: `http://example.com/2?kind=${encodeURIComponent(array[i].value)}`, text: 'Example 2' },
|
||||
]
|
||||
: [],
|
||||
});
|
||||
const dropdown = wrapper.find(UIDropdown);
|
||||
const overlay = shallow(dropdown.prop('overlay'));
|
||||
// We have some wrappers here that dynamically inject specific component so we need to traverse a bit
|
||||
// here
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const menu = shallow(overlay.prop('children')({ Menu: ({ children }) => <div>{children}</div> }));
|
||||
const anchors = menu.find(LinkValue);
|
||||
expect(anchors).toHaveLength(2);
|
||||
const firstAnchor = anchors.first();
|
||||
expect(firstAnchor.prop('href')).toBe('http://example.com/1?kind=client');
|
||||
expect(firstAnchor.children().text()).toBe('Example 1');
|
||||
const secondAnchor = anchors.last();
|
||||
expect(secondAnchor.prop('href')).toBe('http://example.com/2?kind=client');
|
||||
expect(secondAnchor.children().text()).toBe('Example 2');
|
||||
expect(
|
||||
dropdown
|
||||
.closest('tr')
|
||||
.find('td')
|
||||
.first()
|
||||
.text()
|
||||
).toBe('span.kind');
|
||||
});
|
||||
|
||||
it('renders a <CopyIcon /> with correct copyText for each data element', () => {
|
||||
const copyIcons = wrapper.find(CopyIcon);
|
||||
expect(copyIcons.length).toBe(data.length);
|
||||
copyIcons.forEach((copyIcon, i) => {
|
||||
expect(copyIcon.prop('copyText')).toBe(JSON.stringify(data[i], null, 2));
|
||||
expect(copyIcon.prop('tooltipTitle')).toBe('Copy JSON');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a span value containing numeric string correctly', () => {
|
||||
const el = wrapper.find(`.${ubInlineBlock}`);
|
||||
expect(el.length).toBe(data.length);
|
||||
el.forEach((valueDiv, i) => {
|
||||
if (data[i].key !== 'jsonkey') {
|
||||
expect(valueDiv.html()).toMatch(`"${data[i].value}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import jsonMarkup from 'json-markup';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import CopyIcon from '../../common/CopyIcon';
|
||||
|
||||
import { TNil } from '../../types';
|
||||
import { KeyValuePair, Link } from '../../types/trace';
|
||||
import { UIDropdown, UIIcon, UIMenu, UIMenuItem } from '../../uiElementsContext';
|
||||
import { createStyle } from '../../Theme';
|
||||
import { ubInlineBlock, uWidth100 } from '../../uberUtilityStyles';
|
||||
|
||||
export const getStyles = createStyle(() => {
|
||||
const copyIcon = css`
|
||||
label: copyIcon;
|
||||
`;
|
||||
return {
|
||||
KeyValueTable: css`
|
||||
label: KeyValueTable;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 0.7em;
|
||||
max-height: 450px;
|
||||
overflow: auto;
|
||||
`,
|
||||
body: css`
|
||||
label: body;
|
||||
vertical-align: baseline;
|
||||
`,
|
||||
row: css`
|
||||
label: row;
|
||||
& > td {
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
&:nth-child(2n) > td {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
&:not(:hover) .${copyIcon} {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
keyColumn: css`
|
||||
label: keyColumn;
|
||||
color: #888;
|
||||
white-space: pre;
|
||||
width: 125px;
|
||||
`,
|
||||
copyColumn: css`
|
||||
label: copyColumn;
|
||||
text-align: right;
|
||||
`,
|
||||
linkIcon: css`
|
||||
label: linkIcon;
|
||||
vertical-align: middle;
|
||||
font-weight: bold;
|
||||
`,
|
||||
copyIcon,
|
||||
};
|
||||
});
|
||||
|
||||
const jsonObjectOrArrayStartRegex = /^(\[|\{)/;
|
||||
|
||||
function parseIfComplexJson(value: any) {
|
||||
// if the value is a string representing actual json object or array, then use json-markup
|
||||
if (typeof value === 'string' && jsonObjectOrArrayStartRegex.test(value)) {
|
||||
// otherwise just return as is
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (_) {}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const LinkValue = (props: { href: string; title?: string; children: React.ReactNode }) => {
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<a href={props.href} title={props.title} target="_blank" rel="noopener noreferrer">
|
||||
{props.children} <UIIcon className={styles.linkIcon} type="export" />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
LinkValue.defaultProps = {
|
||||
title: '',
|
||||
};
|
||||
|
||||
const linkValueList = (links: Link[]) => (
|
||||
<UIMenu>
|
||||
{links.map(({ text, url }, index) => (
|
||||
// `index` is necessary in the key because url can repeat
|
||||
<UIMenuItem key={`${url}-${index}`}>
|
||||
<LinkValue href={url}>{text}</LinkValue>
|
||||
</UIMenuItem>
|
||||
))}
|
||||
</UIMenu>
|
||||
);
|
||||
|
||||
type KeyValuesTableProps = {
|
||||
data: KeyValuePair[];
|
||||
linksGetter: ((pairs: KeyValuePair[], index: number) => Link[]) | TNil;
|
||||
};
|
||||
|
||||
export default function KeyValuesTable(props: KeyValuesTableProps) {
|
||||
const { data, linksGetter } = props;
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<div className={cx(styles.KeyValueTable)} data-test-id="KeyValueTable">
|
||||
<table className={uWidth100}>
|
||||
<tbody className={styles.body}>
|
||||
{data.map((row, i) => {
|
||||
const markup = {
|
||||
__html: jsonMarkup(parseIfComplexJson(row.value)),
|
||||
};
|
||||
const jsonTable = <div className={ubInlineBlock} dangerouslySetInnerHTML={markup} />;
|
||||
const links = linksGetter ? linksGetter(data, i) : null;
|
||||
let valueMarkup;
|
||||
if (links && links.length === 1) {
|
||||
valueMarkup = (
|
||||
<div>
|
||||
<LinkValue href={links[0].url} title={links[0].text}>
|
||||
{jsonTable}
|
||||
</LinkValue>
|
||||
</div>
|
||||
);
|
||||
} else if (links && links.length > 1) {
|
||||
valueMarkup = (
|
||||
<div>
|
||||
<UIDropdown overlay={linkValueList(links)} placement="bottomRight" trigger={['click']}>
|
||||
<a>
|
||||
{jsonTable} <UIIcon className={styles.linkIcon} type="profile" />
|
||||
</a>
|
||||
</UIDropdown>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
valueMarkup = jsonTable;
|
||||
}
|
||||
return (
|
||||
// `i` is necessary in the key because row.key can repeat
|
||||
<tr className={styles.row} key={`${row.key}-${i}`}>
|
||||
<td className={styles.keyColumn} data-test-id="KeyValueTable--keyColumn">
|
||||
{row.key}
|
||||
</td>
|
||||
<td>{valueMarkup}</td>
|
||||
<td className={styles.copyColumn}>
|
||||
<CopyIcon
|
||||
className={styles.copyIcon}
|
||||
copyText={JSON.stringify(row, null, 2)}
|
||||
tooltipTitle="Copy JSON"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import TextList from './TextList';
|
||||
|
||||
describe('<TextList>', () => {
|
||||
let wrapper;
|
||||
|
||||
const data = [{ key: 'span.kind', value: 'client' }, { key: 'omg', value: 'mos-def' }];
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<TextList data={data} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper.find('[data-test-id="TextList"]').length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders a table row for each data element', () => {
|
||||
const trs = wrapper.find('li');
|
||||
expect(trs.length).toBe(data.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { createStyle } from '../../Theme';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
TextList: css`
|
||||
max-height: 450px;
|
||||
overflow: auto;
|
||||
`,
|
||||
List: css`
|
||||
width: 100%;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
`,
|
||||
item: css`
|
||||
padding: 0.25rem 0.5rem;
|
||||
vertical-align: top;
|
||||
&:nth-child(2n) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type TextListProps = {
|
||||
data: string[];
|
||||
};
|
||||
|
||||
export default function TextList(props: TextListProps) {
|
||||
const { data } = props;
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<div className={cx(styles.TextList)} data-test-id="TextList">
|
||||
<ul className={styles.List}>
|
||||
{data.map((row, i) => {
|
||||
return (
|
||||
// `i` is necessary in the key because row.key can repeat
|
||||
<li className={styles.item} key={`${i}`}>
|
||||
{row}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
/* eslint-disable import/first */
|
||||
jest.mock('../utils');
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import AccordianKeyValues from './AccordianKeyValues';
|
||||
import AccordianLogs from './AccordianLogs';
|
||||
import DetailState from './DetailState';
|
||||
import SpanDetail from './index';
|
||||
import { formatDuration } from '../utils';
|
||||
import CopyIcon from '../../common/CopyIcon';
|
||||
import LabeledList from '../../common/LabeledList';
|
||||
import traceGenerator from '../../demo/trace-generators';
|
||||
import transformTraceData from '../../model/transform-trace-data';
|
||||
|
||||
describe('<SpanDetail>', () => {
|
||||
let wrapper;
|
||||
|
||||
// use `transformTraceData` on a fake trace to get a fully processed span
|
||||
const span = transformTraceData(traceGenerator.trace({ numberOfSpans: 1 })).spans[0];
|
||||
const detailState = new DetailState()
|
||||
.toggleLogs()
|
||||
.toggleProcess()
|
||||
.toggleReferences()
|
||||
.toggleTags();
|
||||
const traceStartTime = 5;
|
||||
const props = {
|
||||
detailState,
|
||||
span,
|
||||
traceStartTime,
|
||||
logItemToggle: jest.fn(),
|
||||
logsToggle: jest.fn(),
|
||||
processToggle: jest.fn(),
|
||||
tagsToggle: jest.fn(),
|
||||
warningsToggle: jest.fn(),
|
||||
referencesToggle: jest.fn(),
|
||||
};
|
||||
span.logs = [
|
||||
{
|
||||
timestamp: 10,
|
||||
fields: [{ key: 'message', value: 'oh the log message' }, { key: 'something', value: 'else' }],
|
||||
},
|
||||
{
|
||||
timestamp: 20,
|
||||
fields: [{ key: 'message', value: 'oh the next log message' }, { key: 'more', value: 'stuff' }],
|
||||
},
|
||||
];
|
||||
|
||||
span.warnings = ['Warning 1', 'Warning 2'];
|
||||
|
||||
span.references = [
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
span: {
|
||||
spanID: 'span2',
|
||||
traceID: 'trace1',
|
||||
operationName: 'op1',
|
||||
process: {
|
||||
serviceName: 'service1',
|
||||
},
|
||||
},
|
||||
spanID: 'span1',
|
||||
traceID: 'trace1',
|
||||
},
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
span: {
|
||||
spanID: 'span3',
|
||||
traceID: 'trace1',
|
||||
operationName: 'op2',
|
||||
process: {
|
||||
serviceName: 'service2',
|
||||
},
|
||||
},
|
||||
spanID: 'span4',
|
||||
traceID: 'trace1',
|
||||
},
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
span: {
|
||||
spanID: 'span6',
|
||||
traceID: 'trace2',
|
||||
operationName: 'op2',
|
||||
process: {
|
||||
serviceName: 'service2',
|
||||
},
|
||||
},
|
||||
spanID: 'span5',
|
||||
traceID: 'trace2',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
formatDuration.mockReset();
|
||||
props.tagsToggle.mockReset();
|
||||
props.processToggle.mockReset();
|
||||
props.logsToggle.mockReset();
|
||||
props.logItemToggle.mockReset();
|
||||
wrapper = shallow(<SpanDetail {...props} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows the operation name', () => {
|
||||
expect(wrapper.find('h2').text()).toBe(span.operationName);
|
||||
});
|
||||
|
||||
it('lists the service name, duration and start time', () => {
|
||||
const words = ['Duration:', 'Service:', 'Start Time:'];
|
||||
const overview = wrapper.find(LabeledList);
|
||||
expect(
|
||||
overview
|
||||
.prop('items')
|
||||
.map(item => item.label)
|
||||
.sort()
|
||||
).toEqual(words);
|
||||
});
|
||||
|
||||
it('renders the span tags', () => {
|
||||
const target = <AccordianKeyValues data={span.tags} label="Tags" isOpen={detailState.isTagsOpen} />;
|
||||
expect(wrapper.containsMatchingElement(target)).toBe(true);
|
||||
wrapper.find({ data: span.tags }).simulate('toggle');
|
||||
expect(props.tagsToggle).toHaveBeenLastCalledWith(span.spanID);
|
||||
});
|
||||
|
||||
it('renders the process tags', () => {
|
||||
const target = (
|
||||
<AccordianKeyValues data={span.process.tags} label="Process" isOpen={detailState.isProcessOpen} />
|
||||
);
|
||||
expect(wrapper.containsMatchingElement(target)).toBe(true);
|
||||
wrapper.find({ data: span.process.tags }).simulate('toggle');
|
||||
expect(props.processToggle).toHaveBeenLastCalledWith(span.spanID);
|
||||
});
|
||||
|
||||
it('renders the logs', () => {
|
||||
const somethingUniq = {};
|
||||
const target = (
|
||||
<AccordianLogs
|
||||
logs={span.logs}
|
||||
isOpen={detailState.logs.isOpen}
|
||||
openedItems={detailState.logs.openedItems}
|
||||
timestamp={traceStartTime}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.containsMatchingElement(target)).toBe(true);
|
||||
const accordianLogs = wrapper.find(AccordianLogs);
|
||||
accordianLogs.simulate('toggle');
|
||||
accordianLogs.simulate('itemToggle', somethingUniq);
|
||||
expect(props.logsToggle).toHaveBeenLastCalledWith(span.spanID);
|
||||
expect(props.logItemToggle).toHaveBeenLastCalledWith(span.spanID, somethingUniq);
|
||||
});
|
||||
|
||||
it('renders the warnings', () => {
|
||||
const warningElm = wrapper.find({ data: span.warnings });
|
||||
expect(warningElm.length).toBe(1);
|
||||
warningElm.simulate('toggle');
|
||||
expect(props.warningsToggle).toHaveBeenLastCalledWith(span.spanID);
|
||||
});
|
||||
|
||||
it('renders the references', () => {
|
||||
const refElem = wrapper.find({ data: span.references });
|
||||
expect(refElem.length).toBe(1);
|
||||
refElem.simulate('toggle');
|
||||
expect(props.referencesToggle).toHaveBeenLastCalledWith(span.spanID);
|
||||
});
|
||||
|
||||
it('renders CopyIcon with deep link URL', () => {
|
||||
expect(
|
||||
wrapper
|
||||
.find(CopyIcon)
|
||||
.prop('copyText')
|
||||
.includes(`?uiFind=${props.span.spanID}`)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import AccordianKeyValues from './AccordianKeyValues';
|
||||
import AccordianLogs from './AccordianLogs';
|
||||
import AccordianText from './AccordianText';
|
||||
import DetailState from './DetailState';
|
||||
import { formatDuration } from '../utils';
|
||||
import CopyIcon from '../../common/CopyIcon';
|
||||
import LabeledList from '../../common/LabeledList';
|
||||
|
||||
import { TNil } from '../../types';
|
||||
import { KeyValuePair, Link, Log, Span } from '../../types/trace';
|
||||
import AccordianReferences from './AccordianReferences';
|
||||
import { createStyle } from '../../Theme';
|
||||
import { UIDivider } from '../../uiElementsContext';
|
||||
import { ubFlex, ubFlexAuto, ubItemsCenter, ubM0, ubMb1, ubMy1, ubTxRightAlign } from '../../uberUtilityStyles';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
divider: css`
|
||||
background: #ddd;
|
||||
`,
|
||||
debugInfo: css`
|
||||
display: block;
|
||||
letter-spacing: 0.25px;
|
||||
margin: 0.5em 0 -0.75em;
|
||||
text-align: right;
|
||||
`,
|
||||
debugLabel: css`
|
||||
&::before {
|
||||
color: #bbb;
|
||||
content: attr(data-label);
|
||||
}
|
||||
`,
|
||||
debugValue: css`
|
||||
background-color: inherit;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: #333;
|
||||
}
|
||||
`,
|
||||
AccordianWarnings: css`
|
||||
background: #fafafa;
|
||||
border: 1px solid #e4e4e4;
|
||||
margin-bottom: 0.25rem;
|
||||
`,
|
||||
AccordianWarningsHeader: css`
|
||||
background: #fff7e6;
|
||||
padding: 0.25rem 0.5rem;
|
||||
&:hover {
|
||||
background: #ffe7ba;
|
||||
}
|
||||
`,
|
||||
AccordianWarningsHeaderOpen: css`
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
`,
|
||||
AccordianWarningsLabel: css`
|
||||
color: #d36c08;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type SpanDetailProps = {
|
||||
detailState: DetailState;
|
||||
linksGetter: ((links: KeyValuePair[], index: number) => Link[]) | TNil;
|
||||
logItemToggle: (spanID: string, log: Log) => void;
|
||||
logsToggle: (spanID: string) => void;
|
||||
processToggle: (spanID: string) => void;
|
||||
span: Span;
|
||||
tagsToggle: (spanID: string) => void;
|
||||
traceStartTime: number;
|
||||
warningsToggle: (spanID: string) => void;
|
||||
referencesToggle: (spanID: string) => void;
|
||||
focusSpan: (uiFind: string) => void;
|
||||
};
|
||||
|
||||
export default function SpanDetail(props: SpanDetailProps) {
|
||||
const {
|
||||
detailState,
|
||||
linksGetter,
|
||||
logItemToggle,
|
||||
logsToggle,
|
||||
processToggle,
|
||||
span,
|
||||
tagsToggle,
|
||||
traceStartTime,
|
||||
warningsToggle,
|
||||
referencesToggle,
|
||||
focusSpan,
|
||||
} = props;
|
||||
const { isTagsOpen, isProcessOpen, logs: logsState, isWarningsOpen, isReferencesOpen } = detailState;
|
||||
const { operationName, process, duration, relativeStartTime, spanID, logs, tags, warnings, references } = span;
|
||||
const overviewItems = [
|
||||
{
|
||||
key: 'svc',
|
||||
label: 'Service:',
|
||||
value: process.serviceName,
|
||||
},
|
||||
{
|
||||
key: 'duration',
|
||||
label: 'Duration:',
|
||||
value: formatDuration(duration),
|
||||
},
|
||||
{
|
||||
key: 'start',
|
||||
label: 'Start Time:',
|
||||
value: formatDuration(relativeStartTime),
|
||||
},
|
||||
];
|
||||
const deepLinkCopyText = `${window.location.origin}${window.location.pathname}?uiFind=${spanID}`;
|
||||
const styles = getStyles();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={cx(ubFlex, ubItemsCenter)}>
|
||||
<h2 className={cx(ubFlexAuto, ubM0)}>{operationName}</h2>
|
||||
<LabeledList className={ubTxRightAlign} dividerClassName={styles.divider} items={overviewItems} />
|
||||
</div>
|
||||
<UIDivider className={cx(styles.divider, ubMy1)} />
|
||||
<div>
|
||||
<div>
|
||||
<AccordianKeyValues
|
||||
data={tags}
|
||||
label="Tags"
|
||||
linksGetter={linksGetter}
|
||||
isOpen={isTagsOpen}
|
||||
onToggle={() => tagsToggle(spanID)}
|
||||
/>
|
||||
{process.tags && (
|
||||
<AccordianKeyValues
|
||||
className={ubMb1}
|
||||
data={process.tags}
|
||||
label="Process"
|
||||
linksGetter={linksGetter}
|
||||
isOpen={isProcessOpen}
|
||||
onToggle={() => processToggle(spanID)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{logs && logs.length > 0 && (
|
||||
<AccordianLogs
|
||||
linksGetter={linksGetter}
|
||||
logs={logs}
|
||||
isOpen={logsState.isOpen}
|
||||
openedItems={logsState.openedItems}
|
||||
onToggle={() => logsToggle(spanID)}
|
||||
onItemToggle={logItem => logItemToggle(spanID, logItem)}
|
||||
timestamp={traceStartTime}
|
||||
/>
|
||||
)}
|
||||
{warnings && warnings.length > 0 && (
|
||||
<AccordianText
|
||||
className={styles.AccordianWarnings}
|
||||
headerClassName={styles.AccordianWarningsHeader}
|
||||
label={<span className={styles.AccordianWarningsLabel}>Warnings</span>}
|
||||
data={warnings}
|
||||
isOpen={isWarningsOpen}
|
||||
onToggle={() => warningsToggle(spanID)}
|
||||
/>
|
||||
)}
|
||||
{references && references.length > 1 && (
|
||||
<AccordianReferences
|
||||
data={references}
|
||||
isOpen={isReferencesOpen}
|
||||
onToggle={() => referencesToggle(spanID)}
|
||||
focusSpan={focusSpan}
|
||||
/>
|
||||
)}
|
||||
<small className={styles.debugInfo}>
|
||||
<span className={styles.debugLabel} data-label="SpanID:" /> {spanID}
|
||||
<CopyIcon
|
||||
copyText={deepLinkCopyText}
|
||||
icon="link"
|
||||
placement="topRight"
|
||||
tooltipTitle="Copy deep link to this span"
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import SpanDetailRow from './SpanDetailRow';
|
||||
import SpanDetail from './SpanDetail';
|
||||
import DetailState from './SpanDetail/DetailState';
|
||||
import SpanTreeOffset from './SpanTreeOffset';
|
||||
|
||||
jest.mock('./SpanTreeOffset');
|
||||
|
||||
describe('<SpanDetailRow>', () => {
|
||||
const spanID = 'some-id';
|
||||
const props = {
|
||||
color: 'some-color',
|
||||
columnDivision: 0.5,
|
||||
detailState: new DetailState(),
|
||||
onDetailToggled: jest.fn(),
|
||||
linksGetter: jest.fn(),
|
||||
isFilteredOut: false,
|
||||
logItemToggle: jest.fn(),
|
||||
logsToggle: jest.fn(),
|
||||
processToggle: jest.fn(),
|
||||
span: { spanID, depth: 3 },
|
||||
tagsToggle: jest.fn(),
|
||||
traceStartTime: 1000,
|
||||
};
|
||||
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
props.onDetailToggled.mockReset();
|
||||
props.linksGetter.mockReset();
|
||||
props.logItemToggle.mockReset();
|
||||
props.logsToggle.mockReset();
|
||||
props.processToggle.mockReset();
|
||||
props.tagsToggle.mockReset();
|
||||
wrapper = shallow(<SpanDetailRow {...props} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('escalates toggle detail', () => {
|
||||
const calls = props.onDetailToggled.mock.calls;
|
||||
expect(calls.length).toBe(0);
|
||||
wrapper.find('[data-test-id="detail-row-expanded-accent"]').prop('onClick')();
|
||||
expect(calls).toEqual([[spanID]]);
|
||||
});
|
||||
|
||||
it('renders the span tree offset', () => {
|
||||
const spanTreeOffset = <SpanTreeOffset span={props.span} showChildrenIcon={false} />;
|
||||
expect(wrapper.contains(spanTreeOffset)).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the SpanDetail', () => {
|
||||
const spanDetail = (
|
||||
<SpanDetail
|
||||
detailState={props.detailState}
|
||||
linksGetter={wrapper.instance()._linksGetter}
|
||||
logItemToggle={props.logItemToggle}
|
||||
logsToggle={props.logsToggle}
|
||||
processToggle={props.processToggle}
|
||||
span={props.span}
|
||||
tagsToggle={props.tagsToggle}
|
||||
traceStartTime={props.traceStartTime}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.contains(spanDetail)).toBe(true);
|
||||
});
|
||||
|
||||
it('adds span when calling linksGetter', () => {
|
||||
const spanDetail = wrapper.find(SpanDetail);
|
||||
const linksGetter = spanDetail.prop('linksGetter');
|
||||
const tags = [{ key: 'myKey', value: 'myValue' }];
|
||||
const linksGetterResponse = {};
|
||||
props.linksGetter.mockReturnValueOnce(linksGetterResponse);
|
||||
const result = linksGetter(tags, 0);
|
||||
expect(result).toBe(linksGetterResponse);
|
||||
expect(props.linksGetter).toHaveBeenCalledTimes(1);
|
||||
expect(props.linksGetter).toHaveBeenCalledWith(props.span, tags, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { css } from 'emotion';
|
||||
|
||||
import SpanDetail from './SpanDetail';
|
||||
import DetailState from './SpanDetail/DetailState';
|
||||
import SpanTreeOffset from './SpanTreeOffset';
|
||||
import TimelineRow from './TimelineRow';
|
||||
import { createStyle } from '../Theme';
|
||||
|
||||
import { Log, Span, KeyValuePair, Link } from '../types/trace';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
expandedAccent: css`
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
&::before {
|
||||
border-left: 4px solid;
|
||||
pointer-events: none;
|
||||
width: 1000px;
|
||||
}
|
||||
&::after {
|
||||
border-right: 1000px solid;
|
||||
border-color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* border-color inherit must come AFTER other border declarations for accent */
|
||||
&::before,
|
||||
&::after {
|
||||
border-color: inherit;
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
opacity: 0.35;
|
||||
}
|
||||
`,
|
||||
infoWrapper: css`
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #d3d3d3;
|
||||
border-top: 3px solid;
|
||||
padding: 0.75rem;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type SpanDetailRowProps = {
|
||||
color: string;
|
||||
columnDivision: number;
|
||||
detailState: DetailState;
|
||||
onDetailToggled: (spanID: string) => void;
|
||||
linksGetter: (span: Span, links: KeyValuePair[], index: number) => Link[];
|
||||
logItemToggle: (spanID: string, log: Log) => void;
|
||||
logsToggle: (spanID: string) => void;
|
||||
processToggle: (spanID: string) => void;
|
||||
referencesToggle: (spanID: string) => void;
|
||||
warningsToggle: (spanID: string) => void;
|
||||
span: Span;
|
||||
tagsToggle: (spanID: string) => void;
|
||||
traceStartTime: number;
|
||||
focusSpan: (uiFind: string) => void;
|
||||
hoverIndentGuideIds: Set<string>;
|
||||
addHoverIndentGuideId: (spanID: string) => void;
|
||||
removeHoverIndentGuideId: (spanID: string) => void;
|
||||
};
|
||||
|
||||
export default class SpanDetailRow extends React.PureComponent<SpanDetailRowProps> {
|
||||
_detailToggle = () => {
|
||||
this.props.onDetailToggled(this.props.span.spanID);
|
||||
};
|
||||
|
||||
_linksGetter = (items: KeyValuePair[], itemIndex: number) => {
|
||||
const { linksGetter, span } = this.props;
|
||||
return linksGetter(span, items, itemIndex);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
color,
|
||||
columnDivision,
|
||||
detailState,
|
||||
logItemToggle,
|
||||
logsToggle,
|
||||
processToggle,
|
||||
referencesToggle,
|
||||
warningsToggle,
|
||||
span,
|
||||
tagsToggle,
|
||||
traceStartTime,
|
||||
focusSpan,
|
||||
hoverIndentGuideIds,
|
||||
addHoverIndentGuideId,
|
||||
removeHoverIndentGuideId,
|
||||
} = this.props;
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<TimelineRow>
|
||||
<TimelineRow.Cell width={columnDivision}>
|
||||
<SpanTreeOffset
|
||||
span={span}
|
||||
showChildrenIcon={false}
|
||||
hoverIndentGuideIds={hoverIndentGuideIds}
|
||||
addHoverIndentGuideId={addHoverIndentGuideId}
|
||||
removeHoverIndentGuideId={removeHoverIndentGuideId}
|
||||
/>
|
||||
<span>
|
||||
<span
|
||||
className={styles.expandedAccent}
|
||||
aria-checked="true"
|
||||
onClick={this._detailToggle}
|
||||
role="switch"
|
||||
style={{ borderColor: color }}
|
||||
data-test-id="detail-row-expanded-accent"
|
||||
/>
|
||||
</span>
|
||||
</TimelineRow.Cell>
|
||||
<TimelineRow.Cell width={1 - columnDivision}>
|
||||
<div className={styles.infoWrapper} style={{ borderTopColor: color }}>
|
||||
<SpanDetail
|
||||
detailState={detailState}
|
||||
linksGetter={this._linksGetter}
|
||||
logItemToggle={logItemToggle}
|
||||
logsToggle={logsToggle}
|
||||
processToggle={processToggle}
|
||||
referencesToggle={referencesToggle}
|
||||
warningsToggle={warningsToggle}
|
||||
span={span}
|
||||
tagsToggle={tagsToggle}
|
||||
traceStartTime={traceStartTime}
|
||||
focusSpan={focusSpan}
|
||||
/>
|
||||
</div>
|
||||
</TimelineRow.Cell>
|
||||
</TimelineRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2018 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
import IoChevronRight from 'react-icons/lib/io/chevron-right';
|
||||
import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
|
||||
|
||||
import SpanTreeOffset, { getStyles } from './SpanTreeOffset';
|
||||
import spanAncestorIdsSpy from '../utils/span-ancestor-ids';
|
||||
|
||||
jest.mock('../utils/span-ancestor-ids');
|
||||
|
||||
describe('SpanTreeOffset', () => {
|
||||
const ownSpanID = 'ownSpanID';
|
||||
const parentSpanID = 'parentSpanID';
|
||||
const rootSpanID = 'rootSpanID';
|
||||
const specialRootID = 'root';
|
||||
let props;
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock implementation instead of Mock return value so that each call returns a new array (like normal)
|
||||
spanAncestorIdsSpy.mockImplementation(() => [parentSpanID, rootSpanID]);
|
||||
props = {
|
||||
addHoverIndentGuideId: jest.fn(),
|
||||
hoverIndentGuideIds: new Set(),
|
||||
removeHoverIndentGuideId: jest.fn(),
|
||||
span: {
|
||||
hasChildren: false,
|
||||
spanID: ownSpanID,
|
||||
},
|
||||
};
|
||||
wrapper = shallow(<SpanTreeOffset {...props} />);
|
||||
});
|
||||
|
||||
describe('.SpanTreeOffset--indentGuide', () => {
|
||||
it('renders only one .SpanTreeOffset--indentGuide for entire trace if span has no ancestors', () => {
|
||||
spanAncestorIdsSpy.mockReturnValue([]);
|
||||
wrapper = shallow(<SpanTreeOffset {...props} />);
|
||||
const indentGuides = wrapper.find('[data-test-id="SpanTreeOffset--indentGuide"]');
|
||||
expect(indentGuides.length).toBe(1);
|
||||
expect(indentGuides.prop('data-ancestor-id')).toBe(specialRootID);
|
||||
});
|
||||
|
||||
it('renders one .SpanTreeOffset--indentGuide per ancestor span, plus one for entire trace', () => {
|
||||
const indentGuides = wrapper.find('[data-test-id="SpanTreeOffset--indentGuide"]');
|
||||
expect(indentGuides.length).toBe(3);
|
||||
expect(indentGuides.at(0).prop('data-ancestor-id')).toBe(specialRootID);
|
||||
expect(indentGuides.at(1).prop('data-ancestor-id')).toBe(rootSpanID);
|
||||
expect(indentGuides.at(2).prop('data-ancestor-id')).toBe(parentSpanID);
|
||||
});
|
||||
|
||||
it('adds .is-active to correct indentGuide', () => {
|
||||
props.hoverIndentGuideIds = new Set([parentSpanID]);
|
||||
wrapper = shallow(<SpanTreeOffset {...props} />);
|
||||
const styles = getStyles();
|
||||
const activeIndentGuide = wrapper.find(`.${styles.indentGuideActive}`);
|
||||
expect(activeIndentGuide.length).toBe(1);
|
||||
expect(activeIndentGuide.prop('data-ancestor-id')).toBe(parentSpanID);
|
||||
});
|
||||
|
||||
it('calls props.addHoverIndentGuideId on mouse enter', () => {
|
||||
wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseenter', {});
|
||||
expect(props.addHoverIndentGuideId).toHaveBeenCalledTimes(1);
|
||||
expect(props.addHoverIndentGuideId).toHaveBeenCalledWith(parentSpanID);
|
||||
});
|
||||
|
||||
it('does not call props.addHoverIndentGuideId on mouse enter if mouse came from a indentGuide with the same ancestorId', () => {
|
||||
const relatedTarget = document.createElement('span');
|
||||
relatedTarget.dataset.ancestorId = parentSpanID;
|
||||
wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseenter', {
|
||||
relatedTarget,
|
||||
});
|
||||
expect(props.addHoverIndentGuideId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls props.removeHoverIndentGuideId on mouse leave', () => {
|
||||
wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseleave', {});
|
||||
expect(props.removeHoverIndentGuideId).toHaveBeenCalledTimes(1);
|
||||
expect(props.removeHoverIndentGuideId).toHaveBeenCalledWith(parentSpanID);
|
||||
});
|
||||
|
||||
it('does not call props.removeHoverIndentGuideId on mouse leave if mouse leaves to a indentGuide with the same ancestorId', () => {
|
||||
const relatedTarget = document.createElement('span');
|
||||
relatedTarget.dataset.ancestorId = parentSpanID;
|
||||
wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseleave', {
|
||||
relatedTarget,
|
||||
});
|
||||
expect(props.removeHoverIndentGuideId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('icon', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setProps({ span: { ...props.span, hasChildren: true } });
|
||||
});
|
||||
|
||||
it('does not render icon if props.span.hasChildren is false', () => {
|
||||
wrapper.setProps({ span: { ...props.span, hasChildren: false } });
|
||||
expect(wrapper.find(IoChevronRight).length).toBe(0);
|
||||
expect(wrapper.find(IoIosArrowDown).length).toBe(0);
|
||||
});
|
||||
|
||||
it('does not render icon if props.span.hasChildren is true and showChildrenIcon is false', () => {
|
||||
wrapper.setProps({ showChildrenIcon: false });
|
||||
expect(wrapper.find(IoChevronRight).length).toBe(0);
|
||||
expect(wrapper.find(IoIosArrowDown).length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders IoChevronRight if props.span.hasChildren is true and props.childrenVisible is false', () => {
|
||||
expect(wrapper.find(IoChevronRight).length).toBe(1);
|
||||
expect(wrapper.find(IoIosArrowDown).length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders IoIosArrowDown if props.span.hasChildren is true and props.childrenVisible is true', () => {
|
||||
wrapper.setProps({ childrenVisible: true });
|
||||
expect(wrapper.find(IoChevronRight).length).toBe(0);
|
||||
expect(wrapper.find(IoIosArrowDown).length).toBe(1);
|
||||
});
|
||||
|
||||
it('calls props.addHoverIndentGuideId on mouse enter', () => {
|
||||
wrapper.find('[data-test-id="icon-wrapper"]').simulate('mouseenter', {});
|
||||
expect(props.addHoverIndentGuideId).toHaveBeenCalledTimes(1);
|
||||
expect(props.addHoverIndentGuideId).toHaveBeenCalledWith(ownSpanID);
|
||||
});
|
||||
|
||||
it('calls props.removeHoverIndentGuideId on mouse leave', () => {
|
||||
wrapper.find('[data-test-id="icon-wrapper"]').simulate('mouseleave', {});
|
||||
expect(props.removeHoverIndentGuideId).toHaveBeenCalledTimes(1);
|
||||
expect(props.removeHoverIndentGuideId).toHaveBeenCalledWith(ownSpanID);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import _get from 'lodash/get';
|
||||
import IoChevronRight from 'react-icons/lib/io/chevron-right';
|
||||
import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { Span } from '../types/trace';
|
||||
import spanAncestorIds from '../utils/span-ancestor-ids';
|
||||
|
||||
import { createStyle } from '../Theme';
|
||||
|
||||
export const getStyles = createStyle(() => {
|
||||
return {
|
||||
SpanTreeOffset: css`
|
||||
label: SpanTreeOffset;
|
||||
color: #000;
|
||||
position: relative;
|
||||
`,
|
||||
SpanTreeOffsetParent: css`
|
||||
label: SpanTreeOffsetParent;
|
||||
&:hover {
|
||||
background-color: #e8e8e8;
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
indentGuide: css`
|
||||
label: indentGuide;
|
||||
/* The size of the indentGuide is based off of the iconWrapper */
|
||||
padding-right: calc(0.5rem + 12px);
|
||||
height: 100%;
|
||||
border-left: 1px solid transparent;
|
||||
display: inline-flex;
|
||||
&::before {
|
||||
content: '';
|
||||
padding-left: 1px;
|
||||
background-color: lightgrey;
|
||||
}
|
||||
`,
|
||||
indentGuideActive: css`
|
||||
label: indentGuideActive;
|
||||
padding-right: calc(0.5rem + 11px);
|
||||
border-left: 0px;
|
||||
&::before {
|
||||
content: '';
|
||||
padding-left: 3px;
|
||||
background-color: darkgrey;
|
||||
}
|
||||
`,
|
||||
iconWrapper: css`
|
||||
label: iconWrapper;
|
||||
position: absolute;
|
||||
right: 0.25rem;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type TProps = {
|
||||
childrenVisible?: boolean;
|
||||
onClick?: () => void;
|
||||
span: Span;
|
||||
showChildrenIcon?: boolean;
|
||||
|
||||
hoverIndentGuideIds: Set<string>;
|
||||
addHoverIndentGuideId: (spanID: string) => void;
|
||||
removeHoverIndentGuideId: (spanID: string) => void;
|
||||
};
|
||||
|
||||
export default class SpanTreeOffset extends React.PureComponent<TProps> {
|
||||
ancestorIds: string[];
|
||||
|
||||
static defaultProps = {
|
||||
childrenVisible: false,
|
||||
showChildrenIcon: true,
|
||||
};
|
||||
|
||||
constructor(props: TProps) {
|
||||
super(props);
|
||||
|
||||
this.ancestorIds = spanAncestorIds(props.span);
|
||||
// Some traces have multiple root-level spans, this connects them all under one guideline and adds the
|
||||
// necessary padding for the collapse icon on root-level spans.
|
||||
this.ancestorIds.push('root');
|
||||
|
||||
this.ancestorIds.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* If the mouse leaves to anywhere except another span with the same ancestor id, this span's ancestor id is
|
||||
* removed from the set of hoverIndentGuideIds.
|
||||
*
|
||||
* @param {Object} event - React Synthetic event tied to mouseleave. Includes the related target which is
|
||||
* the element the user is now hovering.
|
||||
* @param {string} ancestorId - The span id that the user was hovering over.
|
||||
*/
|
||||
handleMouseLeave = (event: React.MouseEvent<HTMLSpanElement>, ancestorId: string) => {
|
||||
if (
|
||||
!(event.relatedTarget instanceof HTMLSpanElement) ||
|
||||
_get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId
|
||||
) {
|
||||
this.props.removeHoverIndentGuideId(ancestorId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* If the mouse entered this span from anywhere except another span with the same ancestor id, this span's
|
||||
* ancestorId is added to the set of hoverIndentGuideIds.
|
||||
*
|
||||
* @param {Object} event - React Synthetic event tied to mouseenter. Includes the related target which is
|
||||
* the last element the user was hovering.
|
||||
* @param {string} ancestorId - The span id that the user is now hovering over.
|
||||
*/
|
||||
handleMouseEnter = (event: React.MouseEvent<HTMLSpanElement>, ancestorId: string) => {
|
||||
if (
|
||||
!(event.relatedTarget instanceof HTMLSpanElement) ||
|
||||
_get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId
|
||||
) {
|
||||
this.props.addHoverIndentGuideId(ancestorId);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { childrenVisible, onClick, showChildrenIcon, span } = this.props;
|
||||
const { hasChildren, spanID } = span;
|
||||
const wrapperProps = hasChildren ? { onClick, role: 'switch', 'aria-checked': childrenVisible } : null;
|
||||
const icon = showChildrenIcon && hasChildren && (childrenVisible ? <IoIosArrowDown /> : <IoChevronRight />);
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<span className={cx(styles.SpanTreeOffset, { [styles.SpanTreeOffsetParent]: hasChildren })} {...wrapperProps}>
|
||||
{this.ancestorIds.map(ancestorId => (
|
||||
<span
|
||||
key={ancestorId}
|
||||
className={cx(styles.indentGuide, {
|
||||
[styles.indentGuideActive]: this.props.hoverIndentGuideIds.has(ancestorId),
|
||||
})}
|
||||
data-ancestor-id={ancestorId}
|
||||
data-test-id="SpanTreeOffset--indentGuide"
|
||||
onMouseEnter={event => this.handleMouseEnter(event, ancestorId)}
|
||||
onMouseLeave={event => this.handleMouseLeave(event, ancestorId)}
|
||||
/>
|
||||
))}
|
||||
{icon && (
|
||||
<span
|
||||
className={styles.iconWrapper}
|
||||
onMouseEnter={event => this.handleMouseEnter(event, spanID)}
|
||||
onMouseLeave={event => this.handleMouseLeave(event, spanID)}
|
||||
data-test-id="icon-wrapper"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import Ticks from './Ticks';
|
||||
|
||||
describe('<Ticks>', () => {
|
||||
it('renders without exploding', () => {
|
||||
const wrapper = shallow(<Ticks endTime={200} numTicks={5} showLabels startTime={100} />);
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { formatDuration } from './utils';
|
||||
import { TNil } from '../types';
|
||||
import { createStyle } from '../Theme';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
Ticks: css`
|
||||
pointer-events: none;
|
||||
`,
|
||||
tick: css`
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: #d8d8d8;
|
||||
&:last-child {
|
||||
width: 0;
|
||||
}
|
||||
`,
|
||||
tickLabel: css`
|
||||
left: 0.25rem;
|
||||
position: absolute;
|
||||
`,
|
||||
tickLabelEndAnchor: css`
|
||||
left: initial;
|
||||
right: 0.25rem;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type TicksProps = {
|
||||
endTime?: number | TNil;
|
||||
numTicks: number;
|
||||
showLabels?: boolean | TNil;
|
||||
startTime?: number | TNil;
|
||||
};
|
||||
|
||||
export default function Ticks(props: TicksProps) {
|
||||
const { endTime, numTicks, showLabels, startTime } = props;
|
||||
|
||||
let labels: undefined | string[];
|
||||
if (showLabels) {
|
||||
labels = [];
|
||||
const viewingDuration = (endTime || 0) - (startTime || 0);
|
||||
for (let i = 0; i < numTicks; i++) {
|
||||
const durationAtTick = (startTime || 0) + (i / (numTicks - 1)) * viewingDuration;
|
||||
labels.push(formatDuration(durationAtTick));
|
||||
}
|
||||
}
|
||||
const styles = getStyles();
|
||||
const ticks: React.ReactNode[] = [];
|
||||
for (let i = 0; i < numTicks; i++) {
|
||||
const portion = i / (numTicks - 1);
|
||||
ticks.push(
|
||||
<div
|
||||
key={portion}
|
||||
className={styles.tick}
|
||||
style={{
|
||||
left: `${portion * 100}%`,
|
||||
}}
|
||||
>
|
||||
{labels && (
|
||||
<span className={cx(styles.tickLabel, { [styles.tickLabelEndAnchor]: portion >= 1 })}>{labels[i]}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className={styles.Ticks}>{ticks}</div>;
|
||||
}
|
||||
|
||||
Ticks.defaultProps = {
|
||||
endTime: null,
|
||||
showLabels: null,
|
||||
startTime: null,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import TimelineCollapser from './TimelineCollapser';
|
||||
|
||||
describe('<TimelineCollapser>', () => {
|
||||
it('renders without exploding', () => {
|
||||
const props = {
|
||||
onCollapseAll: () => {},
|
||||
onCollapseOne: () => {},
|
||||
onExpandAll: () => {},
|
||||
onExpandOne: () => {},
|
||||
};
|
||||
const wrapper = shallow(<TimelineCollapser {...props} />);
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper.find('[data-test-id="TimelineCollapser"]').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { UITooltip, UIIcon } from '../../uiElementsContext';
|
||||
import { createStyle } from '../../Theme';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
TraceTimelineViewer: css`
|
||||
border-bottom: 1px solid #bbb;
|
||||
`,
|
||||
TimelineCollapser: css`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
margin-right: 0.5rem;
|
||||
`,
|
||||
tooltipTitle: css`
|
||||
white-space: pre;
|
||||
`,
|
||||
btn: css`
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
margin-right: 0.3rem;
|
||||
padding: 0.1rem;
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
`,
|
||||
btnExpanded: css`
|
||||
transform: rotate(90deg);
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type CollapserProps = {
|
||||
onCollapseAll: () => void;
|
||||
onCollapseOne: () => void;
|
||||
onExpandOne: () => void;
|
||||
onExpandAll: () => void;
|
||||
};
|
||||
|
||||
function getTitle(value: string) {
|
||||
const styles = getStyles();
|
||||
return <span className={styles.tooltipTitle}>{value}</span>;
|
||||
}
|
||||
|
||||
export default class TimelineCollapser extends React.PureComponent<CollapserProps> {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
|
||||
constructor(props: CollapserProps) {
|
||||
super(props);
|
||||
this.containerRef = React.createRef();
|
||||
}
|
||||
|
||||
// TODO: Something less hacky than createElement to help TypeScript / AntD
|
||||
getContainer = () => this.containerRef.current || document.createElement('div');
|
||||
|
||||
render() {
|
||||
const { onExpandAll, onExpandOne, onCollapseAll, onCollapseOne } = this.props;
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<div className={styles.TimelineCollapser} ref={this.containerRef} data-test-id="TimelineCollapser">
|
||||
<UITooltip title={getTitle('Expand +1')} getPopupContainer={this.getContainer}>
|
||||
<UIIcon type="right" onClick={onExpandOne} className={cx(styles.btn, styles.btnExpanded)} />
|
||||
</UITooltip>
|
||||
<UITooltip title={getTitle('Collapse +1')} getPopupContainer={this.getContainer}>
|
||||
<UIIcon type="right" onClick={onCollapseOne} className={styles.btn} />
|
||||
</UITooltip>
|
||||
<UITooltip title={getTitle('Expand All')} getPopupContainer={this.getContainer}>
|
||||
<UIIcon type="double-right" onClick={onExpandAll} className={cx(styles.btn, styles.btnExpanded)} />
|
||||
</UITooltip>
|
||||
<UITooltip title={getTitle('Collapse All')} getPopupContainer={this.getContainer}>
|
||||
<UIIcon type="double-right" onClick={onCollapseAll} className={styles.btn} />
|
||||
</UITooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import cx from 'classnames';
|
||||
|
||||
import TimelineColumnResizer, { getStyles } from './TimelineColumnResizer';
|
||||
|
||||
describe('<TimelineColumnResizer>', () => {
|
||||
let wrapper;
|
||||
let instance;
|
||||
|
||||
const props = {
|
||||
min: 0.1,
|
||||
max: 0.9,
|
||||
onChange: jest.fn(),
|
||||
position: 0.5,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props.onChange.mockReset();
|
||||
wrapper = mount(<TimelineColumnResizer {...props} />);
|
||||
instance = wrapper.instance();
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper.find('[data-test-id="TimelineColumnResizer"]').length).toBe(1);
|
||||
expect(wrapper.find('[data-test-id="TimelineColumnResizer--gripIcon"]').length).toBe(1);
|
||||
expect(wrapper.find('[data-test-id="TimelineColumnResizer--dragger"]').length).toBe(1);
|
||||
});
|
||||
|
||||
it('sets the root elm', () => {
|
||||
const rootWrapper = wrapper.find('[data-test-id="TimelineColumnResizer"]');
|
||||
expect(rootWrapper.getDOMNode()).toBe(instance._rootElm);
|
||||
});
|
||||
|
||||
describe('uses DraggableManager', () => {
|
||||
it('handles mouse down on the dragger', () => {
|
||||
const dragger = wrapper.find({ onMouseDown: instance._dragManager.handleMouseDown });
|
||||
expect(dragger.length).toBe(1);
|
||||
expect(dragger.is('[data-test-id="TimelineColumnResizer--dragger"]')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns the draggable bounds via _getDraggingBounds()', () => {
|
||||
const left = 10;
|
||||
const width = 100;
|
||||
instance._rootElm.getBoundingClientRect = () => ({ left, width });
|
||||
expect(instance._getDraggingBounds()).toEqual({
|
||||
width,
|
||||
clientXLeft: left,
|
||||
maxValue: props.max,
|
||||
minValue: props.min,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles drag start', () => {
|
||||
const value = Math.random();
|
||||
expect(wrapper.state('dragPosition')).toBe(null);
|
||||
instance._handleDragUpdate({ value });
|
||||
expect(wrapper.state('dragPosition')).toBe(value);
|
||||
});
|
||||
|
||||
it('handles drag end', () => {
|
||||
const manager = { resetBounds: jest.fn() };
|
||||
const value = Math.random();
|
||||
wrapper.setState({ dragPosition: 2 * value });
|
||||
instance._handleDragEnd({ manager, value });
|
||||
expect(manager.resetBounds.mock.calls).toEqual([[]]);
|
||||
expect(wrapper.state('dragPosition')).toBe(null);
|
||||
expect(props.onChange.mock.calls).toEqual([[value]]);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render a dragging indicator when not dragging', () => {
|
||||
const styles = getStyles();
|
||||
expect(wrapper.find('[data-test-id="TimelineColumnResizer--dragger"]').prop('style').right).toBe(
|
||||
undefined
|
||||
);
|
||||
expect(wrapper.find('[data-test-id="TimelineColumnResizer--dragger"]').prop('className')).toBe(
|
||||
styles.dragger
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a dragging indicator when dragging', () => {
|
||||
instance._dragManager.isDragging = () => true;
|
||||
instance._handleDragUpdate({ value: props.min });
|
||||
instance.forceUpdate();
|
||||
wrapper.update();
|
||||
expect(wrapper.find('[data-test-id="TimelineColumnResizer--dragger"]').prop('style').right).toBeDefined();
|
||||
|
||||
const styles = getStyles();
|
||||
expect(wrapper.find('[data-test-id="TimelineColumnResizer--dragger"]').prop('className')).toBe(
|
||||
cx(styles.dragger, styles.draggerDragging, styles.draggerDraggingLeft)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { TNil } from '../../types';
|
||||
import DraggableManager, { DraggableBounds, DraggingUpdate } from '../../utils/DraggableManager';
|
||||
import { createStyle } from '../../Theme';
|
||||
|
||||
export const getStyles = createStyle(() => {
|
||||
return {
|
||||
TimelineColumnResizer: css`
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
`,
|
||||
wrapper: css`
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
`,
|
||||
dragger: css`
|
||||
border-left: 2px solid transparent;
|
||||
cursor: col-resize;
|
||||
height: 5000px;
|
||||
margin-left: -1px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
z-index: 10;
|
||||
&:hover {
|
||||
border-left: 2px solid rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -8px;
|
||||
right: 0;
|
||||
content: ' ';
|
||||
}
|
||||
`,
|
||||
draggerDragging: css`
|
||||
background: rgba(136, 0, 136, 0.05);
|
||||
width: unset;
|
||||
&::before {
|
||||
left: -2000px;
|
||||
right: -2000px;
|
||||
}
|
||||
`,
|
||||
draggerDraggingLeft: css`
|
||||
border-left: 2px solid #808;
|
||||
border-right: 1px solid #999;
|
||||
`,
|
||||
draggerDraggingRight: css`
|
||||
border-left: 1px solid #999;
|
||||
border-right: 2px solid #808;
|
||||
`,
|
||||
gripIcon: css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
&::before,
|
||||
&::after {
|
||||
border-right: 1px solid #ccc;
|
||||
content: ' ';
|
||||
height: 9px;
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
top: 25px;
|
||||
}
|
||||
&::after {
|
||||
right: 5px;
|
||||
}
|
||||
`,
|
||||
gripIconDragging: css`
|
||||
&::before,
|
||||
&::after {
|
||||
border-right: 1px solid rgba(136, 0, 136, 0.5);
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type TimelineColumnResizerProps = {
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (newSize: number) => void;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type TimelineColumnResizerState = {
|
||||
dragPosition: number | TNil;
|
||||
};
|
||||
|
||||
export default class TimelineColumnResizer extends React.PureComponent<
|
||||
TimelineColumnResizerProps,
|
||||
TimelineColumnResizerState
|
||||
> {
|
||||
state: TimelineColumnResizerState;
|
||||
|
||||
_dragManager: DraggableManager;
|
||||
_rootElm: Element | TNil;
|
||||
|
||||
constructor(props: TimelineColumnResizerProps) {
|
||||
super(props);
|
||||
this._dragManager = new DraggableManager({
|
||||
getBounds: this._getDraggingBounds,
|
||||
onDragEnd: this._handleDragEnd,
|
||||
onDragMove: this._handleDragUpdate,
|
||||
onDragStart: this._handleDragUpdate,
|
||||
});
|
||||
this._rootElm = undefined;
|
||||
this.state = {
|
||||
dragPosition: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._dragManager.dispose();
|
||||
}
|
||||
|
||||
_setRootElm = (elm: Element | TNil) => {
|
||||
this._rootElm = elm;
|
||||
};
|
||||
|
||||
_getDraggingBounds = (): DraggableBounds => {
|
||||
if (!this._rootElm) {
|
||||
throw new Error('invalid state');
|
||||
}
|
||||
const { left: clientXLeft, width } = this._rootElm.getBoundingClientRect();
|
||||
const { min, max } = this.props;
|
||||
return {
|
||||
clientXLeft,
|
||||
width,
|
||||
maxValue: max,
|
||||
minValue: min,
|
||||
};
|
||||
};
|
||||
|
||||
_handleDragUpdate = ({ value }: DraggingUpdate) => {
|
||||
this.setState({ dragPosition: value });
|
||||
};
|
||||
|
||||
_handleDragEnd = ({ manager, value }: DraggingUpdate) => {
|
||||
manager.resetBounds();
|
||||
this.setState({ dragPosition: null });
|
||||
this.props.onChange(value);
|
||||
};
|
||||
|
||||
render() {
|
||||
let left;
|
||||
let draggerStyle;
|
||||
const { position } = this.props;
|
||||
const { dragPosition } = this.state;
|
||||
left = `${position * 100}%`;
|
||||
const gripStyle = { left };
|
||||
let isDraggingLeft = false;
|
||||
let isDraggingRight = false;
|
||||
const styles = getStyles();
|
||||
|
||||
if (this._dragManager.isDragging() && this._rootElm && dragPosition != null) {
|
||||
isDraggingLeft = dragPosition < position;
|
||||
isDraggingRight = dragPosition > position;
|
||||
left = `${dragPosition * 100}%`;
|
||||
// Draw a highlight from the current dragged position back to the original
|
||||
// position, e.g. highlight the change. Draw the highlight via `left` and
|
||||
// `right` css styles (simpler than using `width`).
|
||||
const draggerLeft = `${Math.min(position, dragPosition) * 100}%`;
|
||||
// subtract 1px for draggerRight to deal with the right border being off
|
||||
// by 1px when dragging left
|
||||
const draggerRight = `calc(${(1 - Math.max(position, dragPosition)) * 100}% - 1px)`;
|
||||
draggerStyle = { left: draggerLeft, right: draggerRight };
|
||||
} else {
|
||||
draggerStyle = gripStyle;
|
||||
}
|
||||
|
||||
const isDragging = isDraggingLeft || isDraggingRight;
|
||||
return (
|
||||
<div className={styles.TimelineColumnResizer} ref={this._setRootElm} data-test-id="TimelineColumnResizer">
|
||||
<div
|
||||
className={cx(styles.gripIcon, isDragging && styles.gripIconDragging)}
|
||||
style={gripStyle}
|
||||
data-test-id="TimelineColumnResizer--gripIcon"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cx(
|
||||
styles.dragger,
|
||||
isDragging && styles.draggerDragging,
|
||||
isDraggingRight && styles.draggerDraggingRight,
|
||||
isDraggingLeft && styles.draggerDraggingLeft
|
||||
)}
|
||||
onMouseDown={this._dragManager.handleMouseDown}
|
||||
style={draggerStyle}
|
||||
data-test-id="TimelineColumnResizer--dragger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import TimelineHeaderRow from './TimelineHeaderRow';
|
||||
import TimelineColumnResizer from './TimelineColumnResizer';
|
||||
import TimelineViewingLayer from './TimelineViewingLayer';
|
||||
import Ticks from '../Ticks';
|
||||
import TimelineCollapser from './TimelineCollapser';
|
||||
|
||||
describe('<TimelineHeaderRow>', () => {
|
||||
let wrapper;
|
||||
|
||||
const nameColumnWidth = 0.25;
|
||||
const props = {
|
||||
nameColumnWidth,
|
||||
duration: 1234,
|
||||
numTicks: 5,
|
||||
onCollapseAll: () => {},
|
||||
onCollapseOne: () => {},
|
||||
onColummWidthChange: () => {},
|
||||
onExpandAll: () => {},
|
||||
onExpandOne: () => {},
|
||||
updateNextViewRangeTime: () => {},
|
||||
updateViewRangeTime: () => {},
|
||||
viewRangeTime: {
|
||||
current: [0.1, 0.9],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<TimelineHeaderRow {...props} />);
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper.find('[data-test-id="TimelineHeaderRow"]').length).toBe(1);
|
||||
});
|
||||
|
||||
it('propagates the name column width', () => {
|
||||
const nameCol = wrapper.find({ width: nameColumnWidth });
|
||||
const timelineCol = wrapper.find({ width: 1 - nameColumnWidth });
|
||||
expect(nameCol.length).toBe(1);
|
||||
expect(timelineCol.length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders the title', () => {
|
||||
expect(wrapper.find('h3').text()).toMatch(/Service.*?Operation/);
|
||||
});
|
||||
|
||||
it('renders the TimelineViewingLayer', () => {
|
||||
const elm = (
|
||||
<TimelineViewingLayer
|
||||
boundsInvalidator={nameColumnWidth}
|
||||
updateNextViewRangeTime={props.updateNextViewRangeTime}
|
||||
updateViewRangeTime={props.updateViewRangeTime}
|
||||
viewRangeTime={props.viewRangeTime}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.containsMatchingElement(elm)).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the Ticks', () => {
|
||||
const [viewStart, viewEnd] = props.viewRangeTime.current;
|
||||
const elm = (
|
||||
<Ticks
|
||||
numTicks={props.numTicks}
|
||||
startTime={viewStart * props.duration}
|
||||
endTime={viewEnd * props.duration}
|
||||
showLabels
|
||||
/>
|
||||
);
|
||||
expect(wrapper.containsMatchingElement(elm)).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the TimelineColumnResizer', () => {
|
||||
const elm = (
|
||||
<TimelineColumnResizer
|
||||
position={nameColumnWidth}
|
||||
onChange={props.onColummWidthChange}
|
||||
min={0.2}
|
||||
max={0.85}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.containsMatchingElement(elm)).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the TimelineCollapser', () => {
|
||||
const elm = (
|
||||
<TimelineCollapser
|
||||
onCollapseAll={props.onCollapseAll}
|
||||
onExpandAll={props.onExpandAll}
|
||||
onCollapseOne={props.onCollapseOne}
|
||||
onExpandOne={props.onExpandOne}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.containsMatchingElement(elm)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import TimelineCollapser from './TimelineCollapser';
|
||||
import TimelineColumnResizer from './TimelineColumnResizer';
|
||||
import TimelineViewingLayer from './TimelineViewingLayer';
|
||||
import Ticks from '../Ticks';
|
||||
import TimelineRow from '../TimelineRow';
|
||||
import { TUpdateViewRangeTimeFunction, ViewRangeTime, ViewRangeTimeUpdate } from '../types';
|
||||
import { createStyle } from '../../Theme';
|
||||
import { ubFlex, ubPx2 } from '../../uberUtilityStyles';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
TimelineHeaderRow: css`
|
||||
background: #ececec;
|
||||
border-bottom: 1px solid #ccc;
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
width: 100%;
|
||||
z-index: 4;
|
||||
`,
|
||||
title: css`
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type TimelineHeaderRowProps = {
|
||||
duration: number;
|
||||
nameColumnWidth: number;
|
||||
numTicks: number;
|
||||
onCollapseAll: () => void;
|
||||
onCollapseOne: () => void;
|
||||
onColummWidthChange: (width: number) => void;
|
||||
onExpandAll: () => void;
|
||||
onExpandOne: () => void;
|
||||
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
|
||||
updateViewRangeTime: TUpdateViewRangeTimeFunction;
|
||||
viewRangeTime: ViewRangeTime;
|
||||
};
|
||||
|
||||
export default function TimelineHeaderRow(props: TimelineHeaderRowProps) {
|
||||
const {
|
||||
duration,
|
||||
nameColumnWidth,
|
||||
numTicks,
|
||||
onCollapseAll,
|
||||
onCollapseOne,
|
||||
onColummWidthChange,
|
||||
onExpandAll,
|
||||
onExpandOne,
|
||||
updateViewRangeTime,
|
||||
updateNextViewRangeTime,
|
||||
viewRangeTime,
|
||||
} = props;
|
||||
const [viewStart, viewEnd] = viewRangeTime.current;
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<TimelineRow className={styles.TimelineHeaderRow} data-test-id="TimelineHeaderRow">
|
||||
<TimelineRow.Cell className={cx(ubFlex, ubPx2)} width={nameColumnWidth}>
|
||||
<h3 className={styles.TimelineHeaderRow}>Service & Operation</h3>
|
||||
<TimelineCollapser
|
||||
onCollapseAll={onCollapseAll}
|
||||
onExpandAll={onExpandAll}
|
||||
onCollapseOne={onCollapseOne}
|
||||
onExpandOne={onExpandOne}
|
||||
/>
|
||||
</TimelineRow.Cell>
|
||||
<TimelineRow.Cell width={1 - nameColumnWidth}>
|
||||
<TimelineViewingLayer
|
||||
boundsInvalidator={nameColumnWidth}
|
||||
updateNextViewRangeTime={updateNextViewRangeTime}
|
||||
updateViewRangeTime={updateViewRangeTime}
|
||||
viewRangeTime={viewRangeTime}
|
||||
/>
|
||||
<Ticks numTicks={numTicks} startTime={viewStart * duration} endTime={viewEnd * duration} showLabels />
|
||||
</TimelineRow.Cell>
|
||||
<TimelineColumnResizer position={nameColumnWidth} onChange={onColummWidthChange} min={0.2} max={0.85} />
|
||||
</TimelineRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { cx } from 'emotion';
|
||||
|
||||
import TimelineViewingLayer, { getStyles } from './TimelineViewingLayer';
|
||||
|
||||
function mapFromSubRange(viewStart, viewEnd, value) {
|
||||
return viewStart + value * (viewEnd - viewStart);
|
||||
}
|
||||
|
||||
describe('<TimelineViewingLayer>', () => {
|
||||
let wrapper;
|
||||
let instance;
|
||||
|
||||
const viewStart = 0.25;
|
||||
const viewEnd = 0.9;
|
||||
const props = {
|
||||
boundsInvalidator: Math.random(),
|
||||
updateNextViewRangeTime: jest.fn(),
|
||||
updateViewRangeTime: jest.fn(),
|
||||
viewRangeTime: {
|
||||
current: [viewStart, viewEnd],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props.updateNextViewRangeTime.mockReset();
|
||||
props.updateViewRangeTime.mockReset();
|
||||
wrapper = mount(<TimelineViewingLayer {...props} />);
|
||||
instance = wrapper.instance();
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper.find('[data-test-id="TimelineViewingLayer"]').length).toBe(1);
|
||||
});
|
||||
|
||||
it('sets _root to the root DOM node', () => {
|
||||
expect(instance._root).toBeDefined();
|
||||
expect(wrapper.find('[data-test-id="TimelineViewingLayer"]').getDOMNode()).toBe(instance._root);
|
||||
});
|
||||
|
||||
describe('uses DraggableManager', () => {
|
||||
it('initializes the DraggableManager', () => {
|
||||
const dm = instance._draggerReframe;
|
||||
expect(dm).toBeDefined();
|
||||
expect(dm._onMouseMove).toBe(instance._handleReframeMouseMove);
|
||||
expect(dm._onMouseLeave).toBe(instance._handleReframeMouseLeave);
|
||||
expect(dm._onDragStart).toBe(instance._handleReframeDragUpdate);
|
||||
expect(dm._onDragMove).toBe(instance._handleReframeDragUpdate);
|
||||
expect(dm._onDragEnd).toBe(instance._handleReframeDragEnd);
|
||||
});
|
||||
|
||||
it('provides the DraggableManager handlers as callbacks', () => {
|
||||
const { handleMouseDown, handleMouseLeave, handleMouseMove } = instance._draggerReframe;
|
||||
const rootWrapper = wrapper.find('[data-test-id="TimelineViewingLayer"]');
|
||||
expect(rootWrapper.prop('onMouseDown')).toBe(handleMouseDown);
|
||||
expect(rootWrapper.prop('onMouseLeave')).toBe(handleMouseLeave);
|
||||
expect(rootWrapper.prop('onMouseMove')).toBe(handleMouseMove);
|
||||
});
|
||||
|
||||
it('returns the dragging bounds from _getDraggingBounds()', () => {
|
||||
const left = 10;
|
||||
const width = 100;
|
||||
instance._root.getBoundingClientRect = () => ({ left, width });
|
||||
expect(instance._getDraggingBounds()).toEqual({ width, clientXLeft: left });
|
||||
});
|
||||
|
||||
it('updates viewRange.time.cursor via _draggerReframe._onMouseMove', () => {
|
||||
const value = 0.5;
|
||||
const cursor = mapFromSubRange(viewStart, viewEnd, value);
|
||||
instance._draggerReframe._onMouseMove({ value });
|
||||
expect(props.updateNextViewRangeTime.mock.calls).toEqual([[{ cursor }]]);
|
||||
});
|
||||
|
||||
it('resets viewRange.time.cursor via _draggerReframe._onMouseLeave', () => {
|
||||
instance._draggerReframe._onMouseLeave();
|
||||
expect(props.updateNextViewRangeTime.mock.calls).toEqual([[{ cursor: undefined }]]);
|
||||
});
|
||||
|
||||
it('handles drag start via _draggerReframe._onDragStart', () => {
|
||||
const value = 0.5;
|
||||
const shift = mapFromSubRange(viewStart, viewEnd, value);
|
||||
const update = { reframe: { shift, anchor: shift } };
|
||||
instance._draggerReframe._onDragStart({ value });
|
||||
expect(props.updateNextViewRangeTime.mock.calls).toEqual([[update]]);
|
||||
});
|
||||
|
||||
it('handles drag move via _draggerReframe._onDragMove', () => {
|
||||
const anchor = 0.25;
|
||||
const viewRangeTime = { ...props.viewRangeTime, reframe: { anchor, shift: Math.random() } };
|
||||
const value = 0.5;
|
||||
const shift = mapFromSubRange(viewStart, viewEnd, value);
|
||||
// make sure `anchor` is already present on the props
|
||||
wrapper.setProps({ viewRangeTime });
|
||||
expect(wrapper.prop('viewRangeTime').reframe.anchor).toBe(anchor);
|
||||
// the next update should integrate `value` and use the existing anchor
|
||||
instance._draggerReframe._onDragStart({ value });
|
||||
const update = { reframe: { anchor, shift } };
|
||||
expect(props.updateNextViewRangeTime.mock.calls).toEqual([[update]]);
|
||||
});
|
||||
|
||||
it('handles drag end via _draggerReframe._onDragEnd', () => {
|
||||
const manager = { resetBounds: jest.fn() };
|
||||
const value = 0.5;
|
||||
const shift = mapFromSubRange(viewStart, viewEnd, value);
|
||||
const anchor = 0.25;
|
||||
const viewRangeTime = { ...props.viewRangeTime, reframe: { anchor, shift: Math.random() } };
|
||||
wrapper.setProps({ viewRangeTime });
|
||||
instance._draggerReframe._onDragEnd({ manager, value });
|
||||
expect(manager.resetBounds.mock.calls).toEqual([[]]);
|
||||
expect(props.updateViewRangeTime.mock.calls).toEqual([[anchor, shift, 'timeline-header']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('render()', () => {
|
||||
it('renders nothing without a nextViewRangeTime', () => {
|
||||
expect(wrapper.find('div').length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders the cursor when it is the only non-current value set', () => {
|
||||
const cursor = viewStart + 0.5 * (viewEnd - viewStart);
|
||||
const baseViewRangeTime = { ...props.viewRangeTime, cursor };
|
||||
wrapper.setProps({ viewRangeTime: baseViewRangeTime });
|
||||
// cursor is rendered when solo
|
||||
expect(wrapper.find('[data-test-id="TimelineViewingLayer--cursorGuide"]').length).toBe(1);
|
||||
// cursor is skipped when shiftStart, shiftEnd, or reframe are present
|
||||
let viewRangeTime = { ...baseViewRangeTime, shiftStart: cursor };
|
||||
wrapper.setProps({ viewRangeTime });
|
||||
expect(wrapper.find('[data-test-id="TimelineViewingLayer--cursorGuide"]').length).toBe(0);
|
||||
viewRangeTime = { ...baseViewRangeTime, shiftEnd: cursor };
|
||||
wrapper.setProps({ viewRangeTime });
|
||||
expect(wrapper.find('[data-test-id="TimelineViewingLayer--cursorGuide"]').length).toBe(0);
|
||||
viewRangeTime = { ...baseViewRangeTime, reframe: { anchor: cursor, shift: cursor } };
|
||||
wrapper.setProps({ viewRangeTime });
|
||||
expect(wrapper.find('[data-test-id="TimelineViewingLayer--cursorGuide"]').length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders the reframe dragging', () => {
|
||||
const viewRangeTime = { ...props.viewRangeTime, reframe: { anchor: viewStart, shift: viewEnd } };
|
||||
wrapper.setProps({ viewRangeTime });
|
||||
const styles = getStyles();
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-id="Dragged"]')
|
||||
.prop('className')
|
||||
.indexOf(
|
||||
cx(
|
||||
styles.dragged,
|
||||
styles.draggedDraggingLeft,
|
||||
styles.draggedDraggingRight,
|
||||
styles.draggedReframeDrag
|
||||
)
|
||||
) >= 0
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the shiftStart dragging', () => {
|
||||
const viewRangeTime = { ...props.viewRangeTime, shiftStart: viewEnd };
|
||||
wrapper.setProps({ viewRangeTime });
|
||||
const styles = getStyles();
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-id="Dragged"]')
|
||||
.prop('className')
|
||||
.indexOf(
|
||||
cx(
|
||||
styles.dragged,
|
||||
styles.draggedDraggingLeft,
|
||||
styles.draggedDraggingRight,
|
||||
styles.draggedShiftDrag
|
||||
)
|
||||
) >= 0
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the shiftEnd dragging', () => {
|
||||
const viewRangeTime = { ...props.viewRangeTime, shiftEnd: viewStart };
|
||||
wrapper.setProps({ viewRangeTime });
|
||||
// expect(wrapper.find('.isDraggingLeft.isShiftDrag').length).toBe(1);
|
||||
const styles = getStyles();
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-id="Dragged"]')
|
||||
.prop('className')
|
||||
.indexOf(cx(styles.dragged, styles.draggedDraggingLeft, styles.draggedShiftDrag)) >= 0
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
|
||||
import { TUpdateViewRangeTimeFunction, ViewRangeTime, ViewRangeTimeUpdate } from '../types';
|
||||
import { TNil } from '../../types';
|
||||
import DraggableManager, { DraggableBounds, DraggingUpdate } from '../../utils/DraggableManager';
|
||||
import { createStyle } from '../../Theme';
|
||||
|
||||
// exported for testing
|
||||
export const getStyles = createStyle(() => {
|
||||
return {
|
||||
TimelineViewingLayer: css`
|
||||
bottom: 0;
|
||||
cursor: vertical-text;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
`,
|
||||
cursorGuide: css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
background-color: red;
|
||||
`,
|
||||
dragged: css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
`,
|
||||
draggedDraggingLeft: css`
|
||||
border-left: 1px solid;
|
||||
`,
|
||||
draggedDraggingRight: css`
|
||||
border-right: 1px solid;
|
||||
`,
|
||||
draggedShiftDrag: css`
|
||||
background-color: rgba(68, 68, 255, 0.2);
|
||||
border-color: #44f;
|
||||
`,
|
||||
draggedReframeDrag: css`
|
||||
background-color: rgba(255, 68, 68, 0.2);
|
||||
border-color: #f44;
|
||||
`,
|
||||
fullOverlay: css`
|
||||
bottom: 0;
|
||||
cursor: col-resize;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
user-select: none;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type TimelineViewingLayerProps = {
|
||||
/**
|
||||
* `boundsInvalidator` is an arbitrary prop that lets the component know the
|
||||
* bounds for dragging need to be recalculated. In practice, the name column
|
||||
* width serves fine for this.
|
||||
*/
|
||||
boundsInvalidator: any | null | undefined;
|
||||
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
|
||||
updateViewRangeTime: TUpdateViewRangeTimeFunction;
|
||||
viewRangeTime: ViewRangeTime;
|
||||
};
|
||||
|
||||
type TDraggingLeftLayout = {
|
||||
isDraggingLeft: boolean;
|
||||
left: string;
|
||||
width: string;
|
||||
};
|
||||
|
||||
type TOutOfViewLayout = {
|
||||
isOutOfView: true;
|
||||
};
|
||||
|
||||
function isOutOfView(layout: TDraggingLeftLayout | TOutOfViewLayout): layout is TOutOfViewLayout {
|
||||
return Reflect.has(layout, 'isOutOfView');
|
||||
}
|
||||
|
||||
/**
|
||||
* Map from a sub range to the greater view range, e.g, when the view range is
|
||||
* the middle half ([0.25, 0.75]), a value of 0.25 befomes 3/8.
|
||||
* @returns {number}
|
||||
*/
|
||||
function mapFromViewSubRange(viewStart: number, viewEnd: number, value: number) {
|
||||
return viewStart + value * (viewEnd - viewStart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a value from the view ([0, 1]) to a sub-range, e.g, when the view range is
|
||||
* the middle half ([0.25, 0.75]), a value of 3/8 becomes 1/4.
|
||||
* @returns {number}
|
||||
*/
|
||||
function mapToViewSubRange(viewStart: number, viewEnd: number, value: number) {
|
||||
return (value - viewStart) / (viewEnd - viewStart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the layout for the "next" view range time, e.g. the difference from the
|
||||
* drag start and the drag end. This is driven by `shiftStart`, `shiftEnd` or
|
||||
* `reframe` on `props.viewRangeTime`, not by the current state of the
|
||||
* component. So, it reflects in-progress dragging from the span minimap.
|
||||
*/
|
||||
function getNextViewLayout(start: number, position: number): TDraggingLeftLayout | TOutOfViewLayout {
|
||||
let [left, right] = start < position ? [start, position] : [position, start];
|
||||
if (left >= 1 || right <= 0) {
|
||||
return { isOutOfView: true };
|
||||
}
|
||||
if (left < 0) {
|
||||
left = 0;
|
||||
}
|
||||
if (right > 1) {
|
||||
right = 1;
|
||||
}
|
||||
return {
|
||||
isDraggingLeft: start > position,
|
||||
left: `${left * 100}%`,
|
||||
width: `${(right - left) * 100}%`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the visual indication of the "next" view range.
|
||||
*/
|
||||
function getMarkers(viewStart: number, viewEnd: number, from: number, to: number, isShift: boolean): React.ReactNode {
|
||||
const mappedFrom = mapToViewSubRange(viewStart, viewEnd, from);
|
||||
const mappedTo = mapToViewSubRange(viewStart, viewEnd, to);
|
||||
const layout = getNextViewLayout(mappedFrom, mappedTo);
|
||||
if (isOutOfView(layout)) {
|
||||
return null;
|
||||
}
|
||||
const { isDraggingLeft, left, width } = layout;
|
||||
const styles = getStyles();
|
||||
const cls = cx({
|
||||
[styles.draggedDraggingRight]: !isDraggingLeft,
|
||||
[styles.draggedReframeDrag]: !isShift,
|
||||
[styles.draggedShiftDrag]: isShift,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.dragged, styles.draggedDraggingLeft, cls)}
|
||||
style={{ left, width }}
|
||||
data-test-id="Dragged"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `TimelineViewingLayer` is rendered on top of the TimelineHeaderRow time
|
||||
* labels; it handles showing the current view range and handles mouse UX for
|
||||
* modifying it.
|
||||
*/
|
||||
export default class TimelineViewingLayer extends React.PureComponent<TimelineViewingLayerProps> {
|
||||
_draggerReframe: DraggableManager;
|
||||
_root: Element | TNil;
|
||||
|
||||
constructor(props: TimelineViewingLayerProps) {
|
||||
super(props);
|
||||
this._draggerReframe = new DraggableManager({
|
||||
getBounds: this._getDraggingBounds,
|
||||
onDragEnd: this._handleReframeDragEnd,
|
||||
onDragMove: this._handleReframeDragUpdate,
|
||||
onDragStart: this._handleReframeDragUpdate,
|
||||
onMouseLeave: this._handleReframeMouseLeave,
|
||||
onMouseMove: this._handleReframeMouseMove,
|
||||
});
|
||||
this._root = undefined;
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: TimelineViewingLayerProps) {
|
||||
const { boundsInvalidator } = this.props;
|
||||
if (boundsInvalidator !== nextProps.boundsInvalidator) {
|
||||
this._draggerReframe.resetBounds();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._draggerReframe.dispose();
|
||||
}
|
||||
|
||||
_setRoot = (elm: Element | TNil) => {
|
||||
this._root = elm;
|
||||
};
|
||||
|
||||
_getDraggingBounds = (): DraggableBounds => {
|
||||
if (!this._root) {
|
||||
throw new Error('invalid state');
|
||||
}
|
||||
const { left: clientXLeft, width } = this._root.getBoundingClientRect();
|
||||
return { clientXLeft, width };
|
||||
};
|
||||
|
||||
_handleReframeMouseMove = ({ value }: DraggingUpdate) => {
|
||||
const [viewStart, viewEnd] = this.props.viewRangeTime.current;
|
||||
const cursor = mapFromViewSubRange(viewStart, viewEnd, value);
|
||||
this.props.updateNextViewRangeTime({ cursor });
|
||||
};
|
||||
|
||||
_handleReframeMouseLeave = () => {
|
||||
this.props.updateNextViewRangeTime({ cursor: undefined });
|
||||
};
|
||||
|
||||
_handleReframeDragUpdate = ({ value }: DraggingUpdate) => {
|
||||
const { current, reframe } = this.props.viewRangeTime;
|
||||
const [viewStart, viewEnd] = current;
|
||||
const shift = mapFromViewSubRange(viewStart, viewEnd, value);
|
||||
const anchor = reframe ? reframe.anchor : shift;
|
||||
const update = { reframe: { anchor, shift } };
|
||||
this.props.updateNextViewRangeTime(update);
|
||||
};
|
||||
|
||||
_handleReframeDragEnd = ({ manager, value }: DraggingUpdate) => {
|
||||
const { current, reframe } = this.props.viewRangeTime;
|
||||
const [viewStart, viewEnd] = current;
|
||||
const shift = mapFromViewSubRange(viewStart, viewEnd, value);
|
||||
const anchor = reframe ? reframe.anchor : shift;
|
||||
const [start, end] = shift < anchor ? [shift, anchor] : [anchor, shift];
|
||||
manager.resetBounds();
|
||||
this.props.updateViewRangeTime(start, end, 'timeline-header');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { viewRangeTime } = this.props;
|
||||
const { current, cursor, reframe, shiftEnd, shiftStart } = viewRangeTime;
|
||||
const [viewStart, viewEnd] = current;
|
||||
const haveNextTimeRange = reframe != null || shiftEnd != null || shiftStart != null;
|
||||
let cusrorPosition: string | TNil;
|
||||
if (!haveNextTimeRange && cursor != null && cursor >= viewStart && cursor <= viewEnd) {
|
||||
cusrorPosition = `${mapToViewSubRange(viewStart, viewEnd, cursor) * 100}%`;
|
||||
}
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={styles.TimelineViewingLayer}
|
||||
ref={this._setRoot}
|
||||
onMouseDown={this._draggerReframe.handleMouseDown}
|
||||
onMouseLeave={this._draggerReframe.handleMouseLeave}
|
||||
onMouseMove={this._draggerReframe.handleMouseMove}
|
||||
data-test-id="TimelineViewingLayer"
|
||||
>
|
||||
{cusrorPosition != null && (
|
||||
<div
|
||||
className={styles.cursorGuide}
|
||||
style={{ left: cusrorPosition }}
|
||||
data-test-id="TimelineViewingLayer--cursorGuide"
|
||||
/>
|
||||
)}
|
||||
{reframe != null && getMarkers(viewStart, viewEnd, reframe.anchor, reframe.shift, false)}
|
||||
{shiftEnd != null && getMarkers(viewStart, viewEnd, viewEnd, shiftEnd, true)}
|
||||
{shiftStart != null && getMarkers(viewStart, viewEnd, viewStart, shiftStart, true)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export { default } from './TimelineHeaderRow';
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
import { createStyle } from '../Theme';
|
||||
import { ubRelative } from '../uberUtilityStyles';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: row;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type TTimelineRowProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
interface TimelineRowCellProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
width: number;
|
||||
style?: {};
|
||||
}
|
||||
|
||||
export default function TimelineRow(props: TTimelineRowProps) {
|
||||
const { children, className = '', ...rest } = props;
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<div className={cx(styles.flexRow, className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TimelineRow.defaultProps = {
|
||||
className: '',
|
||||
};
|
||||
|
||||
export function TimelineRowCell(props: TimelineRowCellProps) {
|
||||
const { children, className = '', width, style, ...rest } = props;
|
||||
const widthPercent = `${width * 100}%`;
|
||||
const mergedStyle = { ...style, flexBasis: widthPercent, maxWidth: widthPercent };
|
||||
return (
|
||||
<div className={cx(ubRelative, className)} style={mergedStyle} {...(rest as any)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TimelineRowCell.defaultProps = { className: '', style: {} };
|
||||
|
||||
TimelineRow.Cell = TimelineRowCell;
|
||||
@@ -0,0 +1,393 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
import React from 'react';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
|
||||
import ListView from './ListView';
|
||||
import SpanBarRow from './SpanBarRow';
|
||||
import DetailState from './SpanDetail/DetailState';
|
||||
import SpanDetailRow from './SpanDetailRow';
|
||||
import VirtualizedTraceView, { DEFAULT_HEIGHTS } from './VirtualizedTraceView';
|
||||
import traceGenerator from '../demo/trace-generators';
|
||||
import transformTraceData from '../model/transform-trace-data';
|
||||
|
||||
jest.mock('./SpanTreeOffset');
|
||||
|
||||
describe('<VirtualizedTraceViewImpl>', () => {
|
||||
let wrapper;
|
||||
let instance;
|
||||
|
||||
const trace = transformTraceData(traceGenerator.trace({ numberOfSpans: 10 }));
|
||||
const props = {
|
||||
childrenHiddenIDs: new Set(),
|
||||
childrenToggle: jest.fn(),
|
||||
clearShouldScrollToFirstUiFindMatch: jest.fn(),
|
||||
currentViewRangeTime: [0.25, 0.75],
|
||||
detailLogItemToggle: jest.fn(),
|
||||
detailLogsToggle: jest.fn(),
|
||||
detailProcessToggle: jest.fn(),
|
||||
detailStates: new Map(),
|
||||
detailTagsToggle: jest.fn(),
|
||||
detailToggle: jest.fn(),
|
||||
findMatchesIDs: null,
|
||||
registerAccessors: jest.fn(),
|
||||
scrollToFirstVisibleSpan: jest.fn(),
|
||||
setSpanNameColumnWidth: jest.fn(),
|
||||
setTrace: jest.fn(),
|
||||
shouldScrollToFirstUiFindMatch: false,
|
||||
spanNameColumnWidth: 0.5,
|
||||
trace,
|
||||
uiFind: 'uiFind',
|
||||
};
|
||||
|
||||
function expandRow(rowIndex) {
|
||||
const detailStates = new Map();
|
||||
const detailState = new DetailState();
|
||||
detailStates.set(trace.spans[rowIndex].spanID, detailState);
|
||||
wrapper.setProps({ detailStates });
|
||||
return detailState;
|
||||
}
|
||||
|
||||
function addSpansAndCollapseTheirParent(newSpanID = 'some-id') {
|
||||
const childrenHiddenIDs = new Set([newSpanID]);
|
||||
const spans = [
|
||||
trace.spans[0],
|
||||
// this span is condidered to have collapsed children
|
||||
{ spanID: newSpanID, depth: 1 },
|
||||
// these two "spans" are children and should be hidden
|
||||
{ depth: 2 },
|
||||
{ depth: 3 },
|
||||
...trace.spans.slice(1),
|
||||
];
|
||||
const _trace = { ...trace, spans };
|
||||
wrapper.setProps({ childrenHiddenIDs, trace: _trace });
|
||||
return spans;
|
||||
}
|
||||
|
||||
function updateSpan(srcTrace, spanIndex, update) {
|
||||
const span = { ...srcTrace.spans[spanIndex], ...update };
|
||||
const spans = [...srcTrace.spans.slice(0, spanIndex), span, ...srcTrace.spans.slice(spanIndex + 1)];
|
||||
return { ...srcTrace, spans };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(props).forEach(key => {
|
||||
if (typeof props[key] === 'function') {
|
||||
props[key].mockReset();
|
||||
}
|
||||
});
|
||||
wrapper = shallow(<VirtualizedTraceView {...props} />);
|
||||
instance = wrapper.instance();
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders when a trace is not set', () => {
|
||||
wrapper.setProps({ trace: null });
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders a ListView', () => {
|
||||
expect(wrapper.find(ListView)).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets the trace for global state.traceTimeline', () => {
|
||||
expect(props.setTrace.mock.calls).toEqual([[trace, props.uiFind]]);
|
||||
props.setTrace.mockReset();
|
||||
const traceID = 'some-other-id';
|
||||
const _trace = { ...trace, traceID };
|
||||
wrapper.setProps({ trace: _trace });
|
||||
expect(props.setTrace.mock.calls).toEqual([[_trace, props.uiFind]]);
|
||||
});
|
||||
|
||||
describe('props.registerAccessors', () => {
|
||||
let lv;
|
||||
let expectedArg;
|
||||
|
||||
beforeEach(() => {
|
||||
const getBottomRowIndexVisible = () => {};
|
||||
const getTopRowIndexVisible = () => {};
|
||||
lv = {
|
||||
getViewHeight: () => {},
|
||||
getBottomVisibleIndex: getBottomRowIndexVisible,
|
||||
getTopVisibleIndex: getTopRowIndexVisible,
|
||||
getRowPosition: () => {},
|
||||
};
|
||||
expectedArg = {
|
||||
getBottomRowIndexVisible,
|
||||
getTopRowIndexVisible,
|
||||
getViewHeight: lv.getViewHeight,
|
||||
getRowPosition: lv.getRowPosition,
|
||||
getViewRange: instance.getViewRange,
|
||||
getSearchedSpanIDs: instance.getSearchedSpanIDs,
|
||||
getCollapsedChildren: instance.getCollapsedChildren,
|
||||
mapRowIndexToSpanIndex: instance.mapRowIndexToSpanIndex,
|
||||
mapSpanIndexToRowIndex: instance.mapSpanIndexToRowIndex,
|
||||
};
|
||||
});
|
||||
|
||||
it('invokes when the listView is set', () => {
|
||||
expect(props.registerAccessors.mock.calls.length).toBe(0);
|
||||
instance.setListView(lv);
|
||||
expect(props.registerAccessors.mock.calls).toEqual([[expectedArg]]);
|
||||
});
|
||||
|
||||
it('invokes when registerAccessors changes', () => {
|
||||
const registerAccessors = jest.fn();
|
||||
instance.setListView(lv);
|
||||
wrapper.setProps({ registerAccessors });
|
||||
expect(registerAccessors.mock.calls).toEqual([[expectedArg]]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the current view range via getViewRange()', () => {
|
||||
expect(instance.getViewRange()).toBe(props.currentViewRangeTime);
|
||||
});
|
||||
|
||||
it('returns findMatchesIDs via getSearchedSpanIDs()', () => {
|
||||
const findMatchesIDs = new Set();
|
||||
wrapper.setProps({ findMatchesIDs });
|
||||
expect(instance.getSearchedSpanIDs()).toBe(findMatchesIDs);
|
||||
});
|
||||
|
||||
it('returns childrenHiddenIDs via getCollapsedChildren()', () => {
|
||||
const childrenHiddenIDs = new Set();
|
||||
wrapper.setProps({ childrenHiddenIDs });
|
||||
expect(instance.getCollapsedChildren()).toBe(childrenHiddenIDs);
|
||||
});
|
||||
|
||||
describe('mapRowIndexToSpanIndex() maps row index to span index', () => {
|
||||
it('works when nothing is collapsed or expanded', () => {
|
||||
const i = trace.spans.length - 1;
|
||||
expect(instance.mapRowIndexToSpanIndex(i)).toBe(i);
|
||||
});
|
||||
|
||||
it('works when a span is expanded', () => {
|
||||
expandRow(1);
|
||||
expect(instance.mapRowIndexToSpanIndex(0)).toBe(0);
|
||||
expect(instance.mapRowIndexToSpanIndex(1)).toBe(1);
|
||||
expect(instance.mapRowIndexToSpanIndex(2)).toBe(1);
|
||||
expect(instance.mapRowIndexToSpanIndex(3)).toBe(2);
|
||||
});
|
||||
|
||||
it('works when a parent span is collapsed', () => {
|
||||
addSpansAndCollapseTheirParent();
|
||||
expect(instance.mapRowIndexToSpanIndex(0)).toBe(0);
|
||||
expect(instance.mapRowIndexToSpanIndex(1)).toBe(1);
|
||||
expect(instance.mapRowIndexToSpanIndex(2)).toBe(4);
|
||||
expect(instance.mapRowIndexToSpanIndex(3)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapSpanIndexToRowIndex() maps span index to row index', () => {
|
||||
it('works when nothing is collapsed or expanded', () => {
|
||||
const i = trace.spans.length - 1;
|
||||
expect(instance.mapSpanIndexToRowIndex(i)).toBe(i);
|
||||
});
|
||||
|
||||
it('works when a span is expanded', () => {
|
||||
expandRow(1);
|
||||
expect(instance.mapSpanIndexToRowIndex(0)).toBe(0);
|
||||
expect(instance.mapSpanIndexToRowIndex(1)).toBe(1);
|
||||
expect(instance.mapSpanIndexToRowIndex(2)).toBe(3);
|
||||
expect(instance.mapSpanIndexToRowIndex(3)).toBe(4);
|
||||
});
|
||||
|
||||
it('works when a parent span is collapsed', () => {
|
||||
addSpansAndCollapseTheirParent();
|
||||
expect(instance.mapSpanIndexToRowIndex(0)).toBe(0);
|
||||
expect(instance.mapSpanIndexToRowIndex(1)).toBe(1);
|
||||
expect(() => instance.mapSpanIndexToRowIndex(2)).toThrow();
|
||||
expect(() => instance.mapSpanIndexToRowIndex(3)).toThrow();
|
||||
expect(instance.mapSpanIndexToRowIndex(4)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKeyFromIndex() generates a "key" from a row index', () => {
|
||||
function verify(input, output) {
|
||||
expect(instance.getKeyFromIndex(input)).toBe(output);
|
||||
}
|
||||
|
||||
it('works when nothing is expanded or collapsed', () => {
|
||||
verify(0, `${trace.spans[0].spanID}--bar`);
|
||||
});
|
||||
|
||||
it('works when rows are expanded', () => {
|
||||
expandRow(1);
|
||||
verify(1, `${trace.spans[1].spanID}--bar`);
|
||||
verify(2, `${trace.spans[1].spanID}--detail`);
|
||||
verify(3, `${trace.spans[2].spanID}--bar`);
|
||||
});
|
||||
|
||||
it('works when a parent span is collapsed', () => {
|
||||
const spans = addSpansAndCollapseTheirParent();
|
||||
verify(1, `${spans[1].spanID}--bar`);
|
||||
verify(2, `${spans[4].spanID}--bar`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIndexFromKey() converts a "key" to the corresponding row index', () => {
|
||||
function verify(input, output) {
|
||||
expect(instance.getIndexFromKey(input)).toBe(output);
|
||||
}
|
||||
|
||||
it('works when nothing is expanded or collapsed', () => {
|
||||
verify(`${trace.spans[0].spanID}--bar`, 0);
|
||||
});
|
||||
|
||||
it('works when rows are expanded', () => {
|
||||
expandRow(1);
|
||||
verify(`${trace.spans[1].spanID}--bar`, 1);
|
||||
verify(`${trace.spans[1].spanID}--detail`, 2);
|
||||
verify(`${trace.spans[2].spanID}--bar`, 3);
|
||||
});
|
||||
|
||||
it('works when a parent span is collapsed', () => {
|
||||
const spans = addSpansAndCollapseTheirParent();
|
||||
verify(`${spans[1].spanID}--bar`, 1);
|
||||
verify(`${spans[4].spanID}--bar`, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRowHeight()', () => {
|
||||
it('returns the expected height for non-detail rows', () => {
|
||||
expect(instance.getRowHeight(0)).toBe(DEFAULT_HEIGHTS.bar);
|
||||
});
|
||||
|
||||
it('returns the expected height for detail rows that do not have logs', () => {
|
||||
expandRow(0);
|
||||
expect(instance.getRowHeight(1)).toBe(DEFAULT_HEIGHTS.detail);
|
||||
});
|
||||
|
||||
it('returns the expected height for detail rows that do have logs', () => {
|
||||
const logs = [
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
fields: traceGenerator.tags(),
|
||||
},
|
||||
];
|
||||
const altTrace = updateSpan(trace, 0, { logs });
|
||||
expandRow(0);
|
||||
wrapper.setProps({ trace: altTrace });
|
||||
expect(instance.getRowHeight(1)).toBe(DEFAULT_HEIGHTS.detailWithLogs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderRow()', () => {
|
||||
it('renders a SpanBarRow when it is not a detail', () => {
|
||||
const span = trace.spans[1];
|
||||
const row = instance.renderRow('some-key', {}, 1, {});
|
||||
const rowWrapper = shallow(row);
|
||||
|
||||
expect(
|
||||
rowWrapper.containsMatchingElement(
|
||||
<SpanBarRow
|
||||
className={instance.clippingCssClasses}
|
||||
columnDivision={props.spanNameColumnWidth}
|
||||
isChildrenExpanded
|
||||
isDetailExpanded={false}
|
||||
isMatchingFilter={false}
|
||||
numTicks={5}
|
||||
onDetailToggled={props.detailToggle}
|
||||
onChildrenToggled={props.childrenToggle}
|
||||
rpc={undefined}
|
||||
showErrorIcon={false}
|
||||
span={span}
|
||||
/>
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a SpanBarRow with a RPC span if the row is collapsed and a client span', () => {
|
||||
const clientTags = [{ key: 'span.kind', value: 'client' }, ...trace.spans[0].tags];
|
||||
const serverTags = [{ key: 'span.kind', value: 'server' }, ...trace.spans[1].tags];
|
||||
let altTrace = updateSpan(trace, 0, { tags: clientTags });
|
||||
altTrace = updateSpan(altTrace, 1, { tags: serverTags });
|
||||
const childrenHiddenIDs = new Set([altTrace.spans[0].spanID]);
|
||||
wrapper.setProps({ childrenHiddenIDs, trace: altTrace });
|
||||
|
||||
const rowWrapper = mount(instance.renderRow('some-key', {}, 0, {}));
|
||||
const spanBarRow = rowWrapper.find(SpanBarRow);
|
||||
expect(spanBarRow.length).toBe(1);
|
||||
expect(spanBarRow.prop('rpc')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders a SpanDetailRow when it is a detail', () => {
|
||||
const detailState = expandRow(1);
|
||||
const span = trace.spans[1];
|
||||
const row = instance.renderRow('some-key', {}, 2, {});
|
||||
const rowWrapper = shallow(row);
|
||||
expect(
|
||||
rowWrapper.containsMatchingElement(
|
||||
<SpanDetailRow
|
||||
columnDivision={props.spanNameColumnWidth}
|
||||
onDetailToggled={props.detailToggle}
|
||||
detailState={detailState}
|
||||
logItemToggle={props.detailLogItemToggle}
|
||||
logsToggle={props.detailLogsToggle}
|
||||
processToggle={props.detailProcessToggle}
|
||||
span={span}
|
||||
tagsToggle={props.detailTagsToggle}
|
||||
/>
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldScrollToFirstUiFindMatch', () => {
|
||||
const propsWithTrueShouldScrollToFirstUiFindMatch = { ...props, shouldScrollToFirstUiFindMatch: true };
|
||||
|
||||
beforeEach(() => {
|
||||
props.scrollToFirstVisibleSpan.mockReset();
|
||||
props.clearShouldScrollToFirstUiFindMatch.mockReset();
|
||||
});
|
||||
|
||||
it('calls props.scrollToFirstVisibleSpan if shouldScrollToFirstUiFindMatch is true', () => {
|
||||
expect(props.scrollToFirstVisibleSpan).not.toHaveBeenCalled();
|
||||
expect(props.clearShouldScrollToFirstUiFindMatch).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.setProps(propsWithTrueShouldScrollToFirstUiFindMatch);
|
||||
expect(props.scrollToFirstVisibleSpan).toHaveBeenCalledTimes(1);
|
||||
expect(props.clearShouldScrollToFirstUiFindMatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('shouldComponentUpdate', () => {
|
||||
it('returns true if props.shouldScrollToFirstUiFindMatch changes to true', () => {
|
||||
expect(wrapper.instance().shouldComponentUpdate(propsWithTrueShouldScrollToFirstUiFindMatch)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true if props.shouldScrollToFirstUiFindMatch changes to false and another props change', () => {
|
||||
const propsWithOtherDifferenceAndTrueshouldScrollToFirstUiFindMatch = {
|
||||
...propsWithTrueShouldScrollToFirstUiFindMatch,
|
||||
clearShouldScrollToFirstUiFindMatch: () => {},
|
||||
};
|
||||
wrapper.setProps(propsWithOtherDifferenceAndTrueshouldScrollToFirstUiFindMatch);
|
||||
expect(wrapper.instance().shouldComponentUpdate(props)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if props.shouldScrollToFirstUiFindMatch changes to false and no other props change', () => {
|
||||
wrapper.setProps(propsWithTrueShouldScrollToFirstUiFindMatch);
|
||||
expect(wrapper.instance().shouldComponentUpdate(props)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if all props are unchanged', () => {
|
||||
expect(wrapper.instance().shouldComponentUpdate(props)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import { css } from 'emotion';
|
||||
|
||||
import ListView from './ListView';
|
||||
import SpanBarRow from './SpanBarRow';
|
||||
import DetailState from './SpanDetail/DetailState';
|
||||
import SpanDetailRow from './SpanDetailRow';
|
||||
import {
|
||||
createViewedBoundsFunc,
|
||||
findServerChildSpan,
|
||||
isErrorSpan,
|
||||
spanContainsErredSpan,
|
||||
ViewedBoundsFunctionType,
|
||||
} from './utils';
|
||||
import { Accessors } from '../ScrollManager';
|
||||
import colorGenerator from '../utils/color-generator';
|
||||
import { TNil } from '../types';
|
||||
import { Log, Span, Trace, KeyValuePair, Link } from '../types/trace';
|
||||
import TTraceTimeline from '../types/TTraceTimeline';
|
||||
|
||||
import { createStyle } from '../Theme';
|
||||
|
||||
type TExtractUiFindFromStateReturn = {
|
||||
uiFind: string | undefined;
|
||||
};
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
rowsWrapper: css`
|
||||
width: 100%;
|
||||
`,
|
||||
row: css`
|
||||
width: 100%;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type RowState = {
|
||||
isDetail: boolean;
|
||||
span: Span;
|
||||
spanIndex: number;
|
||||
};
|
||||
|
||||
type TVirtualizedTraceViewOwnProps = {
|
||||
currentViewRangeTime: [number, number];
|
||||
findMatchesIDs: Set<string> | TNil;
|
||||
scrollToFirstVisibleSpan: () => void;
|
||||
registerAccessors: (accesors: Accessors) => void;
|
||||
trace: Trace;
|
||||
focusSpan: (uiFind: string) => void;
|
||||
linksGetter: (span: Span, items: KeyValuePair[], itemIndex: number) => Link[];
|
||||
|
||||
// was from redux
|
||||
childrenToggle: (spanID: string) => void;
|
||||
clearShouldScrollToFirstUiFindMatch: () => void;
|
||||
detailLogItemToggle: (spanID: string, log: Log) => void;
|
||||
detailLogsToggle: (spanID: string) => void;
|
||||
detailWarningsToggle: (spanID: string) => void;
|
||||
detailReferencesToggle: (spanID: string) => void;
|
||||
detailProcessToggle: (spanID: string) => void;
|
||||
detailTagsToggle: (spanID: string) => void;
|
||||
detailToggle: (spanID: string) => void;
|
||||
setSpanNameColumnWidth: (width: number) => void;
|
||||
setTrace: (trace: Trace | TNil, uiFind: string | TNil) => void;
|
||||
hoverIndentGuideIds: Set<string>;
|
||||
addHoverIndentGuideId: (spanID: string) => void;
|
||||
removeHoverIndentGuideId: (spanID: string) => void;
|
||||
};
|
||||
|
||||
type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TExtractUiFindFromStateReturn & TTraceTimeline;
|
||||
|
||||
// export for tests
|
||||
export const DEFAULT_HEIGHTS = {
|
||||
bar: 28,
|
||||
detail: 161,
|
||||
detailWithLogs: 197,
|
||||
};
|
||||
|
||||
const NUM_TICKS = 5;
|
||||
|
||||
function generateRowStates(
|
||||
spans: Span[] | TNil,
|
||||
childrenHiddenIDs: Set<string>,
|
||||
detailStates: Map<string, DetailState | TNil>
|
||||
): RowState[] {
|
||||
if (!spans) {
|
||||
return [];
|
||||
}
|
||||
let collapseDepth = null;
|
||||
const rowStates = [];
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
const span = spans[i];
|
||||
const { spanID, depth } = span;
|
||||
let hidden = false;
|
||||
if (collapseDepth != null) {
|
||||
if (depth >= collapseDepth) {
|
||||
hidden = true;
|
||||
} else {
|
||||
collapseDepth = null;
|
||||
}
|
||||
}
|
||||
if (hidden) {
|
||||
continue;
|
||||
}
|
||||
if (childrenHiddenIDs.has(spanID)) {
|
||||
collapseDepth = depth + 1;
|
||||
}
|
||||
rowStates.push({
|
||||
span,
|
||||
isDetail: false,
|
||||
spanIndex: i,
|
||||
});
|
||||
if (detailStates.has(spanID)) {
|
||||
rowStates.push({
|
||||
span,
|
||||
isDetail: true,
|
||||
spanIndex: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rowStates;
|
||||
}
|
||||
|
||||
function getClipping(currentViewRange: [number, number]) {
|
||||
const [zoomStart, zoomEnd] = currentViewRange;
|
||||
return {
|
||||
left: zoomStart > 0,
|
||||
right: zoomEnd < 1,
|
||||
};
|
||||
}
|
||||
|
||||
// export from tests
|
||||
export default class VirtualizedTraceView extends React.Component<VirtualizedTraceViewProps> {
|
||||
clipping: { left: boolean; right: boolean };
|
||||
listView: ListView | TNil;
|
||||
rowStates: RowState[];
|
||||
getViewedBounds: ViewedBoundsFunctionType;
|
||||
|
||||
constructor(props: VirtualizedTraceViewProps) {
|
||||
super(props);
|
||||
// keep "prop derivations" on the instance instead of calculating in
|
||||
// `.render()` to avoid recalculating in every invocation of `.renderRow()`
|
||||
const { currentViewRangeTime, childrenHiddenIDs, detailStates, setTrace, trace, uiFind } = props;
|
||||
this.clipping = getClipping(currentViewRangeTime);
|
||||
const [zoomStart, zoomEnd] = currentViewRangeTime;
|
||||
this.getViewedBounds = createViewedBoundsFunc({
|
||||
min: trace.startTime,
|
||||
max: trace.endTime,
|
||||
viewStart: zoomStart,
|
||||
viewEnd: zoomEnd,
|
||||
});
|
||||
this.rowStates = generateRowStates(trace.spans, childrenHiddenIDs, detailStates);
|
||||
|
||||
setTrace(trace, uiFind);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: VirtualizedTraceViewProps) {
|
||||
// If any prop updates, VirtualizedTraceViewImpl should update.
|
||||
const nextPropKeys = Object.keys(nextProps) as Array<keyof VirtualizedTraceViewProps>;
|
||||
for (let i = 0; i < nextPropKeys.length; i += 1) {
|
||||
if (nextProps[nextPropKeys[i]] !== this.props[nextPropKeys[i]]) {
|
||||
// Unless the only change was props.shouldScrollToFirstUiFindMatch changing to false.
|
||||
if (nextPropKeys[i] === 'shouldScrollToFirstUiFindMatch') {
|
||||
if (nextProps[nextPropKeys[i]]) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
componentWillUpdate(nextProps: VirtualizedTraceViewProps) {
|
||||
const { childrenHiddenIDs, detailStates, registerAccessors, trace, currentViewRangeTime } = this.props;
|
||||
const {
|
||||
currentViewRangeTime: nextViewRangeTime,
|
||||
childrenHiddenIDs: nextHiddenIDs,
|
||||
detailStates: nextDetailStates,
|
||||
registerAccessors: nextRegisterAccessors,
|
||||
setTrace,
|
||||
trace: nextTrace,
|
||||
uiFind,
|
||||
} = nextProps;
|
||||
if (trace !== nextTrace) {
|
||||
setTrace(nextTrace, uiFind);
|
||||
}
|
||||
if (trace !== nextTrace || childrenHiddenIDs !== nextHiddenIDs || detailStates !== nextDetailStates) {
|
||||
this.rowStates = nextTrace ? generateRowStates(nextTrace.spans, nextHiddenIDs, nextDetailStates) : [];
|
||||
}
|
||||
if (currentViewRangeTime !== nextViewRangeTime) {
|
||||
this.clipping = getClipping(nextViewRangeTime);
|
||||
const [zoomStart, zoomEnd] = nextViewRangeTime;
|
||||
this.getViewedBounds = createViewedBoundsFunc({
|
||||
min: trace.startTime,
|
||||
max: trace.endTime,
|
||||
viewStart: zoomStart,
|
||||
viewEnd: zoomEnd,
|
||||
});
|
||||
}
|
||||
if (this.listView && registerAccessors !== nextRegisterAccessors) {
|
||||
nextRegisterAccessors(this.getAccessors());
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const {
|
||||
shouldScrollToFirstUiFindMatch,
|
||||
clearShouldScrollToFirstUiFindMatch,
|
||||
scrollToFirstVisibleSpan,
|
||||
} = this.props;
|
||||
if (shouldScrollToFirstUiFindMatch) {
|
||||
scrollToFirstVisibleSpan();
|
||||
clearShouldScrollToFirstUiFindMatch();
|
||||
}
|
||||
}
|
||||
|
||||
getAccessors() {
|
||||
const lv = this.listView;
|
||||
if (!lv) {
|
||||
throw new Error('ListView unavailable');
|
||||
}
|
||||
return {
|
||||
getViewRange: this.getViewRange,
|
||||
getSearchedSpanIDs: this.getSearchedSpanIDs,
|
||||
getCollapsedChildren: this.getCollapsedChildren,
|
||||
getViewHeight: lv.getViewHeight,
|
||||
getBottomRowIndexVisible: lv.getBottomVisibleIndex,
|
||||
getTopRowIndexVisible: lv.getTopVisibleIndex,
|
||||
getRowPosition: lv.getRowPosition,
|
||||
mapRowIndexToSpanIndex: this.mapRowIndexToSpanIndex,
|
||||
mapSpanIndexToRowIndex: this.mapSpanIndexToRowIndex,
|
||||
};
|
||||
}
|
||||
|
||||
getViewRange = () => this.props.currentViewRangeTime;
|
||||
|
||||
getSearchedSpanIDs = () => this.props.findMatchesIDs;
|
||||
|
||||
getCollapsedChildren = () => this.props.childrenHiddenIDs;
|
||||
|
||||
mapRowIndexToSpanIndex = (index: number) => this.rowStates[index].spanIndex;
|
||||
|
||||
mapSpanIndexToRowIndex = (index: number) => {
|
||||
const max = this.rowStates.length;
|
||||
for (let i = 0; i < max; i++) {
|
||||
const { spanIndex } = this.rowStates[i];
|
||||
if (spanIndex === index) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
throw new Error(`unable to find row for span index: ${index}`);
|
||||
};
|
||||
|
||||
setListView = (listView: ListView | TNil) => {
|
||||
const isChanged = this.listView !== listView;
|
||||
this.listView = listView;
|
||||
if (listView && isChanged) {
|
||||
this.props.registerAccessors(this.getAccessors());
|
||||
}
|
||||
};
|
||||
|
||||
// use long form syntax to avert flow error
|
||||
// https://github.com/facebook/flow/issues/3076#issuecomment-290944051
|
||||
getKeyFromIndex = (index: number) => {
|
||||
const { isDetail, span } = this.rowStates[index];
|
||||
return `${span.spanID}--${isDetail ? 'detail' : 'bar'}`;
|
||||
};
|
||||
|
||||
getIndexFromKey = (key: string) => {
|
||||
const parts = key.split('--');
|
||||
const _spanID = parts[0];
|
||||
const _isDetail = parts[1] === 'detail';
|
||||
const max = this.rowStates.length;
|
||||
for (let i = 0; i < max; i++) {
|
||||
const { span, isDetail } = this.rowStates[i];
|
||||
if (span.spanID === _spanID && isDetail === _isDetail) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
getRowHeight = (index: number) => {
|
||||
const { span, isDetail } = this.rowStates[index];
|
||||
if (!isDetail) {
|
||||
return DEFAULT_HEIGHTS.bar;
|
||||
}
|
||||
if (Array.isArray(span.logs) && span.logs.length) {
|
||||
return DEFAULT_HEIGHTS.detailWithLogs;
|
||||
}
|
||||
return DEFAULT_HEIGHTS.detail;
|
||||
};
|
||||
|
||||
renderRow = (key: string, style: React.CSSProperties, index: number, attrs: {}) => {
|
||||
const { isDetail, span, spanIndex } = this.rowStates[index];
|
||||
return isDetail
|
||||
? this.renderSpanDetailRow(span, key, style, attrs)
|
||||
: this.renderSpanBarRow(span, spanIndex, key, style, attrs);
|
||||
};
|
||||
|
||||
renderSpanBarRow(span: Span, spanIndex: number, key: string, style: React.CSSProperties, attrs: {}) {
|
||||
const { spanID } = span;
|
||||
const { serviceName } = span.process;
|
||||
const {
|
||||
childrenHiddenIDs,
|
||||
childrenToggle,
|
||||
detailStates,
|
||||
detailToggle,
|
||||
findMatchesIDs,
|
||||
spanNameColumnWidth,
|
||||
trace,
|
||||
focusSpan,
|
||||
hoverIndentGuideIds,
|
||||
addHoverIndentGuideId,
|
||||
removeHoverIndentGuideId,
|
||||
} = this.props;
|
||||
// to avert flow error
|
||||
if (!trace) {
|
||||
return null;
|
||||
}
|
||||
const color = colorGenerator.getColorByKey(serviceName);
|
||||
const isCollapsed = childrenHiddenIDs.has(spanID);
|
||||
const isDetailExpanded = detailStates.has(spanID);
|
||||
const isMatchingFilter = findMatchesIDs ? findMatchesIDs.has(spanID) : false;
|
||||
const showErrorIcon = isErrorSpan(span) || (isCollapsed && spanContainsErredSpan(trace.spans, spanIndex));
|
||||
|
||||
// Check for direct child "server" span if the span is a "client" span.
|
||||
let rpc = null;
|
||||
if (isCollapsed) {
|
||||
const rpcSpan = findServerChildSpan(trace.spans.slice(spanIndex));
|
||||
if (rpcSpan) {
|
||||
const rpcViewBounds = this.getViewedBounds(rpcSpan.startTime, rpcSpan.startTime + rpcSpan.duration);
|
||||
rpc = {
|
||||
color: colorGenerator.getColorByKey(rpcSpan.process.serviceName),
|
||||
operationName: rpcSpan.operationName,
|
||||
serviceName: rpcSpan.process.serviceName,
|
||||
viewEnd: rpcViewBounds.end,
|
||||
viewStart: rpcViewBounds.start,
|
||||
};
|
||||
}
|
||||
}
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<div className={styles.row} key={key} style={style} {...attrs}>
|
||||
<SpanBarRow
|
||||
clippingLeft={this.clipping.left}
|
||||
clippingRight={this.clipping.right}
|
||||
color={color}
|
||||
columnDivision={spanNameColumnWidth}
|
||||
isChildrenExpanded={!isCollapsed}
|
||||
isDetailExpanded={isDetailExpanded}
|
||||
isMatchingFilter={isMatchingFilter}
|
||||
numTicks={NUM_TICKS}
|
||||
onDetailToggled={detailToggle}
|
||||
onChildrenToggled={childrenToggle}
|
||||
rpc={rpc}
|
||||
showErrorIcon={showErrorIcon}
|
||||
getViewedBounds={this.getViewedBounds}
|
||||
traceStartTime={trace.startTime}
|
||||
span={span}
|
||||
focusSpan={focusSpan}
|
||||
hoverIndentGuideIds={hoverIndentGuideIds}
|
||||
addHoverIndentGuideId={addHoverIndentGuideId}
|
||||
removeHoverIndentGuideId={removeHoverIndentGuideId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderSpanDetailRow(span: Span, key: string, style: React.CSSProperties, attrs: {}) {
|
||||
const { spanID } = span;
|
||||
const { serviceName } = span.process;
|
||||
const {
|
||||
detailLogItemToggle,
|
||||
detailLogsToggle,
|
||||
detailProcessToggle,
|
||||
detailReferencesToggle,
|
||||
detailWarningsToggle,
|
||||
detailStates,
|
||||
detailTagsToggle,
|
||||
detailToggle,
|
||||
spanNameColumnWidth,
|
||||
trace,
|
||||
focusSpan,
|
||||
hoverIndentGuideIds,
|
||||
addHoverIndentGuideId,
|
||||
removeHoverIndentGuideId,
|
||||
linksGetter,
|
||||
} = this.props;
|
||||
const detailState = detailStates.get(spanID);
|
||||
if (!trace || !detailState) {
|
||||
return null;
|
||||
}
|
||||
const color = colorGenerator.getColorByKey(serviceName);
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<div className={styles.row} key={key} style={{ ...style, zIndex: 1 }} {...attrs}>
|
||||
<SpanDetailRow
|
||||
color={color}
|
||||
columnDivision={spanNameColumnWidth}
|
||||
onDetailToggled={detailToggle}
|
||||
detailState={detailState}
|
||||
linksGetter={linksGetter}
|
||||
logItemToggle={detailLogItemToggle}
|
||||
logsToggle={detailLogsToggle}
|
||||
processToggle={detailProcessToggle}
|
||||
referencesToggle={detailReferencesToggle}
|
||||
warningsToggle={detailWarningsToggle}
|
||||
span={span}
|
||||
tagsToggle={detailTagsToggle}
|
||||
traceStartTime={trace.startTime}
|
||||
focusSpan={focusSpan}
|
||||
hoverIndentGuideIds={hoverIndentGuideIds}
|
||||
addHoverIndentGuideId={addHoverIndentGuideId}
|
||||
removeHoverIndentGuideId={removeHoverIndentGuideId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<div>
|
||||
<ListView
|
||||
ref={this.setListView}
|
||||
dataLength={this.rowStates.length}
|
||||
itemHeightGetter={this.getRowHeight}
|
||||
itemRenderer={this.renderRow}
|
||||
viewBuffer={300}
|
||||
viewBufferMin={100}
|
||||
itemsWrapperClassName={styles.rowsWrapper}
|
||||
getKeyFromIndex={this.getKeyFromIndex}
|
||||
getIndexFromKey={this.getIndexFromKey}
|
||||
windowScroller
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import TraceTimelineViewer from './index';
|
||||
import traceGenerator from '../demo/trace-generators';
|
||||
import transformTraceData from '../model/transform-trace-data';
|
||||
import TimelineHeaderRow from './TimelineHeaderRow';
|
||||
import { defaultTheme } from '../Theme';
|
||||
|
||||
describe('<TraceTimelineViewer>', () => {
|
||||
const trace = transformTraceData(traceGenerator.trace({}));
|
||||
const props = {
|
||||
trace,
|
||||
textFilter: null,
|
||||
viewRange: {
|
||||
time: {
|
||||
current: [0, 1],
|
||||
},
|
||||
},
|
||||
traceTimeline: {
|
||||
spanNameColumnWidth: 0.5,
|
||||
},
|
||||
expandAll: jest.fn(),
|
||||
collapseAll: jest.fn(),
|
||||
expandOne: jest.fn(),
|
||||
collapseOne: jest.fn(),
|
||||
theme: defaultTheme,
|
||||
history: {
|
||||
replace: () => {},
|
||||
},
|
||||
location: {
|
||||
search: null,
|
||||
},
|
||||
};
|
||||
const options = {
|
||||
context: {
|
||||
store: {
|
||||
getState() {
|
||||
return { traceTimeline: { spanNameColumnWidth: 0.25 } };
|
||||
},
|
||||
subscribe() {},
|
||||
dispatch() {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<TraceTimelineViewer {...props} />, options);
|
||||
});
|
||||
|
||||
it('it does not explode', () => {
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('it sets up actions', () => {
|
||||
const headerRow = wrapper.find(TimelineHeaderRow);
|
||||
headerRow.props().onCollapseAll();
|
||||
headerRow.props().onExpandAll();
|
||||
headerRow.props().onExpandOne();
|
||||
headerRow.props().onCollapseOne();
|
||||
expect(props.collapseAll.mock.calls.length).toBe(1);
|
||||
expect(props.expandAll.mock.calls.length).toBe(1);
|
||||
expect(props.expandOne.mock.calls.length).toBe(1);
|
||||
expect(props.collapseOne.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
174
packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx
Normal file
174
packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { css } from 'emotion';
|
||||
|
||||
import TimelineHeaderRow from './TimelineHeaderRow';
|
||||
import VirtualizedTraceView from './VirtualizedTraceView';
|
||||
import { merge as mergeShortcuts } from '../keyboard-shortcuts';
|
||||
import { Accessors } from '../ScrollManager';
|
||||
import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from './types';
|
||||
import { TNil } from '../types';
|
||||
import { Span, Trace, Log, KeyValuePair, Link } from '../types/trace';
|
||||
import TTraceTimeline from '../types/TTraceTimeline';
|
||||
import { createStyle } from '../Theme';
|
||||
import ExternalLinkContext from '../url/externalLinkContext';
|
||||
|
||||
type TExtractUiFindFromStateReturn = {
|
||||
uiFind: string | undefined;
|
||||
};
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
TraceTimelineViewer: css`
|
||||
border-bottom: 1px solid #bbb;
|
||||
|
||||
& .json-markup {
|
||||
line-height: 17px;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
& .json-markup-key {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& .json-markup-bool {
|
||||
color: firebrick;
|
||||
}
|
||||
|
||||
& .json-markup-string {
|
||||
color: teal;
|
||||
}
|
||||
|
||||
& .json-markup-null {
|
||||
color: teal;
|
||||
}
|
||||
|
||||
& .json-markup-number {
|
||||
color: blue;
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type TProps = TExtractUiFindFromStateReturn & {
|
||||
registerAccessors: (accessors: Accessors) => void;
|
||||
findMatchesIDs: Set<string> | TNil;
|
||||
scrollToFirstVisibleSpan: () => void;
|
||||
traceTimeline: TTraceTimeline;
|
||||
trace: Trace;
|
||||
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
|
||||
updateViewRangeTime: TUpdateViewRangeTimeFunction;
|
||||
viewRange: ViewRange;
|
||||
focusSpan: (uiFind: string) => void;
|
||||
createLinkToExternalSpan: (traceID: string, spanID: string) => string;
|
||||
|
||||
setSpanNameColumnWidth: (width: number) => void;
|
||||
collapseAll: (spans: Span[]) => void;
|
||||
collapseOne: (spans: Span[]) => void;
|
||||
expandAll: () => void;
|
||||
expandOne: (spans: Span[]) => void;
|
||||
|
||||
childrenToggle: (spanID: string) => void;
|
||||
clearShouldScrollToFirstUiFindMatch: () => void;
|
||||
detailLogItemToggle: (spanID: string, log: Log) => void;
|
||||
detailLogsToggle: (spanID: string) => void;
|
||||
detailWarningsToggle: (spanID: string) => void;
|
||||
detailReferencesToggle: (spanID: string) => void;
|
||||
detailProcessToggle: (spanID: string) => void;
|
||||
detailTagsToggle: (spanID: string) => void;
|
||||
detailToggle: (spanID: string) => void;
|
||||
setTrace: (trace: Trace | TNil, uiFind: string | TNil) => void;
|
||||
addHoverIndentGuideId: (spanID: string) => void;
|
||||
removeHoverIndentGuideId: (spanID: string) => void;
|
||||
linksGetter: (span: Span, items: KeyValuePair[], itemIndex: number) => Link[];
|
||||
};
|
||||
|
||||
const NUM_TICKS = 5;
|
||||
|
||||
/**
|
||||
* `TraceTimelineViewer` now renders the header row because it is sensitive to
|
||||
* `props.viewRange.time.cursor`. If `VirtualizedTraceView` renders it, it will
|
||||
* re-render the ListView every time the cursor is moved on the trace minimap
|
||||
* or `TimelineHeaderRow`.
|
||||
*/
|
||||
export default class TraceTimelineViewer extends React.PureComponent<TProps> {
|
||||
componentDidMount() {
|
||||
mergeShortcuts({
|
||||
collapseAll: this.collapseAll,
|
||||
expandAll: this.expandAll,
|
||||
collapseOne: this.collapseOne,
|
||||
expandOne: this.expandOne,
|
||||
});
|
||||
}
|
||||
|
||||
collapseAll = () => {
|
||||
this.props.collapseAll(this.props.trace.spans);
|
||||
};
|
||||
|
||||
collapseOne = () => {
|
||||
this.props.collapseOne(this.props.trace.spans);
|
||||
};
|
||||
|
||||
expandAll = () => {
|
||||
this.props.expandAll();
|
||||
};
|
||||
|
||||
expandOne = () => {
|
||||
this.props.expandOne(this.props.trace.spans);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
setSpanNameColumnWidth,
|
||||
updateNextViewRangeTime,
|
||||
updateViewRangeTime,
|
||||
viewRange,
|
||||
createLinkToExternalSpan,
|
||||
traceTimeline,
|
||||
...rest
|
||||
} = this.props;
|
||||
const { trace } = rest;
|
||||
const styles = getStyles();
|
||||
|
||||
return (
|
||||
<ExternalLinkContext.Provider value={createLinkToExternalSpan}>
|
||||
<div className={styles.TraceTimelineViewer}>
|
||||
<TimelineHeaderRow
|
||||
duration={trace.duration}
|
||||
nameColumnWidth={traceTimeline.spanNameColumnWidth}
|
||||
numTicks={NUM_TICKS}
|
||||
onCollapseAll={this.collapseAll}
|
||||
onCollapseOne={this.collapseOne}
|
||||
onColummWidthChange={setSpanNameColumnWidth}
|
||||
onExpandAll={this.expandAll}
|
||||
onExpandOne={this.expandOne}
|
||||
viewRangeTime={viewRange.time}
|
||||
updateNextViewRangeTime={updateNextViewRangeTime}
|
||||
updateViewRangeTime={updateViewRangeTime}
|
||||
/>
|
||||
<VirtualizedTraceView
|
||||
{...rest}
|
||||
{...traceTimeline}
|
||||
setSpanNameColumnWidth={setSpanNameColumnWidth}
|
||||
currentViewRangeTime={viewRange.time.current}
|
||||
/>
|
||||
</div>
|
||||
</ExternalLinkContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { TNil } from '../types';
|
||||
|
||||
interface TimeCursorUpdate {
|
||||
cursor: number | TNil;
|
||||
}
|
||||
|
||||
interface TimeReframeUpdate {
|
||||
reframe: {
|
||||
anchor: number;
|
||||
shift: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TimeShiftEndUpdate {
|
||||
shiftEnd: number;
|
||||
}
|
||||
|
||||
interface TimeShiftStartUpdate {
|
||||
shiftStart: number;
|
||||
}
|
||||
|
||||
export type TUpdateViewRangeTimeFunction = (start: number, end: number, trackSrc?: string) => void;
|
||||
|
||||
export type ViewRangeTimeUpdate = TimeCursorUpdate | TimeReframeUpdate | TimeShiftEndUpdate | TimeShiftStartUpdate;
|
||||
|
||||
export interface ViewRangeTime {
|
||||
current: [number, number];
|
||||
cursor?: number | TNil;
|
||||
reframe?: {
|
||||
anchor: number;
|
||||
shift: number;
|
||||
};
|
||||
shiftEnd?: number;
|
||||
shiftStart?: number;
|
||||
}
|
||||
|
||||
export interface ViewRange {
|
||||
time: ViewRangeTime;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import {
|
||||
findServerChildSpan,
|
||||
createViewedBoundsFunc,
|
||||
isClientSpan,
|
||||
isErrorSpan,
|
||||
isServerSpan,
|
||||
spanContainsErredSpan,
|
||||
spanHasTag,
|
||||
} from './utils';
|
||||
|
||||
import traceGenerator from '../demo/trace-generators';
|
||||
|
||||
describe('TraceTimelineViewer/utils', () => {
|
||||
describe('getViewedBounds()', () => {
|
||||
it('works for the full range', () => {
|
||||
const args = { min: 1, max: 2, viewStart: 0, viewEnd: 1 };
|
||||
const { start, end } = createViewedBoundsFunc(args)(1, 2);
|
||||
expect(start).toBe(0);
|
||||
expect(end).toBe(1);
|
||||
});
|
||||
|
||||
it('works for a sub-range with a full view', () => {
|
||||
const args = { min: 1, max: 2, viewStart: 0, viewEnd: 1 };
|
||||
const { start, end } = createViewedBoundsFunc(args)(1.25, 1.75);
|
||||
expect(start).toBe(0.25);
|
||||
expect(end).toBe(0.75);
|
||||
});
|
||||
|
||||
it('works for a sub-range that fills the view', () => {
|
||||
const args = { min: 1, max: 2, viewStart: 0.25, viewEnd: 0.75 };
|
||||
const { start, end } = createViewedBoundsFunc(args)(1.25, 1.75);
|
||||
expect(start).toBe(0);
|
||||
expect(end).toBe(1);
|
||||
});
|
||||
|
||||
it('works for a sub-range that within a sub-view', () => {
|
||||
const args = { min: 100, max: 200, viewStart: 0.1, viewEnd: 0.9 };
|
||||
const { start, end } = createViewedBoundsFunc(args)(130, 170);
|
||||
expect(start).toBe(0.25);
|
||||
expect(end).toBe(0.75);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spanHasTag() and variants', () => {
|
||||
it('returns true iff the key/value pair is found', () => {
|
||||
const tags = traceGenerator.tags();
|
||||
tags.push({ key: 'span.kind', value: 'server' });
|
||||
expect(spanHasTag('span.kind', 'client', { tags })).toBe(false);
|
||||
expect(spanHasTag('span.kind', 'client', { tags })).toBe(false);
|
||||
expect(spanHasTag('span.kind', 'server', { tags })).toBe(true);
|
||||
});
|
||||
|
||||
const spanTypeTestCases = [
|
||||
{ fn: isClientSpan, name: 'isClientSpan', key: 'span.kind', value: 'client' },
|
||||
{ fn: isServerSpan, name: 'isServerSpan', key: 'span.kind', value: 'server' },
|
||||
{ fn: isErrorSpan, name: 'isErrorSpan', key: 'error', value: true },
|
||||
{ fn: isErrorSpan, name: 'isErrorSpan', key: 'error', value: 'true' },
|
||||
];
|
||||
|
||||
spanTypeTestCases.forEach(testCase => {
|
||||
const msg = `${testCase.name}() is true only when a ${testCase.key}=${testCase.value} tag is present`;
|
||||
it(msg, () => {
|
||||
const span = { tags: traceGenerator.tags() };
|
||||
expect(testCase.fn(span)).toBe(false);
|
||||
span.tags.push(testCase);
|
||||
expect(testCase.fn(span)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('spanContainsErredSpan()', () => {
|
||||
it('returns true only when a descendant has an error tag', () => {
|
||||
const errorTag = { key: 'error', type: 'bool', value: true };
|
||||
const getTags = withError =>
|
||||
withError ? traceGenerator.tags().concat(errorTag) : traceGenerator.tags();
|
||||
|
||||
// Using a string to generate the test spans. Each line results in a span. The
|
||||
// left number indicates whether or not the generated span has a descendant
|
||||
// with an error tag (the expectation). The length of the line indicates the
|
||||
// depth of the span (i.e. further right is higher depth). The right number
|
||||
// indicates whether or not the span has an error tag.
|
||||
const config = `
|
||||
1 0
|
||||
1 0
|
||||
0 1
|
||||
0 0
|
||||
1 0
|
||||
1 1
|
||||
0 1
|
||||
0 0
|
||||
1 0
|
||||
0 1
|
||||
0 0
|
||||
`
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(s => s.trim());
|
||||
// Get the expectation, str -> number -> bool
|
||||
const expectations = config.map(s => Boolean(Number(s[0])));
|
||||
const spans = config.map(line => ({
|
||||
depth: line.length,
|
||||
tags: getTags(+line.slice(-1)),
|
||||
}));
|
||||
|
||||
expectations.forEach((target, i) => {
|
||||
// include the index in the expect condition to know which span failed
|
||||
// (if there is a failure, that is)
|
||||
const result = [i, spanContainsErredSpan(spans, i)];
|
||||
expect(result).toEqual([i, target]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findServerChildSpan()', () => {
|
||||
let spans;
|
||||
|
||||
beforeEach(() => {
|
||||
spans = [
|
||||
{ depth: 0, tags: [{ key: 'span.kind', value: 'client' }] },
|
||||
{ depth: 1, tags: [] },
|
||||
{ depth: 1, tags: [{ key: 'span.kind', value: 'server' }] },
|
||||
{ depth: 1, tags: [{ key: 'span.kind', value: 'third-kind' }] },
|
||||
{ depth: 1, tags: [{ key: 'span.kind', value: 'server' }] },
|
||||
];
|
||||
});
|
||||
|
||||
it('returns falsy if the frist span is not a client', () => {
|
||||
expect(findServerChildSpan(spans.slice(1))).toBeFalsy();
|
||||
});
|
||||
|
||||
it('returns the first server span', () => {
|
||||
const span = findServerChildSpan(spans);
|
||||
expect(span).toBe(spans[2]);
|
||||
});
|
||||
|
||||
it('bails when a non-child-depth span is encountered', () => {
|
||||
spans[1].depth++;
|
||||
expect(findServerChildSpan(spans)).toBeFalsy();
|
||||
spans[1].depth = spans[0].depth;
|
||||
expect(findServerChildSpan(spans)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
115
packages/jaeger-ui-components/src/TraceTimelineViewer/utils.tsx
Normal file
115
packages/jaeger-ui-components/src/TraceTimelineViewer/utils.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Span } from '../types/trace';
|
||||
|
||||
export type ViewedBoundsFunctionType = (start: number, end: number) => { start: number; end: number };
|
||||
/**
|
||||
* Given a range (`min`, `max`) and factoring in a zoom (`viewStart`, `viewEnd`)
|
||||
* a function is created that will find the position of a sub-range (`start`, `end`).
|
||||
* The calling the generated method will return the result as a `{ start, end }`
|
||||
* object with values ranging in [0, 1].
|
||||
*
|
||||
* @param {number} min The start of the outer range.
|
||||
* @param {number} max The end of the outer range.
|
||||
* @param {number} viewStart The start of the zoom, on a range of [0, 1],
|
||||
* relative to the `min`, `max`.
|
||||
* @param {number} viewEnd The end of the zoom, on a range of [0, 1],
|
||||
* relative to the `min`, `max`.
|
||||
* @returns {(number, number) => Object} Created view bounds function
|
||||
*/
|
||||
export function createViewedBoundsFunc(viewRange: { min: number; max: number; viewStart: number; viewEnd: number }) {
|
||||
const { min, max, viewStart, viewEnd } = viewRange;
|
||||
const duration = max - min;
|
||||
const viewMin = min + viewStart * duration;
|
||||
const viewMax = max - (1 - viewEnd) * duration;
|
||||
const viewWindow = viewMax - viewMin;
|
||||
|
||||
/**
|
||||
* View bounds function
|
||||
* @param {number} start The start of the sub-range.
|
||||
* @param {number} end The end of the sub-range.
|
||||
* @return {Object} The resultant range.
|
||||
*/
|
||||
return (start: number, end: number) => ({
|
||||
start: (start - viewMin) / viewWindow,
|
||||
end: (end - viewMin) / viewWindow,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the `span` has a tag matching `key` = `value`.
|
||||
*
|
||||
* @param {string} key The tag key to match on.
|
||||
* @param {any} value The tag value to match.
|
||||
* @param {{tags}} span An object with a `tags` property of { key, value }
|
||||
* items.
|
||||
* @return {boolean} True if a match was found.
|
||||
*/
|
||||
export function spanHasTag(key: string, value: any, span: Span) {
|
||||
if (!Array.isArray(span.tags) || !span.tags.length) {
|
||||
return false;
|
||||
}
|
||||
return span.tags.some(tag => tag.key === key && tag.value === value);
|
||||
}
|
||||
|
||||
export const isClientSpan = spanHasTag.bind(null, 'span.kind', 'client');
|
||||
export const isServerSpan = spanHasTag.bind(null, 'span.kind', 'server');
|
||||
|
||||
const isErrorBool = spanHasTag.bind(null, 'error', true);
|
||||
const isErrorStr = spanHasTag.bind(null, 'error', 'true');
|
||||
export const isErrorSpan = (span: Span) => isErrorBool(span) || isErrorStr(span);
|
||||
|
||||
/**
|
||||
* Returns `true` if at least one of the descendants of the `parentSpanIndex`
|
||||
* span contains an error tag.
|
||||
*
|
||||
* @param {Span[]} spans The spans for a trace - should be
|
||||
* sorted with children following parents.
|
||||
* @param {number} parentSpanIndex The index of the parent span - only
|
||||
* subsequent spans with depth less than
|
||||
* the parent span will be checked.
|
||||
* @return {boolean} Returns `true` if a descendant contains an error tag.
|
||||
*/
|
||||
export function spanContainsErredSpan(spans: Span[], parentSpanIndex: number) {
|
||||
const { depth } = spans[parentSpanIndex];
|
||||
let i = parentSpanIndex + 1;
|
||||
for (; i < spans.length && spans[i].depth > depth; i++) {
|
||||
if (isErrorSpan(spans[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expects the first span to be the parent span.
|
||||
*/
|
||||
export function findServerChildSpan(spans: Span[]) {
|
||||
if (spans.length <= 1 || !isClientSpan(spans[0])) {
|
||||
return false;
|
||||
}
|
||||
const span = spans[0];
|
||||
const spanChildDepth = span.depth + 1;
|
||||
let i = 1;
|
||||
while (i < spans.length && spans[i].depth === spanChildDepth) {
|
||||
if (isServerSpan(spans[i])) {
|
||||
return spans[i];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export { formatDuration } from '../utils/date';
|
||||
194
packages/jaeger-ui-components/src/Tween.test.js
Normal file
194
packages/jaeger-ui-components/src/Tween.test.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import Tween from './Tween';
|
||||
|
||||
describe('Tween', () => {
|
||||
const oldNow = Date.now;
|
||||
const nowFn = jest.fn();
|
||||
const oldSetTimeout = window.setTimeout;
|
||||
const setTimeoutFn = jest.fn();
|
||||
const oldRaf = window.requestAnimationFrame;
|
||||
const rafFn = jest.fn();
|
||||
|
||||
const baseOptions = { duration: 10, from: 0, to: 1 };
|
||||
|
||||
Date.now = nowFn;
|
||||
window.setTimeout = setTimeoutFn;
|
||||
window.requestAnimationFrame = rafFn;
|
||||
|
||||
beforeEach(() => {
|
||||
nowFn.mockReset();
|
||||
nowFn.mockReturnValue(0);
|
||||
setTimeoutFn.mockReset();
|
||||
rafFn.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Date.now = oldNow;
|
||||
window.setTimeout = oldSetTimeout;
|
||||
window.requestAnimationFrame = oldRaf;
|
||||
});
|
||||
|
||||
describe('ctor', () => {
|
||||
it('set startTime to the current time', () => {
|
||||
const n = Math.random();
|
||||
nowFn.mockReturnValue(n);
|
||||
const tween = new Tween(baseOptions);
|
||||
expect(tween.startTime).toBe(n);
|
||||
});
|
||||
|
||||
it('adds delay to the startTime', () => {
|
||||
const n = Math.random();
|
||||
nowFn.mockReturnValue(n);
|
||||
const tween = new Tween({ ...baseOptions, delay: 10 });
|
||||
expect(tween.startTime).toBe(n + 10);
|
||||
});
|
||||
|
||||
describe('with callbacks', () => {
|
||||
it('schedules setTimeout if there is a delay', () => {
|
||||
const delay = 10;
|
||||
const tween = new Tween({ ...baseOptions, delay, onUpdate: jest.fn() });
|
||||
expect(setTimeoutFn).lastCalledWith(tween._frameCallback, delay);
|
||||
});
|
||||
|
||||
it('schedules animation frame if there isnt a delay', () => {
|
||||
const tween = new Tween({ ...baseOptions, onUpdate: jest.fn() });
|
||||
expect(rafFn).lastCalledWith(tween._frameCallback);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrent()', () => {
|
||||
it('returns `{done: false, value: from}` when time is before the delay is finished', () => {
|
||||
const tween = new Tween({ ...baseOptions, delay: 1 });
|
||||
const current = tween.getCurrent();
|
||||
expect(current).toEqual({ done: false, value: baseOptions.from });
|
||||
});
|
||||
|
||||
describe('in progress tweens', () => {
|
||||
it('returns `{done: false...`}', () => {
|
||||
const tween = new Tween(baseOptions);
|
||||
nowFn.mockReturnValue(1);
|
||||
const current = tween.getCurrent();
|
||||
expect(current.done).toBe(false);
|
||||
expect(nowFn()).toBeLessThan(tween.startTime + tween.duration);
|
||||
expect(nowFn()).toBeGreaterThan(tween.startTime);
|
||||
});
|
||||
|
||||
it('progresses `{..., value} as time progresses', () => {
|
||||
const tween = new Tween(baseOptions);
|
||||
let lastValue = tween.getCurrent().value;
|
||||
for (let i = 1; i < baseOptions.duration; i++) {
|
||||
nowFn.mockReturnValue(i);
|
||||
const { done, value } = tween.getCurrent();
|
||||
expect(done).toBe(false);
|
||||
expect(value).toBeGreaterThan(lastValue);
|
||||
lastValue = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns `{done: true, value: to}` when the time is past the duration', () => {
|
||||
const tween = new Tween(baseOptions);
|
||||
nowFn.mockReturnValue(baseOptions.duration);
|
||||
const current = tween.getCurrent();
|
||||
expect(current).toEqual({ done: true, value: baseOptions.to });
|
||||
});
|
||||
});
|
||||
|
||||
describe('_frameCallback', () => {
|
||||
it('freezes the callback argument', () => {
|
||||
let current;
|
||||
const fn = jest.fn(_current => {
|
||||
current = _current;
|
||||
});
|
||||
const tween = new Tween({ ...baseOptions, onUpdate: fn });
|
||||
tween._frameCallback();
|
||||
expect(current).toBeDefined();
|
||||
const copy = { ...current };
|
||||
try {
|
||||
current.done = !current.done;
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (_) {}
|
||||
expect(current).toEqual(copy);
|
||||
});
|
||||
|
||||
it('calls onUpdate if there is an onUpdate callback', () => {
|
||||
const fn = jest.fn();
|
||||
const tween = new Tween({ ...baseOptions, onUpdate: fn });
|
||||
tween._frameCallback();
|
||||
const current = tween.getCurrent();
|
||||
expect(current).toBeDefined();
|
||||
expect(fn).lastCalledWith(current);
|
||||
});
|
||||
|
||||
it('does not call onComplete if there is an onComplete callback and the tween is not complete', () => {
|
||||
const fn = jest.fn();
|
||||
const tween = new Tween({ ...baseOptions, onComplete: fn });
|
||||
tween._frameCallback();
|
||||
expect(fn.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('calls onComplete if there is an onComplete callback and the tween is complete', () => {
|
||||
const fn = jest.fn();
|
||||
const tween = new Tween({ ...baseOptions, onComplete: fn });
|
||||
nowFn.mockReturnValue(nowFn() + baseOptions.duration);
|
||||
tween._frameCallback();
|
||||
const current = tween.getCurrent();
|
||||
expect(fn.mock.calls).toEqual([[current]]);
|
||||
expect(current.done).toBe(true);
|
||||
});
|
||||
|
||||
it('schedules an animatinon frame if the tween is not complete', () => {
|
||||
expect(rafFn.mock.calls.length).toBe(0);
|
||||
const tween = new Tween({ ...baseOptions, onUpdate: () => {} });
|
||||
nowFn.mockReturnValue(nowFn() + 0.5 * baseOptions.duration);
|
||||
rafFn.mockReset();
|
||||
tween._frameCallback();
|
||||
expect(rafFn.mock.calls).toEqual([[tween._frameCallback]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel()', () => {
|
||||
it('cancels scheduled timeouts or animation frames', () => {
|
||||
const oldClearTimeout = window.clearTimeout;
|
||||
const oldCancelRaf = window.cancelAnimationFrame;
|
||||
const clearFn = jest.fn();
|
||||
window.clearTimeout = clearFn;
|
||||
const cancelFn = jest.fn();
|
||||
window.cancelAnimationFrame = cancelFn;
|
||||
|
||||
const tween = new Tween(baseOptions);
|
||||
const id = 1;
|
||||
tween.timeoutID = id;
|
||||
tween.requestID = id;
|
||||
tween.cancel();
|
||||
expect(clearFn.mock.calls).toEqual([[id]]);
|
||||
expect(cancelFn.mock.calls).toEqual([[id]]);
|
||||
expect(tween.timeoutID).toBe(undefined);
|
||||
expect(tween.requestID).toBe(undefined);
|
||||
|
||||
window.clearTimeout = oldClearTimeout;
|
||||
window.cancelAnimationFrame = oldCancelRaf;
|
||||
});
|
||||
|
||||
it('releases references to callbacks', () => {
|
||||
const tween = new Tween({ ...baseOptions, onComplete: () => {}, onUpdate: () => {} });
|
||||
tween.cancel();
|
||||
expect(tween.callbackComplete).toBe(undefined);
|
||||
expect(tween.callbackUpdate).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
114
packages/jaeger-ui-components/src/Tween.tsx
Normal file
114
packages/jaeger-ui-components/src/Tween.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import ease from 'tween-functions';
|
||||
|
||||
import { TNil } from './types';
|
||||
|
||||
interface ITweenState {
|
||||
done: boolean;
|
||||
value: number;
|
||||
}
|
||||
|
||||
type TTweenCallback = (state: ITweenState) => void;
|
||||
|
||||
type TTweenOptions = {
|
||||
delay?: number;
|
||||
duration: number;
|
||||
from: number;
|
||||
onComplete?: TTweenCallback;
|
||||
onUpdate?: TTweenCallback;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export default class Tween {
|
||||
callbackComplete: TTweenCallback | TNil;
|
||||
callbackUpdate: TTweenCallback | TNil;
|
||||
delay: number | TNil;
|
||||
duration: number;
|
||||
from: number;
|
||||
requestID: number | TNil;
|
||||
startTime: number;
|
||||
timeoutID: number | TNil;
|
||||
to: number;
|
||||
|
||||
constructor({ duration, from, to, delay, onUpdate, onComplete }: TTweenOptions) {
|
||||
this.startTime = Date.now() + (delay || 0);
|
||||
this.duration = duration;
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
if (!onUpdate && !onComplete) {
|
||||
this.callbackComplete = undefined;
|
||||
this.callbackUpdate = undefined;
|
||||
this.timeoutID = undefined;
|
||||
this.requestID = undefined;
|
||||
} else {
|
||||
this.callbackComplete = onComplete;
|
||||
this.callbackUpdate = onUpdate;
|
||||
if (delay) {
|
||||
// setTimeout from @types/node returns NodeJS.Timeout, so prefix with `window.`
|
||||
this.timeoutID = window.setTimeout(this._frameCallback, delay);
|
||||
this.requestID = undefined;
|
||||
} else {
|
||||
this.requestID = window.requestAnimationFrame(this._frameCallback);
|
||||
this.timeoutID = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_frameCallback = () => {
|
||||
this.timeoutID = undefined;
|
||||
this.requestID = undefined;
|
||||
const current = Object.freeze(this.getCurrent());
|
||||
if (this.callbackUpdate) {
|
||||
this.callbackUpdate(current);
|
||||
}
|
||||
if (this.callbackComplete && current.done) {
|
||||
this.callbackComplete(current);
|
||||
}
|
||||
if (current.done) {
|
||||
this.callbackComplete = undefined;
|
||||
this.callbackUpdate = undefined;
|
||||
} else {
|
||||
this.requestID = window.requestAnimationFrame(this._frameCallback);
|
||||
}
|
||||
};
|
||||
|
||||
cancel() {
|
||||
if (this.timeoutID != null) {
|
||||
clearTimeout(this.timeoutID);
|
||||
this.timeoutID = undefined;
|
||||
}
|
||||
if (this.requestID != null) {
|
||||
window.cancelAnimationFrame(this.requestID);
|
||||
this.requestID = undefined;
|
||||
}
|
||||
this.callbackComplete = undefined;
|
||||
this.callbackUpdate = undefined;
|
||||
}
|
||||
|
||||
getCurrent(): ITweenState {
|
||||
const t = Date.now() - this.startTime;
|
||||
if (t <= 0) {
|
||||
// still in the delay period
|
||||
return { done: false, value: this.from };
|
||||
}
|
||||
if (t >= this.duration) {
|
||||
// after the expiration
|
||||
return { done: true, value: this.to };
|
||||
}
|
||||
// mid-tween
|
||||
return { done: false, value: ease.easeOutQuint(t, this.from, this.to, this.duration) };
|
||||
}
|
||||
}
|
||||
70
packages/jaeger-ui-components/src/common/CopyIcon.test.js
Normal file
70
packages/jaeger-ui-components/src/common/CopyIcon.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import * as copy from 'copy-to-clipboard';
|
||||
import { UIButton, UITooltip } from '../uiElementsContext';
|
||||
|
||||
import CopyIcon from './CopyIcon';
|
||||
|
||||
jest.mock('copy-to-clipboard');
|
||||
|
||||
describe('<CopyIcon />', () => {
|
||||
const props = {
|
||||
className: 'classNameValue',
|
||||
copyText: 'copyTextValue',
|
||||
tooltipTitle: 'tooltipTitleValue',
|
||||
};
|
||||
let copySpy;
|
||||
let wrapper;
|
||||
|
||||
beforeAll(() => {
|
||||
copySpy = jest.spyOn(copy, 'default');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
copySpy.mockReset();
|
||||
wrapper = shallow(<CopyIcon {...props} />);
|
||||
});
|
||||
|
||||
it('renders as expected', () => {
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('updates state and copies when clicked', () => {
|
||||
expect(wrapper.state().hasCopied).toBe(false);
|
||||
expect(copySpy).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.find(UIButton).simulate('click');
|
||||
expect(wrapper.state().hasCopied).toBe(true);
|
||||
expect(copySpy).toHaveBeenCalledWith(props.copyText);
|
||||
});
|
||||
|
||||
it('updates state when tooltip hides and state.hasCopied is true', () => {
|
||||
wrapper.setState({ hasCopied: true });
|
||||
wrapper.find(UITooltip).prop('onVisibleChange')(false);
|
||||
expect(wrapper.state().hasCopied).toBe(false);
|
||||
|
||||
const state = wrapper.state();
|
||||
wrapper.find(UITooltip).prop('onVisibleChange')(false);
|
||||
expect(wrapper.state()).toBe(state);
|
||||
});
|
||||
|
||||
it('persists state when tooltip opens', () => {
|
||||
wrapper.setState({ hasCopied: true });
|
||||
wrapper.find(UITooltip).prop('onVisibleChange')(true);
|
||||
expect(wrapper.state().hasCopied).toBe(true);
|
||||
});
|
||||
});
|
||||
97
packages/jaeger-ui-components/src/common/CopyIcon.tsx
Normal file
97
packages/jaeger-ui-components/src/common/CopyIcon.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
import copy from 'copy-to-clipboard';
|
||||
|
||||
import { UITooltip, TooltipPlacement, UIButton } from '../uiElementsContext';
|
||||
import { createStyle } from '../Theme';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
CopyIcon: css`
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0px;
|
||||
&:focus {
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
color: inherit;
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type PropsType = {
|
||||
className?: string;
|
||||
copyText: string;
|
||||
icon?: string;
|
||||
placement?: TooltipPlacement;
|
||||
tooltipTitle: string;
|
||||
};
|
||||
|
||||
type StateType = {
|
||||
hasCopied: boolean;
|
||||
};
|
||||
|
||||
export default class CopyIcon extends React.PureComponent<PropsType, StateType> {
|
||||
static defaultProps: Partial<PropsType> = {
|
||||
className: undefined,
|
||||
icon: 'copy',
|
||||
placement: 'left',
|
||||
};
|
||||
|
||||
state = {
|
||||
hasCopied: false,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.setState({
|
||||
hasCopied: true,
|
||||
});
|
||||
copy(this.props.copyText);
|
||||
};
|
||||
|
||||
handleTooltipVisibilityChange = (visible: boolean) => {
|
||||
if (!visible && this.state.hasCopied) {
|
||||
this.setState({
|
||||
hasCopied: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<UITooltip
|
||||
arrowPointAtCenter
|
||||
mouseLeaveDelay={0.5}
|
||||
onVisibleChange={this.handleTooltipVisibilityChange}
|
||||
placement={this.props.placement}
|
||||
title={this.state.hasCopied ? 'Copied' : this.props.tooltipTitle}
|
||||
>
|
||||
<UIButton
|
||||
className={cx(styles.CopyIcon, this.props.className)}
|
||||
htmlType="button"
|
||||
icon={this.props.icon}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
</UITooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
69
packages/jaeger-ui-components/src/common/LabeledList.tsx
Normal file
69
packages/jaeger-ui-components/src/common/LabeledList.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { createStyle } from '../Theme';
|
||||
import { UIDivider } from '../uiElementsContext';
|
||||
|
||||
const getStyles = createStyle(() => {
|
||||
return {
|
||||
LabeledList: css`
|
||||
label: LabeledList;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`,
|
||||
LabeledListItem: css`
|
||||
label: LabeledListItem;
|
||||
display: inline-block;
|
||||
`,
|
||||
LabeledListLabel: css`
|
||||
label: LabeledListLabel;
|
||||
color: #999;
|
||||
margin-right: 0.25rem;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type LabeledListProps = {
|
||||
className?: string;
|
||||
dividerClassName?: string;
|
||||
items: Array<{ key: string; label: React.ReactNode; value: React.ReactNode }>;
|
||||
};
|
||||
|
||||
export default function LabeledList(props: LabeledListProps) {
|
||||
const { className, dividerClassName, items } = props;
|
||||
const styles = getStyles();
|
||||
return (
|
||||
<ul className={cx(styles.LabeledList, className)}>
|
||||
{items.map(({ key, label, value }, i) => {
|
||||
const divider = i < items.length - 1 && (
|
||||
<li className={styles.LabeledListItem} key={`${key}--divider`}>
|
||||
<UIDivider className={dividerClassName} type="vertical" />
|
||||
</li>
|
||||
);
|
||||
return [
|
||||
<li className={styles.LabeledListItem} key={key}>
|
||||
<span className={styles.LabeledListLabel}>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</li>,
|
||||
divider,
|
||||
];
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import NewWindowIcon, { getStyles } from './NewWindowIcon';
|
||||
|
||||
describe('NewWindowIcon', () => {
|
||||
const props = {
|
||||
notIsLarge: 'not is large',
|
||||
};
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<NewWindowIcon {...props} />);
|
||||
});
|
||||
|
||||
it('renders as expected', () => {
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('adds is-large className when props.isLarge is true', () => {
|
||||
const styles = getStyles();
|
||||
expect(wrapper.hasClass(styles.NewWindowIconLarge)).toBe(false);
|
||||
wrapper.setProps({ isLarge: true });
|
||||
expect(wrapper.hasClass(styles.NewWindowIconLarge)).toBe(true);
|
||||
});
|
||||
});
|
||||
45
packages/jaeger-ui-components/src/common/NewWindowIcon.tsx
Normal file
45
packages/jaeger-ui-components/src/common/NewWindowIcon.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2018 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import cx from 'classnames';
|
||||
import IoAndroidOpen from 'react-icons/lib/io/android-open';
|
||||
import { css } from 'emotion';
|
||||
|
||||
import { createStyle } from '../Theme';
|
||||
|
||||
export const getStyles = createStyle(() => {
|
||||
return {
|
||||
NewWindowIconLarge: css`
|
||||
label: NewWindowIconLarge;
|
||||
font-size: 1.5em;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
type Props = {
|
||||
isLarge?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function NewWindowIcon(props: Props) {
|
||||
const { isLarge, className, ...rest } = props;
|
||||
const styles = getStyles();
|
||||
const cls = cx({ [styles.NewWindowIconLarge]: isLarge }, className);
|
||||
return <IoAndroidOpen className={cls} {...rest} />;
|
||||
}
|
||||
|
||||
NewWindowIcon.defaultProps = {
|
||||
isLarge: false,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<CopyIcon /> renders as expected 1`] = `
|
||||
<UITooltip
|
||||
arrowPointAtCenter={true}
|
||||
mouseLeaveDelay={0.5}
|
||||
onVisibleChange={[Function]}
|
||||
placement="left"
|
||||
title="tooltipTitleValue"
|
||||
>
|
||||
<UIButton
|
||||
className="css-oqwzau classNameValue"
|
||||
htmlType="button"
|
||||
icon="copy"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
</UITooltip>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`NewWindowIcon renders as expected 1`] = `
|
||||
<IoAndroidOpen
|
||||
className=""
|
||||
notIsLarge="not is large"
|
||||
/>
|
||||
`;
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import deepFreeze from 'deep-freeze';
|
||||
|
||||
import { FALLBACK_DAG_MAX_NUM_SERVICES } from './index';
|
||||
|
||||
export default deepFreeze(
|
||||
Object.defineProperty(
|
||||
{
|
||||
archiveEnabled: false,
|
||||
dependencies: {
|
||||
dagMaxNumServices: FALLBACK_DAG_MAX_NUM_SERVICES,
|
||||
menuEnabled: true,
|
||||
},
|
||||
linkPatterns: [],
|
||||
menu: [
|
||||
{
|
||||
label: 'About Jaeger',
|
||||
items: [
|
||||
{
|
||||
label: 'GitHub',
|
||||
url: 'https://github.com/uber/jaeger',
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
url: 'http://jaeger.readthedocs.io/en/latest/',
|
||||
},
|
||||
{
|
||||
label: 'Twitter',
|
||||
url: 'https://twitter.com/JaegerTracing',
|
||||
},
|
||||
{
|
||||
label: 'Discussion Group',
|
||||
url: 'https://groups.google.com/forum/#!forum/jaeger-tracing',
|
||||
},
|
||||
{
|
||||
label: 'Gitter.im',
|
||||
url: 'https://gitter.im/jaegertracing/Lobby',
|
||||
},
|
||||
{
|
||||
label: 'Blog',
|
||||
url: 'https://medium.com/jaegertracing/',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
search: {
|
||||
maxLookback: {
|
||||
label: '2 Days',
|
||||
value: '2d',
|
||||
},
|
||||
maxLimit: 1500,
|
||||
},
|
||||
tracking: {
|
||||
gaID: null,
|
||||
trackErrors: true,
|
||||
},
|
||||
},
|
||||
// fields that should be individually merged vs wholesale replaced
|
||||
'__mergeFields',
|
||||
{ value: ['dependencies', 'search', 'tracking'] }
|
||||
)
|
||||
);
|
||||
|
||||
export const deprecations = [
|
||||
{
|
||||
formerKey: 'dependenciesMenuEnabled',
|
||||
currentKey: 'dependencies.menuEnabled',
|
||||
},
|
||||
{
|
||||
formerKey: 'gaTrackingID',
|
||||
currentKey: 'tracking.gaID',
|
||||
},
|
||||
];
|
||||
28
packages/jaeger-ui-components/src/constants/index.tsx
Normal file
28
packages/jaeger-ui-components/src/constants/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export const TOP_NAV_HEIGHT = 46 as 46;
|
||||
|
||||
export const FALLBACK_DAG_MAX_NUM_SERVICES = 100 as 100;
|
||||
export const FALLBACK_TRACE_NAME = '<trace-without-root-span>' as '<trace-without-root-span>';
|
||||
|
||||
export const FETCH_DONE = 'FETCH_DONE' as 'FETCH_DONE';
|
||||
export const FETCH_ERROR = 'FETCH_ERROR' as 'FETCH_ERROR';
|
||||
export const FETCH_LOADING = 'FETCH_LOADING' as 'FETCH_LOADING';
|
||||
|
||||
export const fetchedState = {
|
||||
DONE: FETCH_DONE,
|
||||
ERROR: FETCH_ERROR,
|
||||
LOADING: FETCH_LOADING,
|
||||
};
|
||||
5
packages/jaeger-ui-components/src/demo/.eslintrc
Normal file
5
packages/jaeger-ui-components/src/demo/.eslintrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"rules": {
|
||||
"import/no-extraneous-dependencies": 0
|
||||
}
|
||||
}
|
||||
173
packages/jaeger-ui-components/src/demo/trace-generators.js
Normal file
173
packages/jaeger-ui-components/src/demo/trace-generators.js
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import Chance from 'chance';
|
||||
|
||||
import { getSpanId } from '../selectors/span';
|
||||
|
||||
const chance = new Chance();
|
||||
|
||||
export const SERVICE_LIST = ['serviceA', 'serviceB', 'serviceC', 'serviceD', 'serviceE', 'serviceF'];
|
||||
export const OPERATIONS_LIST = [
|
||||
'GET',
|
||||
'PUT',
|
||||
'POST',
|
||||
'DELETE',
|
||||
'MySQL::SELECT',
|
||||
'MySQL::INSERT',
|
||||
'MongoDB::find',
|
||||
'MongoDB::update',
|
||||
];
|
||||
|
||||
function setupParentSpan(spans, parentSpanValues) {
|
||||
Object.assign(spans[0], parentSpanValues);
|
||||
return spans;
|
||||
}
|
||||
|
||||
function getParentSpanId(span, levels) {
|
||||
let nestingLevel = chance.integer({ min: 1, max: levels.length });
|
||||
|
||||
// pick the correct nesting level if allocated by the levels calculation
|
||||
levels.forEach((level, idx) => {
|
||||
if (level.indexOf(getSpanId(span)) >= 0) {
|
||||
nestingLevel = idx;
|
||||
}
|
||||
});
|
||||
|
||||
return nestingLevel - 1 >= 0 ? chance.pickone(levels[nestingLevel - 1]) : null;
|
||||
}
|
||||
|
||||
/* this simulates the hierarchy created by CHILD_OF tags */
|
||||
function attachReferences(spans, depth, spansPerLevel) {
|
||||
let levels = [[getSpanId(spans[0])]];
|
||||
|
||||
const duplicateLevelFilter = currentLevels => span =>
|
||||
!currentLevels.find(level => level.indexOf(span.spanID) >= 0);
|
||||
|
||||
while (levels.length < depth) {
|
||||
const remainingSpans = spans.filter(duplicateLevelFilter(levels));
|
||||
if (remainingSpans.length <= 0) break;
|
||||
const newLevel = chance
|
||||
.pickset(remainingSpans, spansPerLevel || chance.integer({ min: 4, max: 8 }))
|
||||
.map(getSpanId);
|
||||
levels.push(newLevel);
|
||||
}
|
||||
|
||||
// filter out empty levels
|
||||
levels = levels.filter(level => level.length > 0);
|
||||
|
||||
return spans.map(span => {
|
||||
const parentSpanId = getParentSpanId(span, levels);
|
||||
return parentSpanId
|
||||
? {
|
||||
...span,
|
||||
references: [
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
traceID: span.traceID,
|
||||
spanID: parentSpanId,
|
||||
},
|
||||
],
|
||||
}
|
||||
: span;
|
||||
});
|
||||
}
|
||||
|
||||
export default chance.mixin({
|
||||
trace({
|
||||
// long trace
|
||||
// very short trace
|
||||
// average case
|
||||
numberOfSpans = chance.pickone([
|
||||
Math.ceil(chance.normal({ mean: 200, dev: 10 })) + 1,
|
||||
Math.ceil(chance.integer({ min: 3, max: 10 })),
|
||||
Math.ceil(chance.normal({ mean: 45, dev: 15 })) + 1,
|
||||
]),
|
||||
numberOfProcesses = chance.integer({ min: 1, max: 10 }),
|
||||
maxDepth = chance.integer({ min: 1, max: 10 }),
|
||||
spansPerLevel = null,
|
||||
}) {
|
||||
const traceID = chance.guid();
|
||||
const duration = chance.integer({ min: 10000, max: 5000000 });
|
||||
const timestamp = (new Date().getTime() - chance.integer({ min: 0, max: 1000 }) * 1000) * 1000;
|
||||
|
||||
const processArray = chance.processes({ numberOfProcesses });
|
||||
const processes = processArray.reduce((pMap, p) => ({ ...pMap, [p.processID]: p }), {});
|
||||
|
||||
let spans = chance.n(chance.span, numberOfSpans, {
|
||||
traceID,
|
||||
processes,
|
||||
traceStartTime: timestamp,
|
||||
traceEndTime: timestamp + duration,
|
||||
});
|
||||
spans = attachReferences(spans, maxDepth, spansPerLevel);
|
||||
if (spans.length > 1) {
|
||||
spans = setupParentSpan(spans, { startTime: timestamp, duration });
|
||||
}
|
||||
|
||||
return {
|
||||
traceID,
|
||||
spans,
|
||||
processes,
|
||||
};
|
||||
},
|
||||
tag() {
|
||||
return {
|
||||
key: 'http.url',
|
||||
type: 'String',
|
||||
value: `/v2/${chance.pickone(['alpha', 'beta', 'gamma'])}/${chance.guid()}`,
|
||||
};
|
||||
},
|
||||
span({
|
||||
traceID = chance.guid(),
|
||||
processes = {},
|
||||
traceStartTime = chance.timestamp() * 1000 * 1000,
|
||||
traceEndTime = traceStartTime + 100000,
|
||||
operations = OPERATIONS_LIST,
|
||||
}) {
|
||||
const startTime = chance.integer({
|
||||
min: traceStartTime,
|
||||
max: traceEndTime,
|
||||
});
|
||||
|
||||
return {
|
||||
traceID,
|
||||
processID: chance.pickone(Object.keys(processes)),
|
||||
spanID: chance.guid(),
|
||||
flags: 0,
|
||||
operationName: chance.pickone(operations),
|
||||
references: [],
|
||||
startTime,
|
||||
duration: chance.integer({ min: 1, max: traceEndTime - startTime }),
|
||||
tags: chance.tags(),
|
||||
logs: [],
|
||||
};
|
||||
},
|
||||
process({ services = SERVICE_LIST }) {
|
||||
return {
|
||||
processID: chance.guid(),
|
||||
serviceName: chance.pickone(services),
|
||||
tags: chance.tags(),
|
||||
};
|
||||
},
|
||||
traces({ numberOfTraces = chance.integer({ min: 5, max: 15 }) }) {
|
||||
return chance.n(chance.trace, numberOfTraces, {});
|
||||
},
|
||||
tags() {
|
||||
return chance.n(chance.tag, chance.integer({ min: 1, max: 10 }), {});
|
||||
},
|
||||
processes({ numberOfProcesses = chance.integer({ min: 1, max: 25 }) }) {
|
||||
return chance.n(chance.process, numberOfProcesses, {});
|
||||
},
|
||||
});
|
||||
13
packages/jaeger-ui-components/src/index.ts
Normal file
13
packages/jaeger-ui-components/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { default as TraceTimelineViewer } from './TraceTimelineViewer';
|
||||
export { default as UIElementsContext } from './uiElementsContext';
|
||||
export * from './uiElementsContext';
|
||||
export * from './types';
|
||||
export * from './TraceTimelineViewer/types';
|
||||
export { default as DetailState } from './TraceTimelineViewer/SpanDetail/DetailState';
|
||||
export { default as transformTraceData } from './model/transform-trace-data';
|
||||
|
||||
import { onlyUpdateForKeys } from 'recompose';
|
||||
|
||||
export default {
|
||||
onlyUpdateForKeys,
|
||||
} as any;
|
||||
36
packages/jaeger-ui-components/src/keyboard-mappings.tsx
Normal file
36
packages/jaeger-ui-components/src/keyboard-mappings.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
const keyboardMappings: Record<string, { binding: string | string[]; label: string }> = {
|
||||
scrollPageDown: { binding: 's', label: 'Scroll down' },
|
||||
scrollPageUp: { binding: 'w', label: 'Scroll up' },
|
||||
scrollToNextVisibleSpan: { binding: 'f', label: 'Scroll to the next visible span' },
|
||||
scrollToPrevVisibleSpan: { binding: 'b', label: 'Scroll to the previous visible span' },
|
||||
panLeft: { binding: ['a', 'left'], label: 'Pan left' },
|
||||
panLeftFast: { binding: ['shift+a', 'shift+left'], label: 'Pan left — Large' },
|
||||
panRight: { binding: ['d', 'right'], label: 'Pan right' },
|
||||
panRightFast: { binding: ['shift+d', 'shift+right'], label: 'Pan right — Large' },
|
||||
zoomIn: { binding: 'up', label: 'Zoom in' },
|
||||
zoomInFast: { binding: 'shift+up', label: 'Zoom in — Large' },
|
||||
zoomOut: { binding: 'down', label: 'Zoom out' },
|
||||
zoomOutFast: { binding: 'shift+down', label: 'Zoom out — Large' },
|
||||
collapseAll: { binding: ']', label: 'Collapse All' },
|
||||
expandAll: { binding: '[', label: 'Expand All' },
|
||||
collapseOne: { binding: 'p', label: 'Collapse One Level' },
|
||||
expandOne: { binding: 'o', label: 'Expand One Level' },
|
||||
searchSpans: { binding: 'ctrl+b', label: 'Search Spans' },
|
||||
clearSearch: { binding: 'escape', label: 'Clear Search' },
|
||||
};
|
||||
|
||||
export default keyboardMappings;
|
||||
53
packages/jaeger-ui-components/src/keyboard-shortcuts.tsx
Normal file
53
packages/jaeger-ui-components/src/keyboard-shortcuts.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react';
|
||||
import Combokeys from 'combokeys';
|
||||
|
||||
import keyboardMappings from './keyboard-mappings';
|
||||
|
||||
export type CombokeysHandler =
|
||||
| (() => void)
|
||||
| ((event: React.KeyboardEvent<any>) => void)
|
||||
| ((event: React.KeyboardEvent<any>, s: string) => void);
|
||||
|
||||
export type ShortcutCallbacks = {
|
||||
[name: string]: CombokeysHandler;
|
||||
};
|
||||
|
||||
let instance: Combokeys | undefined;
|
||||
|
||||
function getInstance(): Combokeys {
|
||||
if (instance) {
|
||||
return instance;
|
||||
}
|
||||
const local = new Combokeys(document.body);
|
||||
instance = local;
|
||||
return local;
|
||||
}
|
||||
|
||||
export function merge(callbacks: ShortcutCallbacks) {
|
||||
const inst = getInstance();
|
||||
Object.keys(callbacks).forEach(name => {
|
||||
const keysHandler = callbacks[name];
|
||||
if (keysHandler) {
|
||||
inst.bind(keyboardMappings[name].binding, keysHandler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
const combokeys = getInstance();
|
||||
combokeys.reset();
|
||||
}
|
||||
186
packages/jaeger-ui-components/src/model/ddg/PathElem.test.js
Normal file
186
packages/jaeger-ui-components/src/model/ddg/PathElem.test.js
Normal file
@@ -0,0 +1,186 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import PathElem from './PathElem';
|
||||
import { simplePath } from './sample-paths.test.resources';
|
||||
|
||||
describe('PathElem', () => {
|
||||
const getPath = () => {
|
||||
const path = {
|
||||
focalIdx: 2,
|
||||
};
|
||||
const members = simplePath.map(
|
||||
({ operation, service }, i) =>
|
||||
new PathElem({
|
||||
memberIdx: i,
|
||||
operation: {
|
||||
name: operation,
|
||||
service: {
|
||||
name: service,
|
||||
},
|
||||
},
|
||||
path,
|
||||
})
|
||||
);
|
||||
members[2].visibilityIdx = 0;
|
||||
members[3].visibilityIdx = 1;
|
||||
members[1].visibilityIdx = 2;
|
||||
members[4].visibilityIdx = 3;
|
||||
members[0].visibilityIdx = 4;
|
||||
path.members = members;
|
||||
return path;
|
||||
};
|
||||
const testMemberIdx = 3;
|
||||
const testOperation = {};
|
||||
const testPath = {
|
||||
focalIdx: 4,
|
||||
members: ['member0', 'member1', 'member2', 'member3', 'member4', 'member5'],
|
||||
};
|
||||
const testVisibilityIdx = 105;
|
||||
let pathElem;
|
||||
|
||||
beforeEach(() => {
|
||||
pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: testMemberIdx });
|
||||
});
|
||||
|
||||
it('initializes instance properties', () => {
|
||||
expect(pathElem.memberIdx).toBe(testMemberIdx);
|
||||
expect(pathElem.memberOf).toBe(testPath);
|
||||
expect(pathElem.operation).toBe(testOperation);
|
||||
});
|
||||
|
||||
it('calculates distance', () => {
|
||||
expect(pathElem.distance).toBe(-1);
|
||||
});
|
||||
|
||||
it('sets visibilityIdx', () => {
|
||||
pathElem.visibilityIdx = testVisibilityIdx;
|
||||
expect(pathElem.visibilityIdx).toBe(testVisibilityIdx);
|
||||
});
|
||||
|
||||
it('errors when trying to access unset visibilityIdx', () => {
|
||||
expect(() => pathElem.visibilityIdx).toThrowError();
|
||||
});
|
||||
|
||||
it('errors when trying to override visibilityIdx', () => {
|
||||
pathElem.visibilityIdx = testVisibilityIdx;
|
||||
expect(() => {
|
||||
pathElem.visibilityIdx = testVisibilityIdx;
|
||||
}).toThrowError();
|
||||
});
|
||||
|
||||
it('has externalSideNeighbor if distance is not 0 and it is not external', () => {
|
||||
expect(pathElem.externalSideNeighbor).toBe(testPath.members[testMemberIdx - 1]);
|
||||
});
|
||||
|
||||
it('has a null externalSideNeighbor if distance is 0', () => {
|
||||
pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: testPath.focalIdx });
|
||||
expect(pathElem.externalSideNeighbor).toBe(null);
|
||||
});
|
||||
|
||||
it('has an undefined externalSideNeighbor if is external', () => {
|
||||
pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: 0 });
|
||||
expect(pathElem.externalSideNeighbor).toBe(undefined);
|
||||
});
|
||||
|
||||
it('has focalSideNeighbor if distance is not 0', () => {
|
||||
expect(pathElem.focalSideNeighbor).toBe(testPath.members[testMemberIdx + 1]);
|
||||
});
|
||||
|
||||
it('has a null focalSideNeighbor if distance is 0', () => {
|
||||
pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: testPath.focalIdx });
|
||||
expect(pathElem.focalSideNeighbor).toBe(null);
|
||||
});
|
||||
|
||||
it('is external if it is first or last PathElem in memberOf.path and not the focalElem', () => {
|
||||
expect(pathElem.isExternal).toBe(false);
|
||||
|
||||
const firstElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: 0 });
|
||||
expect(firstElem.isExternal).toBe(true);
|
||||
|
||||
const lastElem = new PathElem({
|
||||
path: testPath,
|
||||
operation: testOperation,
|
||||
memberIdx: testPath.members.length - 1,
|
||||
});
|
||||
expect(lastElem.isExternal).toBe(true);
|
||||
|
||||
const path = {
|
||||
...testPath,
|
||||
focalIdx: testPath.members.length - 1,
|
||||
};
|
||||
const focalElem = new PathElem({ path, operation: testOperation, memberIdx: path.members.length - 1 });
|
||||
expect(focalElem.isExternal).toBe(false);
|
||||
});
|
||||
|
||||
describe('externalPath', () => {
|
||||
const path = getPath();
|
||||
|
||||
it('returns array of itself if it is focal elem', () => {
|
||||
const targetPathElem = path.members[path.focalIdx];
|
||||
expect(targetPathElem.externalPath).toEqual([targetPathElem]);
|
||||
});
|
||||
|
||||
it('returns path away from focal elem in correct order for upstream elem', () => {
|
||||
const idx = path.focalIdx - 1;
|
||||
const targetPathElem = path.members[idx];
|
||||
expect(targetPathElem.externalPath).toEqual(path.members.slice(0, idx + 1));
|
||||
});
|
||||
|
||||
it('returns path away from focal elem in correct order for downstream elem', () => {
|
||||
const idx = path.focalIdx + 1;
|
||||
const targetPathElem = path.members[idx];
|
||||
expect(targetPathElem.externalPath).toEqual(path.members.slice(idx));
|
||||
});
|
||||
});
|
||||
|
||||
describe('focalPath', () => {
|
||||
const path = getPath();
|
||||
|
||||
it('returns array of itself if it is focal elem', () => {
|
||||
const targetPathElem = path.members[path.focalIdx];
|
||||
expect(targetPathElem.focalPath).toEqual([targetPathElem]);
|
||||
});
|
||||
|
||||
it('returns path to focal elem in correct order for upstream elem', () => {
|
||||
const targetPathElem = path.members[0];
|
||||
expect(targetPathElem.focalPath).toEqual(path.members.slice(0, path.focalIdx + 1));
|
||||
});
|
||||
|
||||
it('returns path to focal elem in correct order for downstream elem', () => {
|
||||
const idx = path.members.length - 1;
|
||||
const targetPathElem = path.members[idx];
|
||||
expect(targetPathElem.focalPath).toEqual(path.members.slice(path.focalIdx, idx + 1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('legibility', () => {
|
||||
const path = getPath();
|
||||
const targetPathElem = path.members[1];
|
||||
|
||||
it('creates consumable JSON', () => {
|
||||
expect(targetPathElem.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('creates consumable string', () => {
|
||||
expect(targetPathElem.toString()).toBe(JSON.stringify(targetPathElem.toJSON(), null, 2));
|
||||
});
|
||||
|
||||
it('creates informative string tag', () => {
|
||||
expect(Object.prototype.toString.call(targetPathElem)).toEqual(
|
||||
`[object PathElem ${targetPathElem.visibilityIdx}]`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
117
packages/jaeger-ui-components/src/model/ddg/PathElem.tsx
Normal file
117
packages/jaeger-ui-components/src/model/ddg/PathElem.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { TDdgOperation, TDdgPath } from './types';
|
||||
|
||||
export default class PathElem {
|
||||
memberIdx: number;
|
||||
memberOf: TDdgPath;
|
||||
operation: TDdgOperation;
|
||||
private _visibilityIdx?: number;
|
||||
|
||||
constructor({ path, operation, memberIdx }: { path: TDdgPath; operation: TDdgOperation; memberIdx: number }) {
|
||||
this.memberIdx = memberIdx;
|
||||
this.memberOf = path;
|
||||
this.operation = operation;
|
||||
}
|
||||
|
||||
get distance() {
|
||||
return this.memberIdx - this.memberOf.focalIdx;
|
||||
}
|
||||
|
||||
get externalPath(): PathElem[] {
|
||||
const result: PathElem[] = [];
|
||||
let current: PathElem | null | undefined = this;
|
||||
while (current) {
|
||||
result.push(current);
|
||||
current = current.externalSideNeighbor;
|
||||
}
|
||||
if (this.distance < 0) result.reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
get externalSideNeighbor(): PathElem | null | undefined {
|
||||
if (!this.distance) return null;
|
||||
return this.memberOf.members[this.memberIdx + Math.sign(this.distance)];
|
||||
}
|
||||
|
||||
get focalPath(): PathElem[] {
|
||||
const result: PathElem[] = [];
|
||||
let current: PathElem | null = this;
|
||||
while (current) {
|
||||
result.push(current);
|
||||
current = current.focalSideNeighbor;
|
||||
}
|
||||
if (this.distance > 0) result.reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
get focalSideNeighbor(): PathElem | null {
|
||||
if (!this.distance) return null;
|
||||
return this.memberOf.members[this.memberIdx - Math.sign(this.distance)];
|
||||
}
|
||||
|
||||
get isExternal(): boolean {
|
||||
return Boolean(this.distance) && (this.memberIdx === 0 || this.memberIdx === this.memberOf.members.length - 1);
|
||||
}
|
||||
|
||||
set visibilityIdx(visibilityIdx: number) {
|
||||
if (this._visibilityIdx == null) {
|
||||
this._visibilityIdx = visibilityIdx;
|
||||
} else {
|
||||
throw new Error('Visibility Index cannot be changed once set');
|
||||
}
|
||||
}
|
||||
|
||||
get visibilityIdx(): number {
|
||||
if (this._visibilityIdx == null) {
|
||||
throw new Error('Visibility Index was never set for this PathElem');
|
||||
}
|
||||
return this._visibilityIdx;
|
||||
}
|
||||
|
||||
private toJSONHelper = () => ({
|
||||
memberIdx: this.memberIdx,
|
||||
operation: this.operation.name,
|
||||
service: this.operation.service.name,
|
||||
visibilityIdx: this._visibilityIdx,
|
||||
});
|
||||
|
||||
/*
|
||||
* Because the memberOf on a PathElem contains an array of all of its members which in turn all contain
|
||||
* memberOf back to the path, some assistance is necessary when creating error messages. toJSON is called by
|
||||
* JSON.stringify and expected to return a JSON object. To that end, this method simplifies the
|
||||
* representation of the PathElems in memberOf's path to remove the circular reference.
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
...this.toJSONHelper(),
|
||||
memberOf: {
|
||||
focalIdx: this.memberOf.focalIdx,
|
||||
members: this.memberOf.members.map(member => member.toJSONHelper()),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// `toJSON` is called by `JSON.stringify` while `toString` is used by template strings and string concat
|
||||
toString() {
|
||||
return JSON.stringify(this.toJSON(), null, 2);
|
||||
}
|
||||
|
||||
// `[Symbol.toStringTag]` is used when attempting to use an object as a key on an object, where a full
|
||||
// stringified JSON would reduce clarity
|
||||
get [Symbol.toStringTag]() {
|
||||
return `PathElem ${this._visibilityIdx}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PathElem legibility creates consumable JSON 1`] = `
|
||||
Object {
|
||||
"memberIdx": 1,
|
||||
"memberOf": Object {
|
||||
"focalIdx": 2,
|
||||
"members": Array [
|
||||
Object {
|
||||
"memberIdx": 0,
|
||||
"operation": "firstOperation",
|
||||
"service": "firstService",
|
||||
"visibilityIdx": 4,
|
||||
},
|
||||
Object {
|
||||
"memberIdx": 1,
|
||||
"operation": "beforeOperation",
|
||||
"service": "beforeService",
|
||||
"visibilityIdx": 2,
|
||||
},
|
||||
Object {
|
||||
"memberIdx": 2,
|
||||
"operation": "focalOperation",
|
||||
"service": "focalService",
|
||||
"visibilityIdx": 0,
|
||||
},
|
||||
Object {
|
||||
"memberIdx": 3,
|
||||
"operation": "afterOperation",
|
||||
"service": "afterService",
|
||||
"visibilityIdx": 1,
|
||||
},
|
||||
Object {
|
||||
"memberIdx": 4,
|
||||
"operation": "lastOperation",
|
||||
"service": "lastService",
|
||||
"visibilityIdx": 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
"operation": "beforeOperation",
|
||||
"service": "beforeService",
|
||||
"visibilityIdx": 2,
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export const simplePayloadElemMaker = label => ({
|
||||
operation: `${label}Operation`,
|
||||
service: `${label}Service`,
|
||||
});
|
||||
|
||||
export const focalPayloadElem = simplePayloadElemMaker('focal');
|
||||
|
||||
const sameFocalServicePayloadElem = {
|
||||
operation: 'someOtherOperation',
|
||||
service: focalPayloadElem.service,
|
||||
};
|
||||
|
||||
const pathLengthener = path => {
|
||||
const prequels = [];
|
||||
const sequels = [];
|
||||
path.forEach(({ operation, service }) => {
|
||||
if (operation !== focalPayloadElem.operation && service !== focalPayloadElem.service) {
|
||||
prequels.push({
|
||||
operation: `prequel-${operation}`,
|
||||
service,
|
||||
});
|
||||
sequels.push({
|
||||
operation,
|
||||
service: `sequel-${service}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
return [...prequels, ...path, ...sequels];
|
||||
};
|
||||
|
||||
export const firstPayloadElem = simplePayloadElemMaker('first');
|
||||
export const beforePayloadElem = simplePayloadElemMaker('before');
|
||||
export const midPayloadElem = simplePayloadElemMaker('mid');
|
||||
export const afterPayloadElem = simplePayloadElemMaker('after');
|
||||
export const lastPayloadElem = simplePayloadElemMaker('last');
|
||||
|
||||
export const shortPath = [beforePayloadElem, focalPayloadElem];
|
||||
export const simplePath = [
|
||||
firstPayloadElem,
|
||||
beforePayloadElem,
|
||||
focalPayloadElem,
|
||||
afterPayloadElem,
|
||||
lastPayloadElem,
|
||||
];
|
||||
export const longSimplePath = pathLengthener(simplePath);
|
||||
export const noFocalPath = [
|
||||
firstPayloadElem,
|
||||
beforePayloadElem,
|
||||
midPayloadElem,
|
||||
afterPayloadElem,
|
||||
lastPayloadElem,
|
||||
];
|
||||
export const doubleFocalPath = [
|
||||
firstPayloadElem,
|
||||
beforePayloadElem,
|
||||
focalPayloadElem,
|
||||
midPayloadElem,
|
||||
focalPayloadElem,
|
||||
afterPayloadElem,
|
||||
lastPayloadElem,
|
||||
];
|
||||
export const almostDoubleFocalPath = [
|
||||
firstPayloadElem,
|
||||
beforePayloadElem,
|
||||
sameFocalServicePayloadElem,
|
||||
midPayloadElem,
|
||||
focalPayloadElem,
|
||||
afterPayloadElem,
|
||||
lastPayloadElem,
|
||||
];
|
||||
|
||||
const divergentPayloadElem = simplePayloadElemMaker('divergentPayloadElem');
|
||||
export const convergentPaths = [
|
||||
[firstPayloadElem, focalPayloadElem, divergentPayloadElem, afterPayloadElem, lastPayloadElem],
|
||||
[firstPayloadElem, focalPayloadElem, midPayloadElem, afterPayloadElem, lastPayloadElem],
|
||||
];
|
||||
|
||||
const generationPayloadElems = {
|
||||
afterFocalMid: simplePayloadElemMaker('afterFocalMid'),
|
||||
afterTarget0: simplePayloadElemMaker('afterTarget0'),
|
||||
afterTarget1: simplePayloadElemMaker('afterTarget1'),
|
||||
beforeFocalMid: simplePayloadElemMaker('beforeFocalMid'),
|
||||
beforeTarget0: simplePayloadElemMaker('beforeTarget0'),
|
||||
beforeTarget1: simplePayloadElemMaker('beforeTarget1'),
|
||||
target: simplePayloadElemMaker('target'),
|
||||
};
|
||||
|
||||
export const generationPaths = [
|
||||
[
|
||||
generationPayloadElems.beforeTarget0,
|
||||
generationPayloadElems.target,
|
||||
generationPayloadElems.beforeFocalMid,
|
||||
focalPayloadElem,
|
||||
],
|
||||
[
|
||||
generationPayloadElems.beforeTarget1,
|
||||
generationPayloadElems.target,
|
||||
generationPayloadElems.beforeFocalMid,
|
||||
focalPayloadElem,
|
||||
],
|
||||
[focalPayloadElem, generationPayloadElems.afterFocalMid, generationPayloadElems.target],
|
||||
[
|
||||
focalPayloadElem,
|
||||
generationPayloadElems.afterFocalMid,
|
||||
generationPayloadElems.target,
|
||||
generationPayloadElems.afterTarget0,
|
||||
],
|
||||
[
|
||||
focalPayloadElem,
|
||||
generationPayloadElems.afterFocalMid,
|
||||
generationPayloadElems.target,
|
||||
generationPayloadElems.afterTarget1,
|
||||
],
|
||||
[generationPayloadElems.target, generationPayloadElems.beforeFocalMid, focalPayloadElem],
|
||||
];
|
||||
|
||||
export const wrap = paths => ({
|
||||
dependencies: paths.map(path => ({ path, attributes: [] })),
|
||||
});
|
||||
46
packages/jaeger-ui-components/src/model/ddg/types.tsx
Normal file
46
packages/jaeger-ui-components/src/model/ddg/types.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import PathElem from './PathElem';
|
||||
|
||||
export { default as PathElem } from './PathElem';
|
||||
|
||||
export type TDdgService = {
|
||||
name: string;
|
||||
operations: Map<string, TDdgOperation>;
|
||||
};
|
||||
|
||||
export type TDdgOperation = {
|
||||
name: string;
|
||||
pathElems: PathElem[];
|
||||
service: TDdgService;
|
||||
};
|
||||
|
||||
export type TDdgServiceMap = Map<string, TDdgService>;
|
||||
|
||||
export type TDdgPath = {
|
||||
focalIdx: number;
|
||||
members: PathElem[];
|
||||
traceIDs: string[];
|
||||
};
|
||||
|
||||
export type TDdgDistanceToPathElems = Map<number, PathElem[]>;
|
||||
|
||||
export type TDdgModel = {
|
||||
distanceToPathElems: TDdgDistanceToPathElems;
|
||||
hash: string;
|
||||
paths: TDdgPath[];
|
||||
services: TDdgServiceMap;
|
||||
visIdxToPathElem: PathElem[];
|
||||
};
|
||||
431
packages/jaeger-ui-components/src/model/link-patterns.test.js
Normal file
431
packages/jaeger-ui-components/src/model/link-patterns.test.js
Normal file
@@ -0,0 +1,431 @@
|
||||
// Copyright (c) 2017 The Jaeger Authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import {
|
||||
processTemplate,
|
||||
createTestFunction,
|
||||
getParameterInArray,
|
||||
getParameterInAncestor,
|
||||
processLinkPattern,
|
||||
computeLinks,
|
||||
createGetLinks,
|
||||
computeTraceLink,
|
||||
} from './link-patterns';
|
||||
|
||||
describe('processTemplate()', () => {
|
||||
it('correctly replaces variables', () => {
|
||||
const processedTemplate = processTemplate(
|
||||
'this is a test with #{oneVariable}#{anotherVariable} and the same #{oneVariable}',
|
||||
a => a
|
||||
);
|
||||
expect(processedTemplate.parameters).toEqual(['oneVariable', 'anotherVariable']);
|
||||
expect(processedTemplate.template({ oneVariable: 'MYFIRSTVAR', anotherVariable: 'SECOND' })).toBe(
|
||||
'this is a test with MYFIRSTVARSECOND and the same MYFIRSTVAR'
|
||||
);
|
||||
});
|
||||
|
||||
it('correctly uses the encoding function', () => {
|
||||
const processedTemplate = processTemplate(
|
||||
'this is a test with #{oneVariable}#{anotherVariable} and the same #{oneVariable}',
|
||||
e => `/${e}\\`
|
||||
);
|
||||
expect(processedTemplate.parameters).toEqual(['oneVariable', 'anotherVariable']);
|
||||
expect(processedTemplate.template({ oneVariable: 'MYFIRSTVAR', anotherVariable: 'SECOND' })).toBe(
|
||||
'this is a test with /MYFIRSTVAR\\/SECOND\\ and the same /MYFIRSTVAR\\'
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
// kept on ice until #123 is implemented:
|
||||
|
||||
it('correctly returns the same object when passing an already processed template', () => {
|
||||
const alreadyProcessed = {
|
||||
parameters: ['b'],
|
||||
template: data => `a${data.b}c`,
|
||||
};
|
||||
const processedTemplate = processTemplate(alreadyProcessed, a => a);
|
||||
expect(processedTemplate).toBe(alreadyProcessed);
|
||||
});
|
||||
|
||||
*/
|
||||
|
||||
it('reports an error when passing an object that does not look like an already processed template', () => {
|
||||
expect(() =>
|
||||
processTemplate(
|
||||
{
|
||||
template: data => `a${data.b}c`,
|
||||
},
|
||||
a => a
|
||||
)
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
processTemplate(
|
||||
{
|
||||
parameters: ['b'],
|
||||
},
|
||||
a => a
|
||||
)
|
||||
).toThrow();
|
||||
expect(() => processTemplate({}, a => a)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTestFunction()', () => {
|
||||
it('accepts a string', () => {
|
||||
const testFn = createTestFunction('myValue');
|
||||
expect(testFn('myValue')).toBe(true);
|
||||
expect(testFn('myFirstValue')).toBe(false);
|
||||
expect(testFn('mySecondValue')).toBe(false);
|
||||
expect(testFn('otherValue')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an array', () => {
|
||||
const testFn = createTestFunction(['myFirstValue', 'mySecondValue']);
|
||||
expect(testFn('myValue')).toBe(false);
|
||||
expect(testFn('myFirstValue')).toBe(true);
|
||||
expect(testFn('mySecondValue')).toBe(true);
|
||||
expect(testFn('otherValue')).toBe(false);
|
||||
});
|
||||
|
||||
/*
|
||||
// kept on ice until #123 is implemented:
|
||||
|
||||
it('accepts a regular expression', () => {
|
||||
const testFn = createTestFunction(/^my.*Value$/);
|
||||
expect(testFn('myValue')).toBe(true);
|
||||
expect(testFn('myFirstValue')).toBe(true);
|
||||
expect(testFn('mySecondValue')).toBe(true);
|
||||
expect(testFn('otherValue')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a function', () => {
|
||||
const mockCallback = jest.fn();
|
||||
mockCallback
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValue(false);
|
||||
const testFn = createTestFunction(mockCallback);
|
||||
expect(testFn('myValue')).toBe(true);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallback).toHaveBeenCalledWith('myValue');
|
||||
expect(testFn('myFirstValue')).toBe(false);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(2);
|
||||
expect(mockCallback).toHaveBeenCalledWith('myFirstValue');
|
||||
expect(testFn('mySecondValue')).toBe(true);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(3);
|
||||
expect(mockCallback).toHaveBeenCalledWith('mySecondValue');
|
||||
expect(testFn('otherValue')).toBe(false);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(4);
|
||||
expect(mockCallback).toHaveBeenCalledWith('otherValue');
|
||||
});
|
||||
|
||||
*/
|
||||
|
||||
it('accepts undefined', () => {
|
||||
const testFn = createTestFunction();
|
||||
expect(testFn('myValue')).toBe(true);
|
||||
expect(testFn('myFirstValue')).toBe(true);
|
||||
expect(testFn('mySecondValue')).toBe(true);
|
||||
expect(testFn('otherValue')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown values', () => {
|
||||
expect(() => createTestFunction({})).toThrow();
|
||||
expect(() => createTestFunction(true)).toThrow();
|
||||
expect(() => createTestFunction(false)).toThrow();
|
||||
expect(() => createTestFunction(0)).toThrow();
|
||||
expect(() => createTestFunction(5)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParameterInArray()', () => {
|
||||
const data = [{ key: 'mykey', value: 'ok' }, { key: 'otherkey', value: 'v' }];
|
||||
|
||||
it('returns an entry that is present', () => {
|
||||
expect(getParameterInArray('mykey', data)).toBe(data[0]);
|
||||
expect(getParameterInArray('otherkey', data)).toBe(data[1]);
|
||||
});
|
||||
|
||||
it('returns undefined when the entry cannot be found', () => {
|
||||
expect(getParameterInArray('myotherkey', data)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when there is no array', () => {
|
||||
expect(getParameterInArray('otherkey')).toBeUndefined();
|
||||
expect(getParameterInArray('otherkey', null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParameterInAncestor()', () => {
|
||||
const spans = [
|
||||
{
|
||||
depth: 0,
|
||||
process: {
|
||||
tags: [
|
||||
{ key: 'a', value: 'a7' },
|
||||
{ key: 'b', value: 'b7' },
|
||||
{ key: 'c', value: 'c7' },
|
||||
{ key: 'd', value: 'd7' },
|
||||
{ key: 'e', value: 'e7' },
|
||||
{ key: 'f', value: 'f7' },
|
||||
{ key: 'g', value: 'g7' },
|
||||
{ key: 'h', value: 'h7' },
|
||||
],
|
||||
},
|
||||
tags: [
|
||||
{ key: 'a', value: 'a6' },
|
||||
{ key: 'b', value: 'b6' },
|
||||
{ key: 'c', value: 'c6' },
|
||||
{ key: 'd', value: 'd6' },
|
||||
{ key: 'e', value: 'e6' },
|
||||
{ key: 'f', value: 'f6' },
|
||||
{ key: 'g', value: 'g6' },
|
||||
],
|
||||
},
|
||||
{
|
||||
depth: 1,
|
||||
process: {
|
||||
tags: [
|
||||
{ key: 'a', value: 'a5' },
|
||||
{ key: 'b', value: 'b5' },
|
||||
{ key: 'c', value: 'c5' },
|
||||
{ key: 'd', value: 'd5' },
|
||||
{ key: 'e', value: 'e5' },
|
||||
{ key: 'f', value: 'f5' },
|
||||
],
|
||||
},
|
||||
tags: [
|
||||
{ key: 'a', value: 'a4' },
|
||||
{ key: 'b', value: 'b4' },
|
||||
{ key: 'c', value: 'c4' },
|
||||
{ key: 'd', value: 'd4' },
|
||||
{ key: 'e', value: 'e4' },
|
||||
],
|
||||
},
|
||||
{
|
||||
depth: 1,
|
||||
process: {
|
||||
tags: [
|
||||
{ key: 'a', value: 'a3' },
|
||||
{ key: 'b', value: 'b3' },
|
||||
{ key: 'c', value: 'c3' },
|
||||
{ key: 'd', value: 'd3' },
|
||||
],
|
||||
},
|
||||
tags: [{ key: 'a', value: 'a2' }, { key: 'b', value: 'b2' }, { key: 'c', value: 'c2' }],
|
||||
},
|
||||
{
|
||||
depth: 2,
|
||||
process: {
|
||||
tags: [{ key: 'a', value: 'a1' }, { key: 'b', value: 'b1' }],
|
||||
},
|
||||
tags: [{ key: 'a', value: 'a0' }],
|
||||
},
|
||||
];
|
||||
spans[1].references = [
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
span: spans[0],
|
||||
},
|
||||
];
|
||||
spans[2].references = [
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
span: spans[0],
|
||||
},
|
||||
];
|
||||
spans[3].references = [
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
span: spans[2],
|
||||
},
|
||||
];
|
||||
|
||||
it('uses current span tags', () => {
|
||||
expect(getParameterInAncestor('a', spans[3])).toEqual({ key: 'a', value: 'a0' });
|
||||
expect(getParameterInAncestor('a', spans[2])).toEqual({ key: 'a', value: 'a2' });
|
||||
expect(getParameterInAncestor('a', spans[1])).toEqual({ key: 'a', value: 'a4' });
|
||||
expect(getParameterInAncestor('a', spans[0])).toEqual({ key: 'a', value: 'a6' });
|
||||
});
|
||||
|
||||
it('uses current span process tags', () => {
|
||||
expect(getParameterInAncestor('b', spans[3])).toEqual({ key: 'b', value: 'b1' });
|
||||
expect(getParameterInAncestor('d', spans[2])).toEqual({ key: 'd', value: 'd3' });
|
||||
expect(getParameterInAncestor('f', spans[1])).toEqual({ key: 'f', value: 'f5' });
|
||||
expect(getParameterInAncestor('h', spans[0])).toEqual({ key: 'h', value: 'h7' });
|
||||
});
|
||||
|
||||
it('uses parent span tags', () => {
|
||||
expect(getParameterInAncestor('c', spans[3])).toEqual({ key: 'c', value: 'c2' });
|
||||
expect(getParameterInAncestor('e', spans[2])).toEqual({ key: 'e', value: 'e6' });
|
||||
expect(getParameterInAncestor('f', spans[2])).toEqual({ key: 'f', value: 'f6' });
|
||||
expect(getParameterInAncestor('g', spans[2])).toEqual({ key: 'g', value: 'g6' });
|
||||
expect(getParameterInAncestor('g', spans[1])).toEqual({ key: 'g', value: 'g6' });
|
||||
});
|
||||
|
||||
it('uses parent span process tags', () => {
|
||||
expect(getParameterInAncestor('d', spans[3])).toEqual({ key: 'd', value: 'd3' });
|
||||
expect(getParameterInAncestor('h', spans[2])).toEqual({ key: 'h', value: 'h7' });
|
||||
expect(getParameterInAncestor('h', spans[1])).toEqual({ key: 'h', value: 'h7' });
|
||||
});
|
||||
|
||||
it('uses grand-parent span tags', () => {
|
||||
expect(getParameterInAncestor('e', spans[3])).toEqual({ key: 'e', value: 'e6' });
|
||||
expect(getParameterInAncestor('f', spans[3])).toEqual({ key: 'f', value: 'f6' });
|
||||
expect(getParameterInAncestor('g', spans[3])).toEqual({ key: 'g', value: 'g6' });
|
||||
});
|
||||
|
||||
it('uses grand-parent process tags', () => {
|
||||
expect(getParameterInAncestor('h', spans[3])).toEqual({ key: 'h', value: 'h7' });
|
||||
});
|
||||
|
||||
it('returns undefined when the entry cannot be found', () => {
|
||||
expect(getParameterInAncestor('i', spans[3])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not break if some tags are not defined', () => {
|
||||
const spansWithUndefinedTags = [
|
||||
{
|
||||
depth: 0,
|
||||
process: {},
|
||||
},
|
||||
];
|
||||
expect(getParameterInAncestor('a', spansWithUndefinedTags[0])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTraceLink()', () => {
|
||||
const linkPatterns = [
|
||||
{
|
||||
type: 'traces',
|
||||
url: 'http://example.com/?myKey=#{traceID}',
|
||||
text: 'first link (#{traceID})',
|
||||
},
|
||||
{
|
||||
type: 'traces',
|
||||
url: 'http://example.com/?myKey=#{traceID}&myKey=#{myKey}',
|
||||
text: 'second link (#{myKey})',
|
||||
},
|
||||
].map(processLinkPattern);
|
||||
|
||||
const trace = {
|
||||
processes: [],
|
||||
traceID: 'trc1',
|
||||
spans: [],
|
||||
startTime: 1000,
|
||||
endTime: 2000,
|
||||
duration: 1000,
|
||||
services: [],
|
||||
};
|
||||
|
||||
it('correctly computes links', () => {
|
||||
expect(computeTraceLink(linkPatterns, trace)).toEqual([
|
||||
{
|
||||
url: 'http://example.com/?myKey=trc1',
|
||||
text: 'first link (trc1)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeLinks()', () => {
|
||||
const linkPatterns = [
|
||||
{
|
||||
type: 'tags',
|
||||
key: 'myKey',
|
||||
url: 'http://example.com/?myKey=#{myKey}',
|
||||
text: 'first link (#{myKey})',
|
||||
},
|
||||
{
|
||||
key: 'myOtherKey',
|
||||
url: 'http://example.com/?myKey=#{myOtherKey}&myKey=#{myKey}',
|
||||
text: 'second link (#{myOtherKey})',
|
||||
},
|
||||
].map(processLinkPattern);
|
||||
|
||||
const spans = [
|
||||
{ depth: 0, process: {}, tags: [{ key: 'myKey', value: 'valueOfMyKey' }] },
|
||||
{ depth: 1, process: {}, logs: [{ fields: [{ key: 'myOtherKey', value: 'valueOfMy+Other+Key' }] }] },
|
||||
];
|
||||
spans[1].references = [
|
||||
{
|
||||
refType: 'CHILD_OF',
|
||||
span: spans[0],
|
||||
},
|
||||
];
|
||||
|
||||
it('correctly computes links', () => {
|
||||
expect(computeLinks(linkPatterns, spans[0], spans[0].tags, 0)).toEqual([
|
||||
{
|
||||
url: 'http://example.com/?myKey=valueOfMyKey',
|
||||
text: 'first link (valueOfMyKey)',
|
||||
},
|
||||
]);
|
||||
expect(computeLinks(linkPatterns, spans[1], spans[1].logs[0].fields, 0)).toEqual([
|
||||
{
|
||||
url: 'http://example.com/?myKey=valueOfMy%2BOther%2BKey&myKey=valueOfMyKey',
|
||||
text: 'second link (valueOfMy+Other+Key)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLinks()', () => {
|
||||
const linkPatterns = [
|
||||
{
|
||||
key: 'mySpecialKey',
|
||||
url: 'http://example.com/?mySpecialKey=#{mySpecialKey}',
|
||||
text: 'special key link (#{mySpecialKey})',
|
||||
},
|
||||
].map(processLinkPattern);
|
||||
const template = jest.spyOn(linkPatterns[0].url, 'template');
|
||||
|
||||
const span = { depth: 0, process: {}, tags: [{ key: 'mySpecialKey', value: 'valueOfMyKey' }] };
|
||||
|
||||
let cache;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new WeakMap();
|
||||
template.mockClear();
|
||||
});
|
||||
|
||||
it('does not access the cache if there is no link pattern', () => {
|
||||
cache.get = jest.fn();
|
||||
const getLinks = createGetLinks([], cache);
|
||||
expect(getLinks(span, span.tags, 0)).toEqual([]);
|
||||
expect(cache.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the result from the cache', () => {
|
||||
const result = [];
|
||||
cache.set(span.tags[0], result);
|
||||
const getLinks = createGetLinks(linkPatterns, cache);
|
||||
expect(getLinks(span, span.tags, 0)).toBe(result);
|
||||
expect(template).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds the result to the cache', () => {
|
||||
const getLinks = createGetLinks(linkPatterns, cache);
|
||||
const result = getLinks(span, span.tags, 0);
|
||||
expect(template).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
url: 'http://example.com/?mySpecialKey=valueOfMyKey',
|
||||
text: 'special key link (valueOfMyKey)',
|
||||
},
|
||||
]);
|
||||
expect(cache.get(span.tags[0])).toBe(result);
|
||||
});
|
||||
});
|
||||
249
packages/jaeger-ui-components/src/model/link-patterns.tsx
Normal file
249
packages/jaeger-ui-components/src/model/link-patterns.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
// Copyright (c) 2017 The Jaeger Authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import _uniq from 'lodash/uniq';
|
||||
import memoize from 'lru-memoize';
|
||||
import { getConfigValue } from '../utils/config/get-config';
|
||||
import { getParent } from './span';
|
||||
import { TNil } from '../types';
|
||||
import { Span, Link, KeyValuePair, Trace } from '../types/trace';
|
||||
|
||||
const parameterRegExp = /#\{([^{}]*)\}/g;
|
||||
|
||||
type ProcessedTemplate = {
|
||||
parameters: string[];
|
||||
template: (template: { [key: string]: any }) => string;
|
||||
};
|
||||
|
||||
type ProcessedLinkPattern = {
|
||||
object: any;
|
||||
type: (link: string) => boolean;
|
||||
key: (link: string) => boolean;
|
||||
value: (value: any) => boolean;
|
||||
url: ProcessedTemplate;
|
||||
text: ProcessedTemplate;
|
||||
parameters: string[];
|
||||
};
|
||||
|
||||
type TLinksRV = { url: string; text: string }[];
|
||||
|
||||
function getParamNames(str: string) {
|
||||
const names = new Set<string>();
|
||||
str.replace(parameterRegExp, (match, name) => {
|
||||
names.add(name);
|
||||
return match;
|
||||
});
|
||||
return Array.from(names);
|
||||
}
|
||||
|
||||
function stringSupplant(str: string, encodeFn: (unencoded: any) => string, map: Record<string, any>) {
|
||||
return str.replace(parameterRegExp, (_, name) => {
|
||||
const value = map[name];
|
||||
return value == null ? '' : encodeFn(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function processTemplate(template: any, encodeFn: (unencoded: any) => string): ProcessedTemplate {
|
||||
if (typeof template !== 'string') {
|
||||
/*
|
||||
|
||||
// kept on ice until #123 is implemented:
|
||||
if (template && Array.isArray(template.parameters) && (typeof template.template === 'function')) {
|
||||
return template;
|
||||
}
|
||||
|
||||
*/
|
||||
throw new Error('Invalid template');
|
||||
}
|
||||
return {
|
||||
parameters: getParamNames(template),
|
||||
template: stringSupplant.bind(null, template, encodeFn),
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestFunction(entry: any) {
|
||||
if (typeof entry === 'string') {
|
||||
return (arg: any) => arg === entry;
|
||||
}
|
||||
if (Array.isArray(entry)) {
|
||||
return (arg: any) => entry.indexOf(arg) > -1;
|
||||
}
|
||||
/*
|
||||
|
||||
// kept on ice until #123 is implemented:
|
||||
if (entry instanceof RegExp) {
|
||||
return (arg: any) => entry.test(arg);
|
||||
}
|
||||
if (typeof entry === 'function') {
|
||||
return entry;
|
||||
}
|
||||
|
||||
*/
|
||||
if (entry == null) {
|
||||
return () => true;
|
||||
}
|
||||
throw new Error(`Invalid value: ${entry}`);
|
||||
}
|
||||
|
||||
const identity = (a: any): typeof a => a;
|
||||
|
||||
export function processLinkPattern(pattern: any): ProcessedLinkPattern | TNil {
|
||||
try {
|
||||
const url = processTemplate(pattern.url, encodeURIComponent);
|
||||
const text = processTemplate(pattern.text, identity);
|
||||
return {
|
||||
object: pattern,
|
||||
type: createTestFunction(pattern.type),
|
||||
key: createTestFunction(pattern.key),
|
||||
value: createTestFunction(pattern.value),
|
||||
url,
|
||||
text,
|
||||
parameters: _uniq(url.parameters.concat(text.parameters)),
|
||||
};
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Ignoring invalid link pattern: ${error}`, pattern);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getParameterInArray(name: string, array: KeyValuePair[]) {
|
||||
if (array) {
|
||||
return array.find(entry => entry.key === name);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getParameterInAncestor(name: string, span: Span) {
|
||||
let currentSpan: Span | TNil = span;
|
||||
while (currentSpan) {
|
||||
const result = getParameterInArray(name, currentSpan.tags) || getParameterInArray(name, currentSpan.process.tags);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
currentSpan = getParent(currentSpan);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function callTemplate(template: ProcessedTemplate, data: any) {
|
||||
return template.template(data);
|
||||
}
|
||||
|
||||
export function computeTraceLink(linkPatterns: ProcessedLinkPattern[], trace: Trace) {
|
||||
const result: TLinksRV = [];
|
||||
const validKeys = (Object.keys(trace) as (keyof Trace)[]).filter(
|
||||
key => typeof trace[key] === 'string' || trace[key] === 'number'
|
||||
);
|
||||
|
||||
linkPatterns
|
||||
.filter(pattern => pattern.type('traces'))
|
||||
.forEach(pattern => {
|
||||
const parameterValues: Record<string, any> = {};
|
||||
const allParameters = pattern.parameters.every(parameter => {
|
||||
const key = parameter as keyof Trace;
|
||||
if (validKeys.includes(key)) {
|
||||
// At this point is safe to access to trace object using parameter variable because
|
||||
// we validated parameter against validKeys, this implies that parameter a keyof Trace.
|
||||
parameterValues[parameter] = trace[key];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (allParameters) {
|
||||
result.push({
|
||||
url: callTemplate(pattern.url, parameterValues),
|
||||
text: callTemplate(pattern.text, parameterValues),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function computeLinks(
|
||||
linkPatterns: ProcessedLinkPattern[],
|
||||
span: Span,
|
||||
items: KeyValuePair[],
|
||||
itemIndex: number
|
||||
) {
|
||||
const item = items[itemIndex];
|
||||
let type = 'logs';
|
||||
const processTags = span.process.tags === items;
|
||||
if (processTags) {
|
||||
type = 'process';
|
||||
}
|
||||
const spanTags = span.tags === items;
|
||||
if (spanTags) {
|
||||
type = 'tags';
|
||||
}
|
||||
const result: { url: string; text: string }[] = [];
|
||||
linkPatterns.forEach(pattern => {
|
||||
if (pattern.type(type) && pattern.key(item.key) && pattern.value(item.value)) {
|
||||
const parameterValues: Record<string, any> = {};
|
||||
const allParameters = pattern.parameters.every(parameter => {
|
||||
let entry = getParameterInArray(parameter, items);
|
||||
if (!entry && !processTags) {
|
||||
// do not look in ancestors for process tags because the same object may appear in different places in the hierarchy
|
||||
// and the cache in getLinks uses that object as a key
|
||||
entry = getParameterInAncestor(parameter, span);
|
||||
}
|
||||
if (entry) {
|
||||
parameterValues[parameter] = entry.value;
|
||||
return true;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Skipping link pattern, missing parameter ${parameter} for key ${item.key} in ${type}.`,
|
||||
pattern.object
|
||||
);
|
||||
return false;
|
||||
});
|
||||
if (allParameters) {
|
||||
result.push({
|
||||
url: callTemplate(pattern.url, parameterValues),
|
||||
text: callTemplate(pattern.text, parameterValues),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createGetLinks(linkPatterns: ProcessedLinkPattern[], cache: WeakMap<KeyValuePair, Link[]>) {
|
||||
return (span: Span, items: KeyValuePair[], itemIndex: number) => {
|
||||
if (linkPatterns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const item = items[itemIndex];
|
||||
let result = cache.get(item);
|
||||
if (!result) {
|
||||
result = computeLinks(linkPatterns, span, items, itemIndex);
|
||||
cache.set(item, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
const processedLinks: ProcessedLinkPattern[] = (getConfigValue('linkPatterns') || [])
|
||||
.map(processLinkPattern)
|
||||
.filter(Boolean);
|
||||
|
||||
export const getTraceLinks: (trace: Trace | undefined) => TLinksRV = memoize(10)((trace: Trace | undefined) => {
|
||||
const result: TLinksRV = [];
|
||||
if (!trace) return result;
|
||||
return computeTraceLink(processedLinks, trace);
|
||||
});
|
||||
|
||||
export default createGetLinks(processedLinks, new WeakMap());
|
||||
26
packages/jaeger-ui-components/src/model/span.tsx
Normal file
26
packages/jaeger-ui-components/src/model/span.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2017 The Jaeger Authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Span } from '../types/trace';
|
||||
|
||||
/**
|
||||
* Searches the span.references to find 'CHILD_OF' reference type or returns null.
|
||||
* @param {Span} span The span whose parent is to be returned.
|
||||
* @return {Span|null} The parent span if there is one, null otherwise.
|
||||
*/
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function getParent(span: Span) {
|
||||
const parentRef = span.references ? span.references.find(ref => ref.refType === 'CHILD_OF') : null;
|
||||
return parentRef ? parentRef.span : null;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2019 The Jaeger Authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { orderTags, deduplicateTags } from './transform-trace-data';
|
||||
|
||||
describe('orderTags()', () => {
|
||||
it('correctly orders tags', () => {
|
||||
const orderedTags = orderTags(
|
||||
[
|
||||
{ key: 'b.ip', value: '8.8.4.4' },
|
||||
{ key: 'http.Status_code', value: '200' },
|
||||
{ key: 'z.ip', value: '8.8.8.16' },
|
||||
{ key: 'a.ip', value: '8.8.8.8' },
|
||||
{ key: 'http.message', value: 'ok' },
|
||||
],
|
||||
['z.', 'a.', 'HTTP.']
|
||||
);
|
||||
expect(orderedTags).toEqual([
|
||||
{ key: 'z.ip', value: '8.8.8.16' },
|
||||
{ key: 'a.ip', value: '8.8.8.8' },
|
||||
{ key: 'http.message', value: 'ok' },
|
||||
{ key: 'http.Status_code', value: '200' },
|
||||
{ key: 'b.ip', value: '8.8.4.4' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deduplicateTags()', () => {
|
||||
it('deduplicates tags', () => {
|
||||
const tagsInfo = deduplicateTags([
|
||||
{ key: 'b.ip', value: '8.8.4.4' },
|
||||
{ key: 'b.ip', value: '8.8.8.8' },
|
||||
{ key: 'b.ip', value: '8.8.4.4' },
|
||||
{ key: 'a.ip', value: '8.8.8.8' },
|
||||
]);
|
||||
|
||||
expect(tagsInfo.tags).toEqual([
|
||||
{ key: 'b.ip', value: '8.8.4.4' },
|
||||
{ key: 'b.ip', value: '8.8.8.8' },
|
||||
{ key: 'a.ip', value: '8.8.8.8' },
|
||||
]);
|
||||
expect(tagsInfo.warnings).toEqual(['Duplicate tag "b.ip:8.8.4.4"']);
|
||||
});
|
||||
});
|
||||
184
packages/jaeger-ui-components/src/model/transform-trace-data.tsx
Normal file
184
packages/jaeger-ui-components/src/model/transform-trace-data.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import _isEqual from 'lodash/isEqual';
|
||||
|
||||
// @ts-ignore
|
||||
import { getTraceSpanIdsAsTree } from '../selectors/trace';
|
||||
import { getConfigValue } from '../utils/config/get-config';
|
||||
import { KeyValuePair, Span, SpanData, Trace, TraceData } from '../types/trace';
|
||||
// @ts-ignore
|
||||
import TreeNode from '../utils/TreeNode';
|
||||
|
||||
// exported for tests
|
||||
export function deduplicateTags(spanTags: KeyValuePair[]) {
|
||||
const warningsHash: Map<string, string> = new Map<string, string>();
|
||||
const tags: KeyValuePair[] = spanTags.reduce<KeyValuePair[]>((uniqueTags, tag) => {
|
||||
if (!uniqueTags.some(t => t.key === tag.key && t.value === tag.value)) {
|
||||
uniqueTags.push(tag);
|
||||
} else {
|
||||
warningsHash.set(`${tag.key}:${tag.value}`, `Duplicate tag "${tag.key}:${tag.value}"`);
|
||||
}
|
||||
return uniqueTags;
|
||||
}, []);
|
||||
const warnings = Array.from(warningsHash.values());
|
||||
return { tags, warnings };
|
||||
}
|
||||
|
||||
// exported for tests
|
||||
export function orderTags(spanTags: KeyValuePair[], topPrefixes?: string[]) {
|
||||
const orderedTags: KeyValuePair[] = spanTags.slice();
|
||||
const tp = (topPrefixes || []).map((p: string) => p.toLowerCase());
|
||||
|
||||
orderedTags.sort((a, b) => {
|
||||
const aKey = a.key.toLowerCase();
|
||||
const bKey = b.key.toLowerCase();
|
||||
|
||||
for (let i = 0; i < tp.length; i++) {
|
||||
const p = tp[i];
|
||||
if (aKey.startsWith(p) && !bKey.startsWith(p)) {
|
||||
return -1;
|
||||
}
|
||||
if (!aKey.startsWith(p) && bKey.startsWith(p)) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (aKey > bKey) {
|
||||
return 1;
|
||||
}
|
||||
if (aKey < bKey) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return orderedTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: Mutates `data` - Transform the HTTP response data into the form the app
|
||||
* generally requires.
|
||||
*/
|
||||
export default function transformTraceData(data: TraceData & { spans: SpanData[] }): Trace | null {
|
||||
let { traceID } = data;
|
||||
if (!traceID) {
|
||||
return null;
|
||||
}
|
||||
traceID = traceID.toLowerCase();
|
||||
|
||||
let traceEndTime = 0;
|
||||
let traceStartTime = Number.MAX_SAFE_INTEGER;
|
||||
const spanIdCounts = new Map();
|
||||
const spanMap = new Map<string, Span>();
|
||||
// filter out spans with empty start times
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
data.spans = data.spans.filter(span => Boolean(span.startTime));
|
||||
|
||||
const max = data.spans.length;
|
||||
for (let i = 0; i < max; i++) {
|
||||
const span: Span = data.spans[i] as Span;
|
||||
const { startTime, duration, processID } = span;
|
||||
//
|
||||
let spanID = span.spanID;
|
||||
// check for start / end time for the trace
|
||||
if (startTime < traceStartTime) {
|
||||
traceStartTime = startTime;
|
||||
}
|
||||
if (startTime + duration > traceEndTime) {
|
||||
traceEndTime = startTime + duration;
|
||||
}
|
||||
// make sure span IDs are unique
|
||||
const idCount = spanIdCounts.get(spanID);
|
||||
if (idCount != null) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Dupe spanID, ${idCount + 1} x ${spanID}`, span, spanMap.get(spanID));
|
||||
if (_isEqual(span, spanMap.get(spanID))) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('\t two spans with same ID have `isEqual(...) === true`');
|
||||
}
|
||||
spanIdCounts.set(spanID, idCount + 1);
|
||||
spanID = `${spanID}_${idCount}`;
|
||||
span.spanID = spanID;
|
||||
} else {
|
||||
spanIdCounts.set(spanID, 1);
|
||||
}
|
||||
span.process = data.processes[processID];
|
||||
spanMap.set(spanID, span);
|
||||
}
|
||||
// tree is necessary to sort the spans, so children follow parents, and
|
||||
// siblings are sorted by start time
|
||||
const tree = getTraceSpanIdsAsTree(data);
|
||||
const spans: Span[] = [];
|
||||
const svcCounts: Record<string, number> = {};
|
||||
let traceName = '';
|
||||
|
||||
// Eslint complains about number type not needed but then TS complains it is implicitly any.
|
||||
// eslint-disable-next-line @typescript-eslint/no-inferrable-types
|
||||
tree.walk((spanID: string, node: TreeNode, depth: number = 0) => {
|
||||
if (spanID === '__root__') {
|
||||
return;
|
||||
}
|
||||
const span = spanMap.get(spanID) as Span;
|
||||
if (!span) {
|
||||
return;
|
||||
}
|
||||
const { serviceName } = span.process;
|
||||
svcCounts[serviceName] = (svcCounts[serviceName] || 0) + 1;
|
||||
if (!span.references || !span.references.length) {
|
||||
traceName = `${serviceName}: ${span.operationName}`;
|
||||
}
|
||||
span.relativeStartTime = span.startTime - traceStartTime;
|
||||
span.depth = depth - 1;
|
||||
span.hasChildren = node.children.length > 0;
|
||||
span.warnings = span.warnings || [];
|
||||
span.tags = span.tags || [];
|
||||
span.references = span.references || [];
|
||||
const tagsInfo = deduplicateTags(span.tags);
|
||||
span.tags = orderTags(tagsInfo.tags, getConfigValue('topTagPrefixes'));
|
||||
span.warnings = span.warnings.concat(tagsInfo.warnings);
|
||||
span.references.forEach((ref, index) => {
|
||||
const refSpan = spanMap.get(ref.spanID) as Span;
|
||||
if (refSpan) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
ref.span = refSpan;
|
||||
if (index > 0) {
|
||||
// Don't take into account the parent, just other references.
|
||||
refSpan.subsidiarilyReferencedBy = refSpan.subsidiarilyReferencedBy || [];
|
||||
refSpan.subsidiarilyReferencedBy.push({
|
||||
spanID,
|
||||
traceID,
|
||||
span,
|
||||
refType: ref.refType,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
spans.push(span);
|
||||
});
|
||||
const services = Object.keys(svcCounts).map(name => ({ name, numberOfSpans: svcCounts[name] }));
|
||||
return {
|
||||
services,
|
||||
spans,
|
||||
traceID,
|
||||
traceName,
|
||||
// can't use spread operator for intersection types
|
||||
// repl: https://goo.gl/4Z23MJ
|
||||
// issue: https://github.com/facebook/flow/issues/1511
|
||||
processes: data.processes,
|
||||
duration: traceEndTime - traceStartTime,
|
||||
startTime: traceStartTime,
|
||||
endTime: traceEndTime,
|
||||
};
|
||||
}
|
||||
159
packages/jaeger-ui-components/src/scroll-page.test.js
Normal file
159
packages/jaeger-ui-components/src/scroll-page.test.js
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
/* eslint-disable import/first */
|
||||
jest.mock('./Tween');
|
||||
|
||||
import { scrollBy, scrollTo, cancel } from './scroll-page';
|
||||
import Tween from './Tween';
|
||||
|
||||
// keep track of instances, manually
|
||||
// https://github.com/facebook/jest/issues/5019
|
||||
const tweenInstances = [];
|
||||
|
||||
describe('scroll-by', () => {
|
||||
beforeEach(() => {
|
||||
window.scrollY = 100;
|
||||
tweenInstances.length = 0;
|
||||
Tween.mockClear();
|
||||
Tween.mockImplementation(opts => {
|
||||
const rv = { to: opts.to, onUpdate: opts.onUpdate };
|
||||
Object.keys(Tween.prototype).forEach(name => {
|
||||
if (name !== 'constructor') {
|
||||
rv[name] = jest.fn();
|
||||
}
|
||||
});
|
||||
tweenInstances.push(rv);
|
||||
return rv;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cancel();
|
||||
});
|
||||
|
||||
describe('scrollBy()', () => {
|
||||
describe('when `appendToLast` is `false`', () => {
|
||||
it('scrolls from `window.scrollY` to `window.scrollY + yDelta`', () => {
|
||||
const yDelta = 10;
|
||||
scrollBy(yDelta);
|
||||
const spec = expect.objectContaining({ to: window.scrollY + yDelta });
|
||||
expect(Tween.mock.calls).toEqual([[spec]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when `appendToLast` is true', () => {
|
||||
it('is the same as `appendToLast === false` without an in-progress scroll', () => {
|
||||
const yDelta = 10;
|
||||
scrollBy(yDelta, true);
|
||||
expect(Tween.mock.calls.length).toBe(1);
|
||||
scrollBy(yDelta, false);
|
||||
expect(Tween.mock.calls[0]).toEqual(Tween.mock.calls[1]);
|
||||
});
|
||||
|
||||
it('is additive when an in-progress scroll is the same direction', () => {
|
||||
const yDelta = 10;
|
||||
const spec = expect.objectContaining({ to: window.scrollY + 2 * yDelta });
|
||||
scrollBy(yDelta);
|
||||
scrollBy(yDelta, true);
|
||||
expect(Tween.mock.calls.length).toBe(2);
|
||||
expect(Tween.mock.calls[1]).toEqual([spec]);
|
||||
});
|
||||
|
||||
it('ignores the in-progress scroll is the other direction', () => {
|
||||
const yDelta = 10;
|
||||
const spec = expect.objectContaining({ to: window.scrollY - yDelta });
|
||||
scrollBy(yDelta);
|
||||
scrollBy(-yDelta, true);
|
||||
expect(Tween.mock.calls.length).toBe(2);
|
||||
expect(Tween.mock.calls[1]).toEqual([spec]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollTo', () => {
|
||||
it('scrolls to `y`', () => {
|
||||
const to = 10;
|
||||
const spec = expect.objectContaining({ to });
|
||||
scrollTo(to);
|
||||
expect(Tween.mock.calls).toEqual([[spec]]);
|
||||
});
|
||||
|
||||
it('ignores the in-progress scroll', () => {
|
||||
const to = 10;
|
||||
const spec = expect.objectContaining({ to });
|
||||
scrollTo(Math.random());
|
||||
scrollTo(to);
|
||||
expect(Tween.mock.calls.length).toBe(2);
|
||||
expect(Tween.mock.calls[1]).toEqual([spec]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('cancels the in-progress scroll', () => {
|
||||
scrollTo(10);
|
||||
// there is now an in-progress tween
|
||||
expect(tweenInstances.length).toBe(1);
|
||||
const tw = tweenInstances[0];
|
||||
cancel();
|
||||
expect(tw.cancel.mock.calls).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('is a noop if there is not an in-progress scroll', () => {
|
||||
scrollTo(10);
|
||||
// there is now an in-progress tween
|
||||
expect(tweenInstances.length).toBe(1);
|
||||
const tw = tweenInstances[0];
|
||||
cancel();
|
||||
expect(tw.cancel.mock.calls).toEqual([[]]);
|
||||
tw.cancel.mockReset();
|
||||
// now, we check to see if `cancel()` has an effect on the last created tween
|
||||
cancel();
|
||||
expect(tw.cancel.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_onTweenUpdate', () => {
|
||||
let oldScrollTo;
|
||||
|
||||
beforeEach(() => {
|
||||
oldScrollTo = window.scrollTo;
|
||||
window.scrollTo = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.scrollTo = oldScrollTo;
|
||||
});
|
||||
|
||||
it('scrolls to `value`', () => {
|
||||
const value = 123;
|
||||
// cause a `Tween` to be created to get a reference to _onTweenUpdate
|
||||
scrollTo(10);
|
||||
const { onUpdate } = tweenInstances[0];
|
||||
onUpdate({ value, done: false });
|
||||
expect(window.scrollTo.mock.calls.length).toBe(1);
|
||||
expect(window.scrollTo.mock.calls[0][1]).toBe(value);
|
||||
});
|
||||
|
||||
it('discards the in-progress scroll if the scroll is done', () => {
|
||||
// cause a `Tween` to be created to get a reference to _onTweenUpdate
|
||||
scrollTo(10);
|
||||
const { onUpdate, cancel: twCancel } = tweenInstances[0];
|
||||
onUpdate({ value: 123, done: true });
|
||||
// if the tween is not discarded, `cancel()` will cancel it
|
||||
cancel();
|
||||
expect(twCancel.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
55
packages/jaeger-ui-components/src/scroll-page.tsx
Normal file
55
packages/jaeger-ui-components/src/scroll-page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import Tween from './Tween';
|
||||
|
||||
const DURATION_MS = 350;
|
||||
|
||||
let lastTween: Tween | void;
|
||||
|
||||
// TODO(joe): this util can be modified a bit to be generalized (e.g. take in
|
||||
// an element as a parameter and use scrollTop instead of window.scrollTo)
|
||||
|
||||
function _onTweenUpdate({ done, value }: { done: boolean; value: number }) {
|
||||
window.scrollTo(window.scrollX, value);
|
||||
if (done) {
|
||||
lastTween = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollBy(yDelta: number, appendToLast: boolean = false) {
|
||||
const { scrollY } = window;
|
||||
let targetFrom = scrollY;
|
||||
if (appendToLast && lastTween) {
|
||||
const currentDirection = lastTween.to < scrollY ? 'up' : 'down';
|
||||
const nextDirection = yDelta < 0 ? 'up' : 'down';
|
||||
if (currentDirection === nextDirection) {
|
||||
targetFrom = lastTween.to;
|
||||
}
|
||||
}
|
||||
const to = targetFrom + yDelta;
|
||||
lastTween = new Tween({ to, duration: DURATION_MS, from: scrollY, onUpdate: _onTweenUpdate });
|
||||
}
|
||||
|
||||
export function scrollTo(y: number) {
|
||||
const { scrollY } = window;
|
||||
lastTween = new Tween({ duration: DURATION_MS, from: scrollY, to: y, onUpdate: _onTweenUpdate });
|
||||
}
|
||||
|
||||
export function cancel() {
|
||||
if (lastTween) {
|
||||
lastTween.cancel();
|
||||
lastTween = undefined;
|
||||
}
|
||||
}
|
||||
16
packages/jaeger-ui-components/src/selectors/process.js
Normal file
16
packages/jaeger-ui-components/src/selectors/process.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export const getProcessServiceName = proc => proc.serviceName;
|
||||
export const getProcessTags = proc => proc.tags;
|
||||
30
packages/jaeger-ui-components/src/selectors/process.test.js
Normal file
30
packages/jaeger-ui-components/src/selectors/process.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as processSelectors from './process';
|
||||
import traceGenerator from '../demo/trace-generators';
|
||||
|
||||
const generatedTrace = traceGenerator.trace({ numberOfSpans: 45 });
|
||||
|
||||
it('getProcessServiceName() should return the serviceName of the process', () => {
|
||||
const proc = generatedTrace.processes[Object.keys(generatedTrace.processes)[0]];
|
||||
|
||||
expect(processSelectors.getProcessServiceName(proc)).toBe(proc.serviceName);
|
||||
});
|
||||
|
||||
it('getProcessTags() should return the tags on the process', () => {
|
||||
const proc = generatedTrace.processes[Object.keys(generatedTrace.processes)[0]];
|
||||
|
||||
expect(processSelectors.getProcessTags(proc)).toBe(proc.tags);
|
||||
});
|
||||
96
packages/jaeger-ui-components/src/selectors/span.js
Normal file
96
packages/jaeger-ui-components/src/selectors/span.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { createSelector } from 'reselect';
|
||||
import fuzzy from 'fuzzy';
|
||||
|
||||
import { getProcessServiceName } from './process';
|
||||
|
||||
export const getSpanId = span => span.spanID;
|
||||
export const getSpanName = span => span.operationName;
|
||||
export const getSpanDuration = span => span.duration;
|
||||
export const getSpanTimestamp = span => span.startTime;
|
||||
export const getSpanProcessId = span => span.processID;
|
||||
export const getSpanReferences = span => span.references || [];
|
||||
export const getSpanReferenceByType = createSelector(
|
||||
createSelector(
|
||||
({ span }) => span,
|
||||
getSpanReferences
|
||||
),
|
||||
({ type }) => type,
|
||||
(references, type) => references.find(ref => ref.refType === type)
|
||||
);
|
||||
export const getSpanParentId = createSelector(
|
||||
span => getSpanReferenceByType({ span, type: 'CHILD_OF' }),
|
||||
childOfRef => (childOfRef ? childOfRef.spanID : null)
|
||||
);
|
||||
|
||||
export const getSpanProcess = span => {
|
||||
if (!span.process) {
|
||||
throw new Error(
|
||||
`
|
||||
you must hydrate the spans with the processes, perhaps
|
||||
using hydrateSpansWithProcesses(), before accessing a span's process
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
return span.process;
|
||||
};
|
||||
|
||||
export const getSpanServiceName = createSelector(
|
||||
getSpanProcess,
|
||||
getProcessServiceName
|
||||
);
|
||||
|
||||
export const filterSpansForTimestamps = createSelector(
|
||||
({ spans }) => spans,
|
||||
({ leftBound }) => leftBound,
|
||||
({ rightBound }) => rightBound,
|
||||
(spans, leftBound, rightBound) =>
|
||||
spans.filter(span => getSpanTimestamp(span) >= leftBound && getSpanTimestamp(span) <= rightBound)
|
||||
);
|
||||
|
||||
export const filterSpansForText = createSelector(
|
||||
({ spans }) => spans,
|
||||
({ text }) => text,
|
||||
(spans, text) =>
|
||||
fuzzy
|
||||
.filter(text, spans, {
|
||||
extract: span => `${getSpanServiceName(span)} ${getSpanName(span)}`,
|
||||
})
|
||||
.map(({ original }) => original)
|
||||
);
|
||||
|
||||
const getTextFilterdSpansAsMap = createSelector(
|
||||
filterSpansForText,
|
||||
matchingSpans =>
|
||||
matchingSpans.reduce(
|
||||
(obj, span) => ({
|
||||
...obj,
|
||||
[getSpanId(span)]: span,
|
||||
}),
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
export const highlightSpansForTextFilter = createSelector(
|
||||
({ spans }) => spans,
|
||||
getTextFilterdSpansAsMap,
|
||||
(spans, textFilteredSpansMap) =>
|
||||
spans.map(span => ({
|
||||
...span,
|
||||
muted: !textFilteredSpansMap[getSpanId(span)],
|
||||
}))
|
||||
);
|
||||
206
packages/jaeger-ui-components/src/selectors/span.test.js
Normal file
206
packages/jaeger-ui-components/src/selectors/span.test.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as spanSelectors from './span';
|
||||
import traceGenerator from '../demo/trace-generators';
|
||||
|
||||
const generatedTrace = traceGenerator.trace({ numberOfSpans: 45 });
|
||||
|
||||
it('getSpanId() should return the name of the span', () => {
|
||||
const span = generatedTrace.spans[0];
|
||||
|
||||
expect(spanSelectors.getSpanId(span)).toBe(span.spanID);
|
||||
});
|
||||
|
||||
it('getSpanName() should return the name of the span', () => {
|
||||
const span = generatedTrace.spans[0];
|
||||
|
||||
expect(spanSelectors.getSpanName(span)).toBe(span.operationName);
|
||||
});
|
||||
|
||||
it('getSpanDuration() should return the duration of the span', () => {
|
||||
const span = generatedTrace.spans[0];
|
||||
|
||||
expect(spanSelectors.getSpanDuration(span)).toBe(span.duration);
|
||||
});
|
||||
|
||||
it('getSpanTimestamp() should return the timestamp of the span', () => {
|
||||
const span = generatedTrace.spans[0];
|
||||
|
||||
expect(spanSelectors.getSpanTimestamp(span)).toBe(span.startTime);
|
||||
});
|
||||
|
||||
it('getSpanReferences() should return the span reference array', () => {
|
||||
expect(spanSelectors.getSpanReferences(generatedTrace.spans[0])).toEqual(
|
||||
generatedTrace.spans[0].references
|
||||
);
|
||||
});
|
||||
|
||||
it('getSpanReferences() should return empty array for null references', () => {
|
||||
expect(spanSelectors.getSpanReferences({ references: null })).toEqual([]);
|
||||
});
|
||||
|
||||
it('getSpanReferenceByType() should return the span reference requested', () => {
|
||||
expect(
|
||||
spanSelectors.getSpanReferenceByType({
|
||||
span: generatedTrace.spans[1],
|
||||
type: 'CHILD_OF',
|
||||
}).refType
|
||||
).toBe('CHILD_OF');
|
||||
});
|
||||
|
||||
it('getSpanReferenceByType() should return undefined if one does not exist', () => {
|
||||
expect(
|
||||
spanSelectors.getSpanReferenceByType({
|
||||
span: generatedTrace.spans[0],
|
||||
type: 'FOLLOWS_FROM',
|
||||
})
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it('getSpanParentId() should return the spanID of the parent span', () => {
|
||||
expect(spanSelectors.getSpanParentId(generatedTrace.spans[1])).toBe(
|
||||
generatedTrace.spans[1].references.find(({ refType }) => refType === 'CHILD_OF').spanID
|
||||
);
|
||||
});
|
||||
|
||||
it('getSpanParentId() should return null if no CHILD_OF reference exists', () => {
|
||||
expect(spanSelectors.getSpanParentId(generatedTrace.spans[0])).toBe(null);
|
||||
});
|
||||
|
||||
it('getSpanProcessId() should return the processID of the span', () => {
|
||||
const span = generatedTrace.spans[0];
|
||||
|
||||
expect(spanSelectors.getSpanProcessId(span)).toBe(span.processID);
|
||||
});
|
||||
|
||||
it('getSpanProcess() should return the process of the span', () => {
|
||||
const span = {
|
||||
...generatedTrace.spans[0],
|
||||
process: {},
|
||||
};
|
||||
|
||||
expect(spanSelectors.getSpanProcess(span)).toBe(span.process);
|
||||
});
|
||||
|
||||
it('getSpanProcess() should throw if no process exists', () => {
|
||||
expect(() => spanSelectors.getSpanProcess(generatedTrace.spans[0])).toThrow();
|
||||
});
|
||||
|
||||
it('getSpanServiceName() should return the service name of the span', () => {
|
||||
const serviceName = 'bagel';
|
||||
const span = {
|
||||
...generatedTrace.spans[0],
|
||||
process: { serviceName },
|
||||
};
|
||||
|
||||
expect(spanSelectors.getSpanServiceName(span)).toBe(serviceName);
|
||||
});
|
||||
|
||||
it('filterSpansForTimestamps() should return a filtered list of spans between the times', () => {
|
||||
const now = new Date().getTime() * 1000;
|
||||
const spans = [
|
||||
{
|
||||
startTime: now - 1000,
|
||||
id: 'start-time-1',
|
||||
},
|
||||
{
|
||||
startTime: now,
|
||||
id: 'start-time-2',
|
||||
},
|
||||
{
|
||||
startTime: now + 1000,
|
||||
id: 'start-time-3',
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
spanSelectors.filterSpansForTimestamps({
|
||||
spans,
|
||||
leftBound: now - 500,
|
||||
rightBound: now + 500,
|
||||
})
|
||||
).toEqual([spans[1]]);
|
||||
|
||||
expect(
|
||||
spanSelectors.filterSpansForTimestamps({
|
||||
spans,
|
||||
leftBound: now - 2000,
|
||||
rightBound: now + 2000,
|
||||
})
|
||||
).toEqual([...spans]);
|
||||
|
||||
expect(
|
||||
spanSelectors.filterSpansForTimestamps({
|
||||
spans,
|
||||
leftBound: now - 1000,
|
||||
rightBound: now,
|
||||
})
|
||||
).toEqual([spans[0], spans[1]]);
|
||||
|
||||
expect(
|
||||
spanSelectors.filterSpansForTimestamps({
|
||||
spans,
|
||||
leftBound: now,
|
||||
rightBound: now + 1000,
|
||||
})
|
||||
).toEqual([spans[1], spans[2]]);
|
||||
});
|
||||
|
||||
it('filterSpansForText() should return a filtered list of spans between the times', () => {
|
||||
const spans = [
|
||||
{
|
||||
operationName: 'GET /mything',
|
||||
process: {
|
||||
serviceName: 'alpha',
|
||||
},
|
||||
id: 'start-time-1',
|
||||
},
|
||||
{
|
||||
operationName: 'GET /another',
|
||||
process: {
|
||||
serviceName: 'beta',
|
||||
},
|
||||
id: 'start-time-1',
|
||||
},
|
||||
{
|
||||
operationName: 'POST /mything',
|
||||
process: {
|
||||
serviceName: 'alpha',
|
||||
},
|
||||
id: 'start-time-1',
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
spanSelectors.filterSpansForText({
|
||||
spans,
|
||||
text: '/mything',
|
||||
})
|
||||
).toEqual([spans[0], spans[2]]);
|
||||
|
||||
expect(
|
||||
spanSelectors.filterSpansForText({
|
||||
spans,
|
||||
text: 'GET',
|
||||
})
|
||||
).toEqual([spans[0], spans[1]]);
|
||||
|
||||
expect(
|
||||
spanSelectors.filterSpansForText({
|
||||
spans,
|
||||
text: 'alpha',
|
||||
})
|
||||
).toEqual([spans[0], spans[2]]);
|
||||
});
|
||||
60
packages/jaeger-ui-components/src/selectors/trace.fixture.js
Normal file
60
packages/jaeger-ui-components/src/selectors/trace.fixture.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// See https://github.com/jaegertracing/jaeger-ui/issues/115 for details.
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const followsFromRef = {
|
||||
processes: {
|
||||
p1: {
|
||||
serviceName: 'issue115',
|
||||
tags: [],
|
||||
},
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
duration: 1173,
|
||||
flags: 1,
|
||||
logs: [],
|
||||
operationName: 'thread',
|
||||
processID: 'p1',
|
||||
references: [
|
||||
{
|
||||
refType: 'FOLLOWS_FROM',
|
||||
spanID: 'ea7cfaca83f0724b',
|
||||
traceID: '2992f2a5b5d037a8aabffd08ef384237',
|
||||
},
|
||||
],
|
||||
spanID: '1bdf4201221bb2ac',
|
||||
startTime: 1509533706521220,
|
||||
tags: [],
|
||||
traceID: '2992f2a5b5d037a8aabffd08ef384237',
|
||||
warnings: null,
|
||||
},
|
||||
{
|
||||
duration: 70406,
|
||||
flags: 1,
|
||||
logs: [],
|
||||
operationName: 'demo',
|
||||
processID: 'p1',
|
||||
references: [],
|
||||
spanID: 'ea7cfaca83f0724b',
|
||||
startTime: 1509533706470949,
|
||||
tags: [],
|
||||
traceID: '2992f2a5b5d037a8aabffd08ef384237',
|
||||
warnings: null,
|
||||
},
|
||||
],
|
||||
traceID: '2992f2a5b5d037a8aabffd08ef384237',
|
||||
warnings: null,
|
||||
};
|
||||
343
packages/jaeger-ui-components/src/selectors/trace.js
Normal file
343
packages/jaeger-ui-components/src/selectors/trace.js
Normal file
@@ -0,0 +1,343 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { createSelector, createStructuredSelector } from 'reselect';
|
||||
|
||||
import {
|
||||
getSpanId,
|
||||
getSpanName,
|
||||
getSpanServiceName,
|
||||
getSpanTimestamp,
|
||||
getSpanDuration,
|
||||
getSpanProcessId,
|
||||
} from './span';
|
||||
import { getProcessServiceName } from './process';
|
||||
import { formatMillisecondTime, formatSecondTime, ONE_SECOND } from '../utils/date';
|
||||
import { numberSortComparator } from '../utils/sort';
|
||||
import TreeNode from '../utils/TreeNode';
|
||||
|
||||
export const getTraceId = trace => trace.traceID;
|
||||
|
||||
export const getTraceSpans = trace => trace.spans;
|
||||
|
||||
const getTraceProcesses = trace => trace.processes;
|
||||
|
||||
const getSpanWithProcess = createSelector(
|
||||
state => state.span,
|
||||
state => state.processes,
|
||||
(span, processes) => ({
|
||||
...span,
|
||||
process: processes[getSpanProcessId(span)],
|
||||
})
|
||||
);
|
||||
|
||||
export const getTraceSpansAsMap = createSelector(
|
||||
getTraceSpans,
|
||||
spans => spans.reduce((map, span) => map.set(getSpanId(span), span), new Map())
|
||||
);
|
||||
|
||||
export const TREE_ROOT_ID = '__root__';
|
||||
|
||||
/**
|
||||
* Build a tree of { value: spanID, children } items derived from the
|
||||
* `span.references` information. The tree represents the grouping of parent /
|
||||
* child relationships. The root-most node is nominal in that
|
||||
* `.value === TREE_ROOT_ID`. This is done because a root span (the main trace
|
||||
* span) is not always included with the trace data. Thus, there can be
|
||||
* multiple top-level spans, and the root node acts as their common parent.
|
||||
*
|
||||
* The children are sorted by `span.startTime` after the tree is built.
|
||||
*
|
||||
* @param {Trace} trace The trace to build the tree of spanIDs.
|
||||
* @return {TreeNode} A tree of spanIDs derived from the relationships
|
||||
* between spans in the trace.
|
||||
*/
|
||||
export function getTraceSpanIdsAsTree(trace) {
|
||||
const nodesById = new Map(trace.spans.map(span => [span.spanID, new TreeNode(span.spanID)]));
|
||||
const spansById = new Map(trace.spans.map(span => [span.spanID, span]));
|
||||
const root = new TreeNode(TREE_ROOT_ID);
|
||||
trace.spans.forEach(span => {
|
||||
const node = nodesById.get(span.spanID);
|
||||
if (Array.isArray(span.references) && span.references.length) {
|
||||
const { refType, spanID: parentID } = span.references[0];
|
||||
if (refType === 'CHILD_OF' || refType === 'FOLLOWS_FROM') {
|
||||
const parent = nodesById.get(parentID) || root;
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
throw new Error(`Unrecognized ref type: ${refType}`);
|
||||
}
|
||||
} else {
|
||||
root.children.push(node);
|
||||
}
|
||||
});
|
||||
const comparator = (nodeA, nodeB) => {
|
||||
const a = spansById.get(nodeA.value);
|
||||
const b = spansById.get(nodeB.value);
|
||||
return +(a.startTime > b.startTime) || +(a.startTime === b.startTime) - 1;
|
||||
};
|
||||
trace.spans.forEach(span => {
|
||||
const node = nodesById.get(span.spanID);
|
||||
if (node.children.length > 1) {
|
||||
node.children.sort(comparator);
|
||||
}
|
||||
});
|
||||
root.children.sort(comparator);
|
||||
return root;
|
||||
}
|
||||
|
||||
// attach "process" as an object to each span.
|
||||
export const hydrateSpansWithProcesses = trace => {
|
||||
const spans = getTraceSpans(trace);
|
||||
const processes = getTraceProcesses(trace);
|
||||
|
||||
return {
|
||||
...trace,
|
||||
spans: spans.map(span => getSpanWithProcess({ span, processes })),
|
||||
};
|
||||
};
|
||||
|
||||
export const getTraceSpanCount = createSelector(
|
||||
getTraceSpans,
|
||||
spans => spans.length
|
||||
);
|
||||
|
||||
export const getTraceTimestamp = createSelector(
|
||||
getTraceSpans,
|
||||
spans =>
|
||||
spans.reduce(
|
||||
(prevTimestamp, span) =>
|
||||
prevTimestamp ? Math.min(prevTimestamp, getSpanTimestamp(span)) : getSpanTimestamp(span),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
export const getTraceDuration = createSelector(
|
||||
getTraceSpans,
|
||||
getTraceTimestamp,
|
||||
(spans, timestamp) =>
|
||||
spans.reduce(
|
||||
(prevDuration, span) =>
|
||||
prevDuration
|
||||
? Math.max(getSpanTimestamp(span) - timestamp + getSpanDuration(span), prevDuration)
|
||||
: getSpanDuration(span),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
export const getTraceEndTimestamp = createSelector(
|
||||
getTraceTimestamp,
|
||||
getTraceDuration,
|
||||
(timestamp, duration) => timestamp + duration
|
||||
);
|
||||
|
||||
export const getParentSpan = createSelector(
|
||||
getTraceSpanIdsAsTree,
|
||||
getTraceSpansAsMap,
|
||||
(tree, spanMap) =>
|
||||
tree.children
|
||||
.map(node => spanMap.get(node.value))
|
||||
.sort((spanA, spanB) => numberSortComparator(getSpanTimestamp(spanA), getSpanTimestamp(spanB)))[0]
|
||||
);
|
||||
|
||||
export const getTraceDepth = createSelector(
|
||||
getTraceSpanIdsAsTree,
|
||||
spanTree => spanTree.depth - 1
|
||||
);
|
||||
|
||||
export const getSpanDepthForTrace = createSelector(
|
||||
createSelector(
|
||||
state => state.trace,
|
||||
getTraceSpanIdsAsTree
|
||||
),
|
||||
createSelector(
|
||||
state => state.span,
|
||||
getSpanId
|
||||
),
|
||||
(node, spanID) => node.getPath(spanID).length - 1
|
||||
);
|
||||
|
||||
export const getTraceServices = createSelector(
|
||||
getTraceProcesses,
|
||||
processes =>
|
||||
Object.keys(processes).reduce(
|
||||
(services, processID) => services.add(getProcessServiceName(processes[processID])),
|
||||
new Set()
|
||||
)
|
||||
);
|
||||
|
||||
export const getTraceServiceCount = createSelector(
|
||||
getTraceServices,
|
||||
services => services.size
|
||||
);
|
||||
|
||||
// establish constants to determine how math should be handled
|
||||
// for nanosecond-to-millisecond conversions.
|
||||
export const DURATION_FORMATTERS = {
|
||||
ms: formatMillisecondTime,
|
||||
s: formatSecondTime,
|
||||
};
|
||||
|
||||
const getDurationFormatterForTrace = createSelector(
|
||||
getTraceDuration,
|
||||
totalDuration => (totalDuration >= ONE_SECOND ? DURATION_FORMATTERS.s : DURATION_FORMATTERS.ms)
|
||||
);
|
||||
|
||||
export const formatDurationForUnit = createSelector(
|
||||
({ duration }) => duration,
|
||||
({ unit }) => DURATION_FORMATTERS[unit],
|
||||
(duration, formatter) => formatter(duration)
|
||||
);
|
||||
|
||||
export const formatDurationForTrace = createSelector(
|
||||
({ duration }) => duration,
|
||||
createSelector(
|
||||
({ trace }) => trace,
|
||||
getDurationFormatterForTrace
|
||||
),
|
||||
(duration, formatter) => formatter(duration)
|
||||
);
|
||||
|
||||
export const getSortedSpans = createSelector(
|
||||
({ trace }) => trace,
|
||||
({ spans }) => spans,
|
||||
({ sort }) => sort,
|
||||
(trace, spans, { dir, comparator, selector }) =>
|
||||
[...spans].sort((spanA, spanB) => dir * comparator(selector(spanA, trace), selector(spanB, trace)))
|
||||
);
|
||||
|
||||
const getTraceSpansByHierarchyPosition = createSelector(
|
||||
getTraceSpanIdsAsTree,
|
||||
tree => {
|
||||
const hierarchyPositionMap = new Map();
|
||||
let i = 0;
|
||||
tree.walk(spanID => hierarchyPositionMap.set(spanID, i++));
|
||||
return hierarchyPositionMap;
|
||||
}
|
||||
);
|
||||
|
||||
export const getTreeSizeForTraceSpan = createSelector(
|
||||
createSelector(
|
||||
state => state.trace,
|
||||
getTraceSpanIdsAsTree
|
||||
),
|
||||
createSelector(
|
||||
state => state.span,
|
||||
getSpanId
|
||||
),
|
||||
(tree, spanID) => {
|
||||
const node = tree.find(spanID);
|
||||
if (!node) {
|
||||
return -1;
|
||||
}
|
||||
return node.size - 1;
|
||||
}
|
||||
);
|
||||
|
||||
export const getSpanHierarchySortPositionForTrace = createSelector(
|
||||
createSelector(
|
||||
({ trace }) => trace,
|
||||
getTraceSpansByHierarchyPosition
|
||||
),
|
||||
({ span }) => span,
|
||||
(hierarchyPositionMap, span) => hierarchyPositionMap.get(getSpanId(span))
|
||||
);
|
||||
|
||||
export const getTraceName = createSelector(
|
||||
createSelector(
|
||||
createSelector(
|
||||
hydrateSpansWithProcesses,
|
||||
getParentSpan
|
||||
),
|
||||
createStructuredSelector({
|
||||
name: getSpanName,
|
||||
serviceName: getSpanServiceName,
|
||||
})
|
||||
),
|
||||
({ name, serviceName }) => `${serviceName}: ${name}`
|
||||
);
|
||||
|
||||
export const omitCollapsedSpans = createSelector(
|
||||
({ spans }) => spans,
|
||||
createSelector(
|
||||
({ trace }) => trace,
|
||||
getTraceSpanIdsAsTree
|
||||
),
|
||||
({ collapsed }) => collapsed,
|
||||
(spans, tree, collapse) => {
|
||||
const hiddenSpanIds = collapse.reduce((result, collapsedSpanId) => {
|
||||
tree.find(collapsedSpanId).walk(id => id !== collapsedSpanId && result.add(id));
|
||||
return result;
|
||||
}, new Set());
|
||||
|
||||
return hiddenSpanIds.size > 0 ? spans.filter(span => !hiddenSpanIds.has(getSpanId(span))) : spans;
|
||||
}
|
||||
);
|
||||
|
||||
export const DEFAULT_TICK_INTERVAL = 4;
|
||||
export const DEFAULT_TICK_WIDTH = 3;
|
||||
export const getTicksForTrace = createSelector(
|
||||
({ trace }) => trace,
|
||||
({ interval = DEFAULT_TICK_INTERVAL }) => interval,
|
||||
({ width = DEFAULT_TICK_WIDTH }) => width,
|
||||
(
|
||||
trace,
|
||||
interval,
|
||||
width
|
||||
// timestamps will be spaced over the interval, starting from the initial timestamp
|
||||
) =>
|
||||
[...Array(interval + 1).keys()].map(num => ({
|
||||
timestamp: getTraceTimestamp(trace) + getTraceDuration(trace) * (num / interval),
|
||||
width,
|
||||
}))
|
||||
);
|
||||
|
||||
// TODO: delete this when the backend can ensure uniqueness
|
||||
/* istanbul ignore next */
|
||||
export const enforceUniqueSpanIds = createSelector(
|
||||
/* istanbul ignore next */ trace => trace,
|
||||
getTraceSpans,
|
||||
/* istanbul ignore next */ (trace, spans) => {
|
||||
const map = new Map();
|
||||
|
||||
return {
|
||||
...trace,
|
||||
spans: spans.reduce((result, span) => {
|
||||
const spanID = map.has(getSpanId(span))
|
||||
? `${getSpanId(span)}_${map.get(getSpanId(span))}`
|
||||
: getSpanId(span);
|
||||
const updatedSpan = { ...span, spanID };
|
||||
|
||||
if (spanID !== getSpanId(span)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('duplicate spanID in trace replaced', getSpanId(span), 'new:', spanID);
|
||||
}
|
||||
|
||||
// set the presence of the span in the map or increment the number
|
||||
map.set(getSpanId(span), (map.get(getSpanId(span)) || 0) + 1);
|
||||
|
||||
return result.concat([updatedSpan]);
|
||||
}, []),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: delete this when the backend can ensure uniqueness
|
||||
export const dropEmptyStartTimeSpans = createSelector(
|
||||
/* istanbul ignore next */ trace => trace,
|
||||
getTraceSpans,
|
||||
/* istanbul ignore next */ (trace, spans) => ({
|
||||
...trace,
|
||||
spans: spans.filter(span => !!getSpanTimestamp(span)),
|
||||
})
|
||||
);
|
||||
357
packages/jaeger-ui-components/src/selectors/trace.test.js
Normal file
357
packages/jaeger-ui-components/src/selectors/trace.test.js
Normal file
@@ -0,0 +1,357 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import _values from 'lodash/values';
|
||||
|
||||
import { followsFromRef } from './trace.fixture';
|
||||
import {
|
||||
getSpanId,
|
||||
getSpanName,
|
||||
getSpanParentId,
|
||||
getSpanProcess,
|
||||
getSpanProcessId,
|
||||
getSpanServiceName,
|
||||
getSpanTimestamp,
|
||||
} from './span';
|
||||
import * as traceSelectors from './trace';
|
||||
import traceGenerator from '../demo/trace-generators';
|
||||
import { numberSortComparator } from '../utils/sort';
|
||||
|
||||
const generatedTrace = traceGenerator.trace({ numberOfSpans: 45 });
|
||||
|
||||
it('getTraceId() should return the traceID', () => {
|
||||
expect(traceSelectors.getTraceId(generatedTrace)).toBe(generatedTrace.traceID);
|
||||
});
|
||||
|
||||
it('hydrateSpansWithProcesses() should return the trace with processes on each span', () => {
|
||||
const hydratedTrace = traceSelectors.hydrateSpansWithProcesses(generatedTrace);
|
||||
|
||||
hydratedTrace.spans.forEach(span =>
|
||||
expect(getSpanProcess(span)).toBe(generatedTrace.processes[getSpanProcessId(span)])
|
||||
);
|
||||
});
|
||||
|
||||
it('getTraceSpansAsMap() should return a map of all of the spans', () => {
|
||||
const spanMap = traceSelectors.getTraceSpansAsMap(generatedTrace);
|
||||
[...spanMap.entries()].forEach(pair => {
|
||||
expect(pair[1]).toEqual(generatedTrace.spans.find(span => getSpanId(span) === pair[0]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTraceSpanIdsAsTree()', () => {
|
||||
it('builds the tree properly', () => {
|
||||
const tree = traceSelectors.getTraceSpanIdsAsTree(generatedTrace);
|
||||
const spanMap = traceSelectors.getTraceSpansAsMap(generatedTrace);
|
||||
|
||||
tree.walk((value, node) => {
|
||||
const expectedParentValue = value === traceSelectors.TREE_ROOT_ID ? null : value;
|
||||
node.children.forEach(childNode => {
|
||||
expect(getSpanParentId(spanMap.get(childNode.value))).toBe(expectedParentValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('#115 - handles FOLLOW_FROM refs', () => {
|
||||
expect(() => traceSelectors.getTraceSpanIdsAsTree(followsFromRef)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('getParentSpan() should return the parent span of the tree', () => {
|
||||
expect(traceSelectors.getParentSpan(generatedTrace)).toBe(
|
||||
traceSelectors
|
||||
.getTraceSpansAsMap(generatedTrace)
|
||||
.get(traceSelectors.getTraceSpanIdsAsTree(generatedTrace).children[0].value)
|
||||
);
|
||||
});
|
||||
|
||||
it('getParentSpan() should return the first span if there are multiple parents', () => {
|
||||
const initialTimestamp = new Date().getTime() * 1000;
|
||||
const firstSpan = {
|
||||
startTime: initialTimestamp,
|
||||
spanID: 'my-span-1',
|
||||
references: [],
|
||||
};
|
||||
|
||||
const trace = {
|
||||
spans: [
|
||||
{
|
||||
startTime: initialTimestamp + 2000,
|
||||
spanID: 'my-span-3',
|
||||
references: [],
|
||||
},
|
||||
firstSpan,
|
||||
{
|
||||
startTime: initialTimestamp + 1000,
|
||||
spanID: 'my-span-2',
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(traceSelectors.getParentSpan(trace)).toBe(firstSpan);
|
||||
});
|
||||
|
||||
it('getTraceName() should return a formatted name for the first span', () => {
|
||||
const hydratedTrace = traceSelectors.hydrateSpansWithProcesses(generatedTrace);
|
||||
const parentSpan = traceSelectors.getParentSpan(hydratedTrace);
|
||||
|
||||
expect(traceSelectors.getTraceName(hydratedTrace)).toBe(
|
||||
`${getSpanServiceName(parentSpan)}: ${getSpanName(parentSpan)}`
|
||||
);
|
||||
});
|
||||
|
||||
it('getTraceSpanCount() should return the length of the spans array', () => {
|
||||
expect(traceSelectors.getTraceSpanCount(generatedTrace)).toBe(generatedTrace.spans.length);
|
||||
});
|
||||
|
||||
it('getTraceDuration() should return the duration for the span', () => {
|
||||
expect(traceSelectors.getTraceDuration(generatedTrace)).toBe(generatedTrace.spans[0].duration);
|
||||
});
|
||||
|
||||
it('getTraceTimestamp() should return the first timestamp for the conventional trace', () => {
|
||||
expect(traceSelectors.getTraceTimestamp(generatedTrace)).toBe(generatedTrace.spans[0].startTime);
|
||||
});
|
||||
|
||||
it('getTraceDepth() should determine the total depth of the trace tree', () => {
|
||||
expect(traceSelectors.getTraceDepth(generatedTrace)).toBe(
|
||||
traceSelectors.getTraceSpanIdsAsTree(generatedTrace).depth - 1
|
||||
);
|
||||
});
|
||||
|
||||
it('getSpanDepthForTrace() should determine the depth of a given span in the parent', () => {
|
||||
function testDepthCalc(span) {
|
||||
let depth = 2;
|
||||
let currentId = getSpanParentId(span);
|
||||
|
||||
const findCurrentSpanById = item => getSpanId(item) === currentId;
|
||||
while (currentId !== getSpanId(generatedTrace.spans[0])) {
|
||||
depth++;
|
||||
currentId = getSpanParentId(generatedTrace.spans.find(findCurrentSpanById));
|
||||
}
|
||||
|
||||
// console.log('hypothetical depth', depth);
|
||||
|
||||
expect(
|
||||
traceSelectors.getSpanDepthForTrace({
|
||||
trace: generatedTrace,
|
||||
span,
|
||||
})
|
||||
).toBe(depth);
|
||||
}
|
||||
|
||||
// test depth calculations for a few random spans
|
||||
testDepthCalc(generatedTrace.spans[1]);
|
||||
testDepthCalc(generatedTrace.spans[Math.floor(generatedTrace.spans.length / 2)]);
|
||||
testDepthCalc(generatedTrace.spans[Math.floor(generatedTrace.spans.length / 4)]);
|
||||
testDepthCalc(generatedTrace.spans[Math.floor(generatedTrace.spans.length * 0.75)]);
|
||||
});
|
||||
|
||||
it('getTraceServices() should return an unique array of all services in the trace', () => {
|
||||
const svcs = [...traceSelectors.getTraceServices(generatedTrace)].sort();
|
||||
const set = new Set(_values(generatedTrace.processes).map(v => v.serviceName));
|
||||
const setSvcs = [...set.values()].sort();
|
||||
expect(svcs).toEqual(setSvcs);
|
||||
});
|
||||
|
||||
it('getTraceServiceCount() should return the length of the service list', () => {
|
||||
expect(traceSelectors.getTraceServiceCount(generatedTrace)).toBe(
|
||||
generatedTrace.spans.reduce(
|
||||
(results, { processID }) => results.add(generatedTrace.processes[processID].serviceName),
|
||||
new Set()
|
||||
).size
|
||||
);
|
||||
});
|
||||
|
||||
it('formatDurationForUnit() should use the formatters to return the proper value', () => {
|
||||
expect(traceSelectors.formatDurationForUnit({ duration: 302000, unit: 'ms' })).toBe('302ms');
|
||||
|
||||
expect(traceSelectors.formatDurationForUnit({ duration: 1302000, unit: 'ms' })).toBe('1302ms');
|
||||
|
||||
expect(traceSelectors.formatDurationForUnit({ duration: 1302000, unit: 's' })).toBe('1.302s');
|
||||
|
||||
expect(traceSelectors.formatDurationForUnit({ duration: 90000, unit: 's' })).toBe('0.09s');
|
||||
});
|
||||
|
||||
it('formatDurationForTrace() should return a ms value for traces shorter than a second', () => {
|
||||
expect(
|
||||
traceSelectors.formatDurationForTrace({
|
||||
trace: {
|
||||
spans: [{ duration: 600000 }],
|
||||
},
|
||||
duration: 302000,
|
||||
})
|
||||
).toBe('302ms');
|
||||
});
|
||||
|
||||
it('formatDurationForTrace() should return a s value for traces longer than a second', () => {
|
||||
expect(
|
||||
traceSelectors.formatDurationForTrace({
|
||||
trace: {
|
||||
...generatedTrace,
|
||||
spans: generatedTrace.spans.concat([
|
||||
{
|
||||
...generatedTrace.spans[0],
|
||||
duration: 1000000,
|
||||
},
|
||||
]),
|
||||
},
|
||||
duration: 302000,
|
||||
})
|
||||
).toBe('0.302s');
|
||||
|
||||
expect(
|
||||
traceSelectors.formatDurationForTrace({
|
||||
trace: {
|
||||
...generatedTrace,
|
||||
spans: generatedTrace.spans.concat([
|
||||
{
|
||||
...generatedTrace.spans[0],
|
||||
duration: 1200000,
|
||||
},
|
||||
]),
|
||||
},
|
||||
duration: 302000,
|
||||
})
|
||||
).toBe('0.302s');
|
||||
});
|
||||
|
||||
it('getSortedSpans() should sort spans given a sort object', () => {
|
||||
expect(
|
||||
traceSelectors.getSortedSpans({
|
||||
trace: generatedTrace,
|
||||
spans: generatedTrace.spans,
|
||||
sort: {
|
||||
dir: 1,
|
||||
comparator: numberSortComparator,
|
||||
selector: getSpanTimestamp,
|
||||
},
|
||||
})
|
||||
).toEqual([...generatedTrace.spans].sort((spanA, spanB) => spanA.startTime - spanB.startTime));
|
||||
|
||||
expect(
|
||||
traceSelectors.getSortedSpans({
|
||||
trace: generatedTrace,
|
||||
spans: generatedTrace.spans,
|
||||
sort: {
|
||||
dir: -1,
|
||||
comparator: numberSortComparator,
|
||||
selector: getSpanTimestamp,
|
||||
},
|
||||
})
|
||||
).toEqual([...generatedTrace.spans].sort((spanA, spanB) => spanB.startTime - spanA.startTime));
|
||||
});
|
||||
|
||||
it('getTreeSizeForTraceSpan() should return the size for the parent span', () => {
|
||||
expect(
|
||||
traceSelectors.getTreeSizeForTraceSpan({
|
||||
trace: generatedTrace,
|
||||
span: generatedTrace.spans[0],
|
||||
})
|
||||
).toBe(generatedTrace.spans.length - 1);
|
||||
});
|
||||
|
||||
it('getTreeSizeForTraceSpan() should return the size for a child span', () => {
|
||||
expect(
|
||||
traceSelectors.getTreeSizeForTraceSpan({
|
||||
trace: generatedTrace,
|
||||
span: generatedTrace.spans[1],
|
||||
})
|
||||
).toBe(traceSelectors.getTraceSpanIdsAsTree(generatedTrace).find(generatedTrace.spans[1].spanID).size - 1);
|
||||
});
|
||||
|
||||
it('getTreeSizeForTraceSpan() should return -1 for an absent span', () => {
|
||||
expect(
|
||||
traceSelectors.getTreeSizeForTraceSpan({
|
||||
trace: generatedTrace,
|
||||
span: { spanID: 'whatever' },
|
||||
})
|
||||
).toBe(-1);
|
||||
});
|
||||
|
||||
it('getTraceName() should return the trace name based on the parentSpan', () => {
|
||||
const serviceName = generatedTrace.processes[generatedTrace.spans[0].processID].serviceName;
|
||||
const operationName = generatedTrace.spans[0].operationName;
|
||||
|
||||
expect(traceSelectors.getTraceName(generatedTrace)).toBe(`${serviceName}: ${operationName}`);
|
||||
});
|
||||
|
||||
it('omitCollapsedSpans() should filter out collaped spans', () => {
|
||||
const span = generatedTrace.spans[1];
|
||||
const size = traceSelectors.getTraceSpanIdsAsTree(generatedTrace).find(span.spanID).size - 1;
|
||||
|
||||
expect(
|
||||
traceSelectors.omitCollapsedSpans({
|
||||
trace: generatedTrace,
|
||||
spans: generatedTrace.spans,
|
||||
collapsed: [span.spanID],
|
||||
}).length
|
||||
).toBe(generatedTrace.spans.length - size);
|
||||
});
|
||||
|
||||
it('getTicksForTrace() should return a list of ticks given interval parameters', () => {
|
||||
const timestamp = new Date().getTime() * 1000;
|
||||
const trace = {
|
||||
spans: [
|
||||
{
|
||||
startTime: timestamp,
|
||||
duration: 3000000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
traceSelectors.getTicksForTrace({
|
||||
trace,
|
||||
interval: 3,
|
||||
width: 10,
|
||||
})
|
||||
).toEqual([
|
||||
{ timestamp, width: 10 },
|
||||
{ timestamp: timestamp + 1000000, width: 10 },
|
||||
{ timestamp: timestamp + 2000000, width: 10 },
|
||||
{ timestamp: timestamp + 3000000, width: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('getTicksForTrace() should use defaults', () => {
|
||||
const timestamp = new Date().getTime() * 1000;
|
||||
const trace = {
|
||||
spans: [
|
||||
{
|
||||
startTime: timestamp,
|
||||
duration: 4000000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(traceSelectors.getTicksForTrace({ trace })).toEqual([
|
||||
{ timestamp, width: traceSelectors.DEFAULT_TICK_WIDTH },
|
||||
{
|
||||
timestamp: timestamp + 1000000,
|
||||
width: traceSelectors.DEFAULT_TICK_WIDTH,
|
||||
},
|
||||
{
|
||||
timestamp: timestamp + 2000000,
|
||||
width: traceSelectors.DEFAULT_TICK_WIDTH,
|
||||
},
|
||||
{
|
||||
timestamp: timestamp + 3000000,
|
||||
width: traceSelectors.DEFAULT_TICK_WIDTH,
|
||||
},
|
||||
{
|
||||
timestamp: timestamp + 4000000,
|
||||
width: traceSelectors.DEFAULT_TICK_WIDTH,
|
||||
},
|
||||
]);
|
||||
});
|
||||
36
packages/jaeger-ui-components/src/types/TDdgState.tsx
Normal file
36
packages/jaeger-ui-components/src/types/TDdgState.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { ApiError } from './api-error';
|
||||
import { fetchedState } from '../constants';
|
||||
import { TDdgModel } from '../model/ddg/types';
|
||||
|
||||
export type TDdgStateEntry =
|
||||
| {
|
||||
state: typeof fetchedState.LOADING;
|
||||
}
|
||||
| {
|
||||
error: ApiError;
|
||||
state: typeof fetchedState.ERROR;
|
||||
}
|
||||
| {
|
||||
model: TDdgModel;
|
||||
state: typeof fetchedState.DONE;
|
||||
viewModifiers: Map<number, number>;
|
||||
};
|
||||
|
||||
type TDdgState = Record<string, TDdgStateEntry>;
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
export default TDdgState;
|
||||
18
packages/jaeger-ui-components/src/types/TNil.tsx
Normal file
18
packages/jaeger-ui-components/src/types/TNil.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
type TNil = null | undefined;
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
export default TNil;
|
||||
24
packages/jaeger-ui-components/src/types/TTraceDiffState.tsx
Normal file
24
packages/jaeger-ui-components/src/types/TTraceDiffState.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import TNil from './TNil';
|
||||
|
||||
type TTraceDiffState = {
|
||||
a?: string | TNil;
|
||||
b?: string | TNil;
|
||||
cohort: string[];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
export default TTraceDiffState;
|
||||
28
packages/jaeger-ui-components/src/types/TTraceTimeline.tsx
Normal file
28
packages/jaeger-ui-components/src/types/TTraceTimeline.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import DetailState from '../TraceTimelineViewer/SpanDetail/DetailState';
|
||||
import TNil from './TNil';
|
||||
|
||||
type TTraceTimeline = {
|
||||
childrenHiddenIDs: Set<string>;
|
||||
detailStates: Map<string, DetailState>;
|
||||
hoverIndentGuideIds: Set<string>;
|
||||
shouldScrollToFirstUiFindMatch: boolean;
|
||||
spanNameColumnWidth: number;
|
||||
traceID: string | TNil;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
export default TTraceTimeline;
|
||||
24
packages/jaeger-ui-components/src/types/api-error.tsx
Normal file
24
packages/jaeger-ui-components/src/types/api-error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export type ApiError = // eslint-disable-line import/prefer-default-export
|
||||
| string
|
||||
| {
|
||||
message: string;
|
||||
httpStatus?: any;
|
||||
httpStatusText?: string;
|
||||
httpUrl?: string;
|
||||
httpQuery?: string;
|
||||
httpBody?: string;
|
||||
};
|
||||
25
packages/jaeger-ui-components/src/types/archive.tsx
Normal file
25
packages/jaeger-ui-components/src/types/archive.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { ApiError } from './api-error';
|
||||
|
||||
export type TraceArchive = {
|
||||
isLoading?: boolean;
|
||||
isArchived?: boolean;
|
||||
isError?: boolean;
|
||||
error?: ApiError;
|
||||
isAcknowledged?: boolean;
|
||||
};
|
||||
|
||||
export type TracesArchive = Record<string, TraceArchive>;
|
||||
57
packages/jaeger-ui-components/src/types/config.tsx
Normal file
57
packages/jaeger-ui-components/src/types/config.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { TNil } from '.';
|
||||
|
||||
export type ConfigMenuItem = {
|
||||
label: string;
|
||||
url: string;
|
||||
anchorTarget?: '_self' | '_blank' | '_parent' | '_top';
|
||||
};
|
||||
|
||||
export type ConfigMenuGroup = {
|
||||
label: string;
|
||||
items: ConfigMenuItem[];
|
||||
};
|
||||
|
||||
export type TScript = {
|
||||
text: string;
|
||||
type: 'inline';
|
||||
};
|
||||
|
||||
export type LinkPatternsConfig = {
|
||||
type: 'process' | 'tags' | 'logs' | 'traces';
|
||||
key?: string;
|
||||
url: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
archiveEnabled?: boolean;
|
||||
deepDependencies?: { menuEnabled?: boolean };
|
||||
dependencies?: { dagMaxServicesLen?: number; menuEnabled?: boolean };
|
||||
menu: (ConfigMenuGroup | ConfigMenuItem)[];
|
||||
search?: { maxLookback: { label: string; value: string }; maxLimit: number };
|
||||
scripts?: TScript[];
|
||||
topTagPrefixes?: string[];
|
||||
tracking?: {
|
||||
cookieToDimension?: {
|
||||
cookie: string;
|
||||
dimension: string;
|
||||
}[];
|
||||
gaID: string | TNil;
|
||||
trackErrors: boolean | TNil;
|
||||
};
|
||||
linkPatterns?: LinkPatternsConfig;
|
||||
};
|
||||
25
packages/jaeger-ui-components/src/types/embedded.tsx
Normal file
25
packages/jaeger-ui-components/src/types/embedded.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2018 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
type EmbeddedStateV0 = {
|
||||
version: 'v0';
|
||||
searchHideGraph: boolean;
|
||||
timeline: {
|
||||
collapseTitle: boolean;
|
||||
hideMinimap: boolean;
|
||||
hideSummary: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type EmbeddedState = EmbeddedStateV0; // eslint-disable-line import/prefer-default-export
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user