Tracing: Adds header and minimap (#23315)

* 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

* Add header with minimap

* Fix sizing issue due to column resizer handle

* Fix issues with sizing, search functionality, duplicate react, tests

* Refactor TraceView component, fix tests

* Fix type errors

* Add tests for hooks

Co-authored-by: David Kaltschmidt <david.kaltschmidt@gmail.com>
pull/23441/head
Andrej Ocenas 5 years ago committed by GitHub
parent d04dce6a37
commit 008bee8f27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 17
      conf/provisioning/datasources/loki_test.yaml
  2. 4
      packages/grafana-ui/src/components/Input/Input.tsx
  3. 1
      packages/jaeger-ui-components/package.json
  4. 32
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.test.js
  5. 73
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.tsx
  6. 44
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.test.js
  7. 47
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.tsx
  8. 62
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.test.js
  9. 108
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx
  10. 59
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/TickLabels.test.js
  11. 60
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/TickLabels.tsx
  12. 328
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.js
  13. 408
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx
  14. 76
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.js
  15. 102
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx
  16. 199
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.test.js
  17. 62
      packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.tsx
  18. 80
      packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js
  19. 293
      packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx
  20. 15
      packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.markers.tsx
  21. 92
      packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js
  22. 144
      packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx
  23. 15
      packages/jaeger-ui-components/src/TracePageHeader/index.tsx
  24. 6
      packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx
  25. 11
      packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx
  26. 19
      packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx
  27. 64
      packages/jaeger-ui-components/src/common/BreakableText.tsx
  28. 59
      packages/jaeger-ui-components/src/common/ExternalLinks.tsx
  29. 78
      packages/jaeger-ui-components/src/common/LoadingIndicator.tsx
  30. 65
      packages/jaeger-ui-components/src/common/TraceName.tsx
  31. 65
      packages/jaeger-ui-components/src/common/UiFindInput.test.js
  32. 66
      packages/jaeger-ui-components/src/common/UiFindInput.tsx
  33. 10
      packages/jaeger-ui-components/src/common/__snapshots__/UiFindInput.test.js.snap
  34. 2
      packages/jaeger-ui-components/src/index.ts
  35. 12
      packages/jaeger-ui-components/src/model/link-patterns.tsx
  36. 1
      packages/jaeger-ui-components/src/model/span.tsx
  37. 20
      packages/jaeger-ui-components/src/model/trace-viewer.ts
  38. 2
      packages/jaeger-ui-components/src/types/trace.tsx
  39. 12
      packages/jaeger-ui-components/src/uberUtilityStyles.ts
  40. 40
      packages/jaeger-ui-components/src/uiElementsContext.tsx
  41. 184
      packages/jaeger-ui-components/src/utils/filter-spans.test.js
  42. 67
      packages/jaeger-ui-components/src/utils/filter-spans.tsx
  43. 2
      public/app/features/explore/Explore.test.tsx
  44. 26
      public/app/features/explore/Explore.tsx
  45. 182
      public/app/features/explore/TraceView.test.tsx
  46. 247
      public/app/features/explore/TraceView.tsx
  47. 216
      public/app/features/explore/TraceView/TraceView.test.tsx
  48. 119
      public/app/features/explore/TraceView/TraceView.tsx
  49. 45
      public/app/features/explore/TraceView/uiElements.tsx
  50. 65
      public/app/features/explore/TraceView/useChildrenState.test.ts
  51. 94
      public/app/features/explore/TraceView/useChildrenState.ts
  52. 56
      public/app/features/explore/TraceView/useDetailState.test.ts
  53. 70
      public/app/features/explore/TraceView/useDetailState.ts
  54. 16
      public/app/features/explore/TraceView/useHoverIndentGuide.test.ts
  55. 30
      public/app/features/explore/TraceView/useHoverIndentGuide.ts
  56. 44
      public/app/features/explore/TraceView/useSearch.test.ts
  57. 15
      public/app/features/explore/TraceView/useSearch.ts
  58. 25
      public/app/features/explore/TraceView/useViewRange.test.ts
  59. 35
      public/app/features/explore/TraceView/useViewRange.ts
  60. 2
      public/app/features/explore/Wrapper.tsx
  61. 734
      yarn.lock

@ -0,0 +1,17 @@
apiVersion: 1
datasources:
- name: loki-derived-test
type: loki
access: proxy
url: http://localhost:3100
editable: false
jsonData:
derivedFields:
- name: "traceID"
matcherRegex: "traceID=(\\w+)"
url: "$${__value.raw}"
datasourceName: "Jaeger"

@ -11,9 +11,9 @@ export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'prefix' | 'siz
/** Show an invalid state around the input */
invalid?: boolean;
/** Show an icon as a prefix in the input */
prefix?: JSX.Element | string | null;
prefix?: ReactNode;
/** Show an icon as a suffix in the input */
suffix?: JSX.Element | string | null;
suffix?: ReactNode;
/** Show a loading indicator as a suffix in the input */
loading?: boolean;
/** Add a component as an addon before the input */

@ -34,7 +34,6 @@
"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"

@ -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 CanvasSpanGraph from './CanvasSpanGraph';
describe('<CanvasSpanGraph>', () => {
it('renders without exploding', () => {
const items = [{ valueWidth: 1, valueOffset: 1, serviceName: 'service-name-0' }];
const wrapper = shallow(<CanvasSpanGraph items={[]} valueWidth={4000} />);
expect(wrapper).toBeDefined();
wrapper.instance()._setCanvasRef({
getContext: () => ({
fillRect: () => {},
}),
});
wrapper.setProps({ items });
});
});

@ -0,0 +1,73 @@
// 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 renderIntoCanvas from './render-into-canvas';
import colorGenerator from '../../utils/color-generator';
import { TNil } from '../../types';
import { createStyle } from '../../Theme';
const getStyles = createStyle(() => {
return {
CanvasSpanGraph: css`
label: CanvasSpanGraph;
background: #fafafa;
height: 60px;
position: absolute;
width: 100%;
`,
};
});
type CanvasSpanGraphProps = {
items: Array<{ valueWidth: number; valueOffset: number; serviceName: string }>;
valueWidth: number;
};
const getColor = (hex: string) => colorGenerator.getRgbColorByKey(hex);
export default class CanvasSpanGraph extends React.PureComponent<CanvasSpanGraphProps> {
_canvasElm: HTMLCanvasElement | TNil;
constructor(props: CanvasSpanGraphProps) {
super(props);
this._canvasElm = undefined;
}
componentDidMount() {
this._draw();
}
componentDidUpdate() {
this._draw();
}
_setCanvasRef = (elm: HTMLCanvasElement | TNil) => {
this._canvasElm = elm;
};
_draw() {
if (this._canvasElm) {
const { valueWidth: totalValueWidth, items } = this.props;
renderIntoCanvas(this._canvasElm, items, totalValueWidth, getColor);
}
}
render() {
return <canvas className={getStyles().CanvasSpanGraph} ref={this._setCanvasRef} />;
}
}

@ -0,0 +1,44 @@
// Copyright (c) 2017 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from 'react';
import { shallow } from 'enzyme';
import GraphTicks from './GraphTicks';
describe('<GraphTicks>', () => {
const defaultProps = {
items: [
{ valueWidth: 100, valueOffset: 25, serviceName: 'a' },
{ valueWidth: 100, valueOffset: 50, serviceName: 'b' },
],
valueWidth: 200,
numTicks: 4,
};
let ticksG;
beforeEach(() => {
const wrapper = shallow(<GraphTicks {...defaultProps} />);
ticksG = wrapper.find('[data-test="ticks"]');
});
it('creates a <g> for ticks', () => {
expect(ticksG.length).toBe(1);
});
it('creates a line for each ticks excluding the first and last', () => {
expect(ticksG.find('line').length).toBe(defaultProps.numTicks - 1);
});
});

@ -0,0 +1,47 @@
// 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 { createStyle } from '../../Theme';
const getStyles = createStyle(() => {
return {
GraphTick: css`
label: GraphTick;
stroke: #aaa;
stroke-width: 1px;
`,
};
});
type GraphTicksProps = {
numTicks: number;
};
export default function GraphTicks(props: GraphTicksProps) {
const { numTicks } = props;
const ticks = [];
// i starts at 1, limit is `i < numTicks` so the first and last ticks aren't drawn
for (let i = 1; i < numTicks; i++) {
const x = `${(i / numTicks) * 100}%`;
ticks.push(<line className={getStyles().GraphTick} x1={x} y1="0%" x2={x} y2="100%" key={i / numTicks} />);
}
return (
<g data-test="ticks" aria-hidden="true">
{ticks}
</g>
);
}

@ -0,0 +1,62 @@
// 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 sinon from 'sinon';
import Scrubber, { getStyles } from './Scrubber';
describe('<Scrubber>', () => {
const defaultProps = {
onMouseDown: sinon.spy(),
position: 0,
};
let wrapper;
beforeEach(() => {
wrapper = shallow(<Scrubber {...defaultProps} />);
});
it('contains the proper svg components', () => {
const styles = getStyles();
expect(
wrapper.matchesElement(
<g>
<g className={styles.ScrubberHandles}>
<rect className={styles.ScrubberHandleExpansion} />
<rect className={styles.ScrubberHandle} />
</g>
<line className={styles.ScrubberLine} />
</g>
)
).toBeTruthy();
});
it('calculates the correct x% for a timestamp', () => {
wrapper = shallow(<Scrubber {...defaultProps} position={0.5} />);
const line = wrapper.find('line').first();
const rect = wrapper.find('rect').first();
expect(line.prop('x1')).toBe('50%');
expect(line.prop('x2')).toBe('50%');
expect(rect.prop('x')).toBe('50%');
});
it('supports onMouseDown', () => {
const event = {};
wrapper.find(`.${getStyles().ScrubberHandles}`).prop('onMouseDown')(event);
expect(defaultProps.onMouseDown.calledWith(event)).toBeTruthy();
});
});

@ -0,0 +1,108 @@
// 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 cx from 'classnames';
import { createStyle } from '../../Theme';
import { css } from 'emotion';
export const getStyles = createStyle(() => {
const ScrubberHandleExpansion = css`
label: ScrubberHandleExpansion;
cursor: col-resize;
fill-opacity: 0;
fill: #44f;
`;
const ScrubberHandle = css`
label: ScrubberHandle;
cursor: col-resize;
fill: #555;
`;
const ScrubberLine = css`
label: ScrubberLine;
pointer-events: none;
stroke: #555;
`;
return {
ScrubberDragging: css`
label: ScrubberDragging;
& .${ScrubberHandleExpansion} {
fill-opacity: 1;
}
& .${ScrubberHandle} {
fill: #44f;
}
& > .${ScrubberLine} {
stroke: #44f;
}
`,
ScrubberHandles: css`
label: ScrubberHandles;
&:hover > .${ScrubberHandleExpansion} {
fill-opacity: 1;
}
&:hover > .${ScrubberHandle} {
fill: #44f;
}
&:hover + .${ScrubberLine} {
stroke: #44f;
}
`,
ScrubberHandleExpansion,
ScrubberHandle,
ScrubberLine,
};
});
type ScrubberProps = {
isDragging: boolean;
position: number;
onMouseDown: (evt: React.MouseEvent<any>) => void;
onMouseEnter: (evt: React.MouseEvent<any>) => void;
onMouseLeave: (evt: React.MouseEvent<any>) => void;
};
export default function Scrubber({ isDragging, onMouseDown, onMouseEnter, onMouseLeave, position }: ScrubberProps) {
const xPercent = `${position * 100}%`;
const styles = getStyles();
const className = cx({ [styles.ScrubberDragging]: isDragging });
return (
<g className={className}>
<g
className={styles.ScrubberHandles}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* handleExpansion is only visible when `isDragging` is true */}
<rect
x={xPercent}
className={styles.ScrubberHandleExpansion}
style={{ transform: `translate(-4.5px)` }}
width="9"
height="20"
/>
<rect
x={xPercent}
className={styles.ScrubberHandle}
style={{ transform: `translate(-1.5px)` }}
width="3"
height="20"
/>
</g>
<line className={styles.ScrubberLine} y2="100%" x1={xPercent} x2={xPercent} />
</g>
);
}

@ -0,0 +1,59 @@
// 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 TickLabels from './TickLabels';
describe('<TickLabels>', () => {
const defaultProps = {
numTicks: 4,
duration: 5000,
};
let wrapper;
let ticks;
beforeEach(() => {
wrapper = shallow(<TickLabels {...defaultProps} />);
ticks = wrapper.find('[data-test="tick"]');
});
it('renders the right number of ticks', () => {
expect(ticks.length).toBe(defaultProps.numTicks + 1);
});
it('places the first tick on the left', () => {
const firstTick = ticks.first();
expect(firstTick.prop('style')).toEqual({ left: '0%' });
});
it('places the last tick on the right', () => {
const lastTick = ticks.last();
expect(lastTick.prop('style')).toEqual({ right: '0%' });
});
it('places middle ticks at proper intervals', () => {
const positions = ['25%', '50%', '75%'];
positions.forEach((pos, i) => {
const tick = ticks.at(i + 1);
expect(tick.prop('style')).toEqual({ left: pos });
});
});
it("doesn't explode if no trace is present", () => {
expect(() => shallow(<TickLabels {...defaultProps} trace={null} />)).not.toThrow();
});
});

@ -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.
import React from 'react';
import { formatDuration } from '../../utils/date';
import { createStyle } from '../../Theme';
import { css } from 'emotion';
const getStyles = createStyle(() => {
return {
TickLabels: css`
label: TickLabels;
height: 1rem;
position: relative;
`,
TickLabelsLabel: css`
label: TickLabelsLabel;
color: #717171;
font-size: 0.7rem;
position: absolute;
user-select: none;
`,
};
});
type TickLabelsProps = {
numTicks: number;
duration: number;
};
export default function TickLabels(props: TickLabelsProps) {
const { numTicks, duration } = props;
const styles = getStyles();
const ticks = [];
for (let i = 0; i < numTicks + 1; i++) {
const portion = i / numTicks;
const style = portion === 1 ? { right: '0%' } : { left: `${portion * 100}%` };
ticks.push(
<div key={portion} className={styles.TickLabelsLabel} style={style} data-test="tick">
{formatDuration(duration * portion)}
</div>
);
}
return <div className={styles.TickLabels}>{ticks}</div>;
}

@ -0,0 +1,328 @@
// 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 { shallow } from 'enzyme';
import React from 'react';
import GraphTicks from './GraphTicks';
import Scrubber from './Scrubber';
import ViewingLayer, { dragTypes, getStyles } from './ViewingLayer';
import { EUpdateTypes } from '../../utils/DraggableManager';
import { polyfill as polyfillAnimationFrame } from '../../utils/test/requestAnimationFrame';
function getViewRange(viewStart, viewEnd) {
return {
time: {
current: [viewStart, viewEnd],
},
};
}
describe('<SpanGraph>', () => {
polyfillAnimationFrame(window);
let props;
let wrapper;
beforeEach(() => {
props = {
height: 60,
numTicks: 5,
updateNextViewRangeTime: jest.fn(),
updateViewRangeTime: jest.fn(),
viewRange: getViewRange(0, 1),
};
wrapper = shallow(<ViewingLayer {...props} />);
});
describe('_getDraggingBounds()', () => {
beforeEach(() => {
props = { ...props, viewRange: getViewRange(0.1, 0.9) };
wrapper = shallow(<ViewingLayer {...props} />);
wrapper.instance()._setRoot({
getBoundingClientRect() {
return { left: 10, width: 100 };
},
});
});
it('throws if _root is not set', () => {
const instance = wrapper.instance();
instance._root = null;
expect(() => instance._getDraggingBounds(dragTypes.REFRAME)).toThrow();
});
it('returns the correct bounds for reframe', () => {
const bounds = wrapper.instance()._getDraggingBounds(dragTypes.REFRAME);
expect(bounds).toEqual({
clientXLeft: 10,
width: 100,
maxValue: 1,
minValue: 0,
});
});
it('returns the correct bounds for shiftStart', () => {
const bounds = wrapper.instance()._getDraggingBounds(dragTypes.SHIFT_START);
expect(bounds).toEqual({
clientXLeft: 10,
width: 100,
maxValue: 0.9,
minValue: 0,
});
});
it('returns the correct bounds for shiftEnd', () => {
const bounds = wrapper.instance()._getDraggingBounds(dragTypes.SHIFT_END);
expect(bounds).toEqual({
clientXLeft: 10,
width: 100,
maxValue: 1,
minValue: 0.1,
});
});
});
describe('DraggableManager callbacks', () => {
describe('reframe', () => {
it('handles mousemove', () => {
const value = 0.5;
wrapper.instance()._handleReframeMouseMove({ value });
const calls = props.updateNextViewRangeTime.mock.calls;
expect(calls).toEqual([[{ cursor: value }]]);
});
it('handles mouseleave', () => {
wrapper.instance()._handleReframeMouseLeave();
const calls = props.updateNextViewRangeTime.mock.calls;
expect(calls).toEqual([[{ cursor: null }]]);
});
describe('drag update', () => {
it('handles sans anchor', () => {
const value = 0.5;
wrapper.instance()._handleReframeDragUpdate({ value });
const calls = props.updateNextViewRangeTime.mock.calls;
expect(calls).toEqual([[{ reframe: { anchor: value, shift: value } }]]);
});
it('handles the existing anchor', () => {
const value = 0.5;
const anchor = 0.1;
const time = { ...props.viewRange.time, reframe: { anchor } };
props = { ...props, viewRange: { time } };
wrapper = shallow(<ViewingLayer {...props} />);
wrapper.instance()._handleReframeDragUpdate({ value });
const calls = props.updateNextViewRangeTime.mock.calls;
expect(calls).toEqual([[{ reframe: { anchor, shift: value } }]]);
});
});
describe('drag end', () => {
let manager;
beforeEach(() => {
manager = { resetBounds: jest.fn() };
});
it('handles sans anchor', () => {
const value = 0.5;
wrapper.instance()._handleReframeDragEnd({ manager, value });
expect(manager.resetBounds.mock.calls).toEqual([[]]);
const calls = props.updateViewRangeTime.mock.calls;
expect(calls).toEqual([[value, value, 'minimap']]);
});
it('handles dragged left (anchor is greater)', () => {
const value = 0.5;
const anchor = 0.6;
const time = { ...props.viewRange.time, reframe: { anchor } };
props = { ...props, viewRange: { time } };
wrapper = shallow(<ViewingLayer {...props} />);
wrapper.instance()._handleReframeDragEnd({ manager, value });
expect(manager.resetBounds.mock.calls).toEqual([[]]);
const calls = props.updateViewRangeTime.mock.calls;
expect(calls).toEqual([[value, anchor, 'minimap']]);
});
it('handles dragged right (anchor is less)', () => {
const value = 0.5;
const anchor = 0.4;
const time = { ...props.viewRange.time, reframe: { anchor } };
props = { ...props, viewRange: { time } };
wrapper = shallow(<ViewingLayer {...props} />);
wrapper.instance()._handleReframeDragEnd({ manager, value });
expect(manager.resetBounds.mock.calls).toEqual([[]]);
const calls = props.updateViewRangeTime.mock.calls;
expect(calls).toEqual([[anchor, value, 'minimap']]);
});
});
});
describe('scrubber', () => {
it('prevents the cursor from being drawn on scrubber mouseover', () => {
wrapper.instance()._handleScrubberEnterLeave({ type: EUpdateTypes.MouseEnter });
expect(wrapper.state('preventCursorLine')).toBe(true);
});
it('prevents the cursor from being drawn on scrubber mouseleave', () => {
wrapper.instance()._handleScrubberEnterLeave({ type: EUpdateTypes.MouseLeave });
expect(wrapper.state('preventCursorLine')).toBe(false);
});
describe('drag start and update', () => {
it('stops propagation on drag start', () => {
const stopPropagation = jest.fn();
const update = {
event: { stopPropagation },
type: EUpdateTypes.DragStart,
};
wrapper.instance()._handleScrubberDragUpdate(update);
expect(stopPropagation.mock.calls).toEqual([[]]);
});
it('updates the viewRange for shiftStart and shiftEnd', () => {
const instance = wrapper.instance();
const value = 0.5;
const cases = [
{
dragUpdate: {
value,
tag: dragTypes.SHIFT_START,
type: EUpdateTypes.DragMove,
},
viewRangeUpdate: { shiftStart: value },
},
{
dragUpdate: {
value,
tag: dragTypes.SHIFT_END,
type: EUpdateTypes.DragMove,
},
viewRangeUpdate: { shiftEnd: value },
},
];
cases.forEach(_case => {
instance._handleScrubberDragUpdate(_case.dragUpdate);
expect(props.updateNextViewRangeTime).lastCalledWith(_case.viewRangeUpdate);
});
});
});
it('updates the view on drag end', () => {
const instance = wrapper.instance();
const [viewStart, viewEnd] = props.viewRange.time.current;
const value = 0.5;
const cases = [
{
dragUpdate: {
value,
manager: { resetBounds: jest.fn() },
tag: dragTypes.SHIFT_START,
},
viewRangeUpdate: [value, viewEnd],
},
{
dragUpdate: {
value,
manager: { resetBounds: jest.fn() },
tag: dragTypes.SHIFT_END,
},
viewRangeUpdate: [viewStart, value],
},
];
cases.forEach(_case => {
const { manager } = _case.dragUpdate;
wrapper.setState({ preventCursorLine: true });
expect(wrapper.state('preventCursorLine')).toBe(true);
instance._handleScrubberDragEnd(_case.dragUpdate);
expect(wrapper.state('preventCursorLine')).toBe(false);
expect(manager.resetBounds.mock.calls).toEqual([[]]);
expect(props.updateViewRangeTime).lastCalledWith(..._case.viewRangeUpdate, 'minimap');
});
});
});
describe('.ViewingLayer--resetZoom', () => {
it('should not render .ViewingLayer--resetZoom if props.viewRange.time.current = [0,1]', () => {
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(0);
wrapper.setProps({ viewRange: { time: { current: [0, 1] } } });
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(0);
});
it('should render ViewingLayer--resetZoom if props.viewRange.time.current[0] !== 0', () => {
// If the test fails on the following expect statement, this may be a false negative
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(0);
wrapper.setProps({ viewRange: { time: { current: [0.1, 1] } } });
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(1);
});
it('should render ViewingLayer--resetZoom if props.viewRange.time.current[1] !== 1', () => {
// If the test fails on the following expect statement, this may be a false negative
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(0);
wrapper.setProps({ viewRange: { time: { current: [0, 0.9] } } });
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(1);
});
it('should call props.updateViewRangeTime when clicked', () => {
wrapper.setProps({ viewRange: { time: { current: [0.1, 0.9] } } });
const resetZoomButton = wrapper.find(`.${getStyles().ViewingLayerResetZoom}`);
// If the test fails on the following expect statement, this may be a false negative caused
// by a regression to rendering.
expect(resetZoomButton.length).toBe(1);
resetZoomButton.simulate('click');
expect(props.updateViewRangeTime).lastCalledWith(0, 1);
});
});
});
it('renders a <GraphTicks />', () => {
expect(wrapper.find(GraphTicks).length).toBe(1);
});
it('renders a filtering box if leftBound exists', () => {
const _props = { ...props, viewRange: getViewRange(0.2, 1) };
wrapper = shallow(<ViewingLayer {..._props} />);
const leftBox = wrapper.find(`.${getStyles().ViewingLayerInactive}`);
expect(leftBox.length).toBe(1);
const width = Number(leftBox.prop('width').slice(0, -1));
const x = leftBox.prop('x');
expect(Math.round(width)).toBe(20);
expect(x).toBe(0);
});
it('renders a filtering box if rightBound exists', () => {
const _props = { ...props, viewRange: getViewRange(0, 0.8) };
wrapper = shallow(<ViewingLayer {..._props} />);
const rightBox = wrapper.find(`.${getStyles().ViewingLayerInactive}`);
expect(rightBox.length).toBe(1);
const width = Number(rightBox.prop('width').slice(0, -1));
const x = Number(rightBox.prop('x').slice(0, -1));
expect(Math.round(width)).toBe(20);
expect(x).toBe(80);
});
it('renders handles for the timeRangeFilter', () => {
const [viewStart, viewEnd] = props.viewRange.time.current;
let scrubber = <Scrubber position={viewStart} />;
expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy();
scrubber = <Scrubber position={viewEnd} />;
expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy();
});
});

@ -0,0 +1,408 @@
// 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 cx from 'classnames';
import * as React from 'react';
import { css } from 'emotion';
import GraphTicks from './GraphTicks';
import Scrubber from './Scrubber';
import { TUpdateViewRangeTimeFunction, UIButton, ViewRange, ViewRangeTimeUpdate } from '../..';
import { TNil } from '../..';
import DraggableManager, { DraggableBounds, DraggingUpdate, EUpdateTypes } from '../../utils/DraggableManager';
import { createStyle } from '../../Theme';
export const getStyles = createStyle(() => {
// Need this cause emotion will merge emotion generated classes into single className if used with cx from emotion
// package and the selector won't work
const ViewingLayerResetZoomHoverClassName = 'JaegerUiComponents__ViewingLayerResetZoomHoverClassName';
const ViewingLayerResetZoom = css`
label: ViewingLayerResetZoom;
display: none;
position: absolute;
right: 1%;
top: 10%;
z-index: 1;
`;
return {
ViewingLayer: css`
label: ViewingLayer;
cursor: vertical-text;
position: relative;
z-index: 1;
&:hover > .${ViewingLayerResetZoomHoverClassName} {
display: unset;
}
`,
ViewingLayerGraph: css`
label: ViewingLayerGraph;
border: 1px solid #999;
/* need !important here to overcome something from semantic UI */
overflow: visible !important;
position: relative;
transform-origin: 0 0;
width: 100%;
`,
ViewingLayerInactive: css`
label: ViewingLayerInactive;
fill: rgba(214, 214, 214, 0.5);
`,
ViewingLayerCursorGuide: css`
label: ViewingLayerCursorGuide;
stroke: #f44;
stroke-width: 1;
`,
ViewingLayerDraggedShift: css`
label: ViewingLayerDraggedShift;
fill-opacity: 0.2;
`,
ViewingLayerDrag: css`
label: ViewingLayerDrag;
fill: #44f;
`,
ViewingLayerFullOverlay: css`
label: ViewingLayerFullOverlay;
bottom: 0;
cursor: col-resize;
left: 0;
position: fixed;
right: 0;
top: 0;
user-select: none;
`,
ViewingLayerResetZoom,
ViewingLayerResetZoomHoverClassName,
};
});
type ViewingLayerProps = {
height: number;
numTicks: number;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
viewRange: ViewRange;
};
type ViewingLayerState = {
/**
* Cursor line should not be drawn when the mouse is over the scrubber handle.
*/
preventCursorLine: boolean;
};
/**
* Designate the tags for the different dragging managers. Exported for tests.
*/
export const dragTypes = {
/**
* Tag for dragging the right scrubber, e.g. end of the current view range.
*/
SHIFT_END: 'SHIFT_END',
/**
* Tag for dragging the left scrubber, e.g. start of the current view range.
*/
SHIFT_START: 'SHIFT_START',
/**
* Tag for dragging a new view range.
*/
REFRAME: 'REFRAME',
};
/**
* Returns the layout information for drawing the view-range differential, e.g.
* show what will change when the mouse is released. Basically, this is the
* difference from the start of the drag to the current position.
*
* @returns {{ x: string, width: string, leadginX: string }}
*/
function getNextViewLayout(start: number, position: number) {
const [left, right] = start < position ? [start, position] : [position, start];
return {
x: `${left * 100}%`,
width: `${(right - left) * 100}%`,
leadingX: `${position * 100}%`,
};
}
/**
* `ViewingLayer` is rendered on top of the Canvas rendering of the minimap and
* handles showing the current view range and handles mouse UX for modifying it.
*/
export default class ViewingLayer extends React.PureComponent<ViewingLayerProps, ViewingLayerState> {
state: ViewingLayerState;
_root: Element | TNil;
/**
* `_draggerReframe` handles clicking and dragging on the `ViewingLayer` to
* redefined the view range.
*/
_draggerReframe: DraggableManager;
/**
* `_draggerStart` handles dragging the left scrubber to adjust the start of
* the view range.
*/
_draggerStart: DraggableManager;
/**
* `_draggerEnd` handles dragging the right scrubber to adjust the end of
* the view range.
*/
_draggerEnd: DraggableManager;
constructor(props: ViewingLayerProps) {
super(props);
this._draggerReframe = new DraggableManager({
getBounds: this._getDraggingBounds,
onDragEnd: this._handleReframeDragEnd,
onDragMove: this._handleReframeDragUpdate,
onDragStart: this._handleReframeDragUpdate,
onMouseMove: this._handleReframeMouseMove,
onMouseLeave: this._handleReframeMouseLeave,
tag: dragTypes.REFRAME,
});
this._draggerStart = new DraggableManager({
getBounds: this._getDraggingBounds,
onDragEnd: this._handleScrubberDragEnd,
onDragMove: this._handleScrubberDragUpdate,
onDragStart: this._handleScrubberDragUpdate,
onMouseEnter: this._handleScrubberEnterLeave,
onMouseLeave: this._handleScrubberEnterLeave,
tag: dragTypes.SHIFT_START,
});
this._draggerEnd = new DraggableManager({
getBounds: this._getDraggingBounds,
onDragEnd: this._handleScrubberDragEnd,
onDragMove: this._handleScrubberDragUpdate,
onDragStart: this._handleScrubberDragUpdate,
onMouseEnter: this._handleScrubberEnterLeave,
onMouseLeave: this._handleScrubberEnterLeave,
tag: dragTypes.SHIFT_END,
});
this._root = undefined;
this.state = {
preventCursorLine: false,
};
}
componentWillUnmount() {
this._draggerReframe.dispose();
this._draggerEnd.dispose();
this._draggerStart.dispose();
}
_setRoot = (elm: SVGElement | TNil) => {
this._root = elm;
};
_getDraggingBounds = (tag: string | TNil): DraggableBounds => {
if (!this._root) {
throw new Error('invalid state');
}
const { left: clientXLeft, width } = this._root.getBoundingClientRect();
const [viewStart, viewEnd] = this.props.viewRange.time.current;
let maxValue = 1;
let minValue = 0;
if (tag === dragTypes.SHIFT_START) {
maxValue = viewEnd;
} else if (tag === dragTypes.SHIFT_END) {
minValue = viewStart;
}
return { clientXLeft, maxValue, minValue, width };
};
_handleReframeMouseMove = ({ value }: DraggingUpdate) => {
this.props.updateNextViewRangeTime({ cursor: value });
};
_handleReframeMouseLeave = () => {
this.props.updateNextViewRangeTime({ cursor: null });
};
_handleReframeDragUpdate = ({ value }: DraggingUpdate) => {
const shift = value;
const { time } = this.props.viewRange;
const anchor = time.reframe ? time.reframe.anchor : shift;
const update = { reframe: { anchor, shift } };
this.props.updateNextViewRangeTime(update);
};
_handleReframeDragEnd = ({ manager, value }: DraggingUpdate) => {
const { time } = this.props.viewRange;
const anchor = time.reframe ? time.reframe.anchor : value;
const [start, end] = value < anchor ? [value, anchor] : [anchor, value];
manager.resetBounds();
this.props.updateViewRangeTime(start, end, 'minimap');
};
_handleScrubberEnterLeave = ({ type }: DraggingUpdate) => {
const preventCursorLine = type === EUpdateTypes.MouseEnter;
this.setState({ preventCursorLine });
};
_handleScrubberDragUpdate = ({ event, tag, type, value }: DraggingUpdate) => {
if (type === EUpdateTypes.DragStart) {
event.stopPropagation();
}
if (tag === dragTypes.SHIFT_START) {
this.props.updateNextViewRangeTime({ shiftStart: value });
} else if (tag === dragTypes.SHIFT_END) {
this.props.updateNextViewRangeTime({ shiftEnd: value });
}
};
_handleScrubberDragEnd = ({ manager, tag, value }: DraggingUpdate) => {
const [viewStart, viewEnd] = this.props.viewRange.time.current;
let update: [number, number];
if (tag === dragTypes.SHIFT_START) {
update = [value, viewEnd];
} else if (tag === dragTypes.SHIFT_END) {
update = [viewStart, value];
} else {
// to satisfy flow
throw new Error('bad state');
}
manager.resetBounds();
this.setState({ preventCursorLine: false });
this.props.updateViewRangeTime(update[0], update[1], 'minimap');
};
/**
* Resets the zoom to fully zoomed out.
*/
_resetTimeZoomClickHandler = () => {
this.props.updateViewRangeTime(0, 1);
};
/**
* Renders the difference between where the drag started and the current
* position, e.g. the red or blue highlight.
*
* @returns React.Node[]
*/
_getMarkers(from: number, to: number) {
const styles = getStyles();
const layout = getNextViewLayout(from, to);
return [
<rect
key="fill"
className={cx(styles.ViewingLayerDraggedShift, styles.ViewingLayerDrag)}
x={layout.x}
y="0"
width={layout.width}
height={this.props.height - 2}
/>,
<rect
key="edge"
className={cx(styles.ViewingLayerDrag)}
x={layout.leadingX}
y="0"
width="1"
height={this.props.height - 2}
/>,
];
}
render() {
const { height, viewRange, numTicks } = this.props;
const { preventCursorLine } = this.state;
const { current, cursor, shiftStart, shiftEnd, reframe } = viewRange.time;
const haveNextTimeRange = shiftStart != null || shiftEnd != null || reframe != null;
const [viewStart, viewEnd] = current;
let leftInactive = 0;
if (viewStart) {
leftInactive = viewStart * 100;
}
let rightInactive = 100;
if (viewEnd) {
rightInactive = 100 - viewEnd * 100;
}
let cursorPosition: string | undefined;
if (!haveNextTimeRange && cursor != null && !preventCursorLine) {
cursorPosition = `${cursor * 100}%`;
}
const styles = getStyles();
return (
<div aria-hidden className={styles.ViewingLayer} style={{ height }}>
{(viewStart !== 0 || viewEnd !== 1) && (
<UIButton
onClick={this._resetTimeZoomClickHandler}
className={cx(styles.ViewingLayerResetZoom, styles.ViewingLayerResetZoomHoverClassName)}
htmlType="button"
>
Reset Selection
</UIButton>
)}
<svg
height={height}
className={styles.ViewingLayerGraph}
ref={this._setRoot}
onMouseDown={this._draggerReframe.handleMouseDown}
onMouseLeave={this._draggerReframe.handleMouseLeave}
onMouseMove={this._draggerReframe.handleMouseMove}
>
{leftInactive > 0 && (
<rect x={0} y={0} height="100%" width={`${leftInactive}%`} className={styles.ViewingLayerInactive} />
)}
{rightInactive > 0 && (
<rect
x={`${100 - rightInactive}%`}
y={0}
height="100%"
width={`${rightInactive}%`}
className={styles.ViewingLayerInactive}
/>
)}
<GraphTicks numTicks={numTicks} />
{cursorPosition && (
<line
className={styles.ViewingLayerCursorGuide}
x1={cursorPosition}
y1="0"
x2={cursorPosition}
y2={height - 2}
strokeWidth="1"
/>
)}
{shiftStart != null && this._getMarkers(viewStart, shiftStart)}
{shiftEnd != null && this._getMarkers(viewEnd, shiftEnd)}
<Scrubber
isDragging={shiftStart != null}
onMouseDown={this._draggerStart.handleMouseDown}
onMouseEnter={this._draggerStart.handleMouseEnter}
onMouseLeave={this._draggerStart.handleMouseLeave}
position={viewStart || 0}
/>
<Scrubber
isDragging={shiftEnd != null}
position={viewEnd || 1}
onMouseDown={this._draggerEnd.handleMouseDown}
onMouseEnter={this._draggerEnd.handleMouseEnter}
onMouseLeave={this._draggerEnd.handleMouseLeave}
/>
{reframe != null && this._getMarkers(reframe.anchor, reframe.shift)}
</svg>
{/* fullOverlay updates the mouse cursor blocks mouse events */}
{haveNextTimeRange && <div className={styles.ViewingLayerFullOverlay} />}
</div>
);
}
}

@ -0,0 +1,76 @@
// 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 CanvasSpanGraph from './CanvasSpanGraph';
import SpanGraph from './index';
import TickLabels from './TickLabels';
import ViewingLayer from './ViewingLayer';
import traceGenerator from '../../demo/trace-generators';
import transformTraceData from '../../model/transform-trace-data';
import { polyfill as polyfillAnimationFrame } from '../../utils/test/requestAnimationFrame';
describe('<SpanGraph>', () => {
polyfillAnimationFrame(window);
const trace = transformTraceData(traceGenerator.trace({}));
const props = {
trace,
updateViewRangeTime: () => {},
viewRange: {
time: {
current: [0, 1],
},
},
};
let wrapper;
beforeEach(() => {
wrapper = shallow(<SpanGraph {...props} />);
});
it('renders a <CanvasSpanGraph />', () => {
expect(wrapper.find(CanvasSpanGraph).length).toBe(1);
});
it('renders a <TickLabels />', () => {
expect(wrapper.find(TickLabels).length).toBe(1);
});
it('returns a <div> if a trace is not provided', () => {
wrapper = shallow(<SpanGraph {...props} trace={null} />);
expect(wrapper.matchesElement(<div />)).toBeTruthy();
});
it('passes the number of ticks to render to components', () => {
const tickHeader = wrapper.find(TickLabels);
const viewingLayer = wrapper.find(ViewingLayer);
expect(tickHeader.prop('numTicks')).toBeGreaterThan(1);
expect(viewingLayer.prop('numTicks')).toBeGreaterThan(1);
expect(tickHeader.prop('numTicks')).toBe(viewingLayer.prop('numTicks'));
});
it('passes items to CanvasSpanGraph', () => {
const canvasGraph = wrapper.find(CanvasSpanGraph).first();
const items = trace.spans.map(span => ({
valueOffset: span.relativeStartTime,
valueWidth: span.duration,
serviceName: span.process.serviceName,
}));
expect(canvasGraph.prop('items')).toEqual(items);
});
});

@ -0,0 +1,102 @@
// 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 cx from 'classnames';
import CanvasSpanGraph from './CanvasSpanGraph';
import TickLabels from './TickLabels';
import ViewingLayer from './ViewingLayer';
import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '../..';
import { Span, Trace } from '../..';
import { ubPb2, ubPx2, ubRelative } from '../../uberUtilityStyles';
const DEFAULT_HEIGHT = 60;
const TIMELINE_TICK_INTERVAL = 4;
type SpanGraphProps = {
height?: number;
trace: Trace;
viewRange: ViewRange;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
updateNextViewRangeTime: (nextUpdate: ViewRangeTimeUpdate) => void;
};
/**
* Store `items` in state so they are not regenerated every render. Otherwise,
* the canvas graph will re-render itself every time.
*/
type SpanGraphState = {
items: Array<{
valueOffset: number;
valueWidth: number;
serviceName: string;
}>;
};
function getItem(span: Span) {
return {
valueOffset: span.relativeStartTime,
valueWidth: span.duration,
serviceName: span.process.serviceName,
};
}
export default class SpanGraph extends React.PureComponent<SpanGraphProps, SpanGraphState> {
state: SpanGraphState;
static defaultProps = {
height: DEFAULT_HEIGHT,
};
constructor(props: SpanGraphProps) {
super(props);
const { trace } = props;
this.state = {
items: trace ? trace.spans.map(getItem) : [],
};
}
componentWillReceiveProps(nextProps: SpanGraphProps) {
const { trace } = nextProps;
if (this.props.trace !== trace) {
this.setState({
items: trace ? trace.spans.map(getItem) : [],
});
}
}
render() {
const { height, trace, viewRange, updateNextViewRangeTime, updateViewRangeTime } = this.props;
if (!trace) {
return <div />;
}
const { items } = this.state;
return (
<div className={cx(ubPb2, ubPx2)}>
<TickLabels numTicks={TIMELINE_TICK_INTERVAL} duration={trace.duration} />
<div className={ubRelative}>
<CanvasSpanGraph valueWidth={trace.duration} items={items} />
<ViewingLayer
viewRange={viewRange}
numTicks={TIMELINE_TICK_INTERVAL}
height={height || DEFAULT_HEIGHT}
updateViewRangeTime={updateViewRangeTime}
updateNextViewRangeTime={updateNextViewRangeTime}
/>
</div>
</div>
);
}
}

@ -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 _range from 'lodash/range';
import renderIntoCanvas, {
BG_COLOR,
ITEM_ALPHA,
MIN_ITEM_HEIGHT,
MAX_TOTAL_HEIGHT,
MIN_ITEM_WIDTH,
MIN_TOTAL_HEIGHT,
MAX_ITEM_HEIGHT,
} from './render-into-canvas';
const getCanvasWidth = () => window.innerWidth * 2;
const getBgFillRect = items => ({
fillStyle: BG_COLOR,
height:
!items || items.length < MIN_TOTAL_HEIGHT ? MIN_TOTAL_HEIGHT : Math.min(MAX_TOTAL_HEIGHT, items.length),
width: getCanvasWidth(),
x: 0,
y: 0,
});
describe('renderIntoCanvas()', () => {
const basicItem = { valueWidth: 100, valueOffset: 50, serviceName: 'some-name' };
class CanvasContext {
constructor() {
this.fillStyle = undefined;
this.fillRectAccumulator = [];
}
fillRect(x, y, width, height) {
const fillStyle = this.fillStyle;
this.fillRectAccumulator.push({
fillStyle,
height,
width,
x,
y,
});
}
}
class Canvas {
constructor() {
this.contexts = [];
this.height = NaN;
this.width = NaN;
this.getContext = jest.fn(this._getContext.bind(this));
}
_getContext() {
const ctx = new CanvasContext();
this.contexts.push(ctx);
return ctx;
}
}
function getColorFactory() {
let i = 0;
const inputOutput = [];
function getFakeColor(str) {
const rv = [i, i, i];
i++;
inputOutput.push({
input: str,
output: rv.slice(),
});
return rv;
}
getFakeColor.inputOutput = inputOutput;
return getFakeColor;
}
it('sets the width', () => {
const canvas = new Canvas();
expect(canvas.width !== canvas.width).toBe(true);
renderIntoCanvas(canvas, [basicItem], 150, getColorFactory());
expect(canvas.width).toBe(getCanvasWidth());
});
describe('when there are limited number of items', () => {
it('sets the height', () => {
const canvas = new Canvas();
expect(canvas.height !== canvas.height).toBe(true);
renderIntoCanvas(canvas, [basicItem], 150, getColorFactory());
expect(canvas.height).toBe(MIN_TOTAL_HEIGHT);
});
it('draws the background', () => {
const expectedDrawing = [getBgFillRect()];
const canvas = new Canvas();
const items = [];
const totalValueWidth = 4000;
const getFillColor = getColorFactory();
renderIntoCanvas(canvas, items, totalValueWidth, getFillColor);
expect(canvas.getContext.mock.calls).toEqual([['2d', { alpha: false }]]);
expect(canvas.contexts.length).toBe(1);
expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawing);
});
it('draws the map', () => {
const totalValueWidth = 4000;
const items = [
{ valueWidth: 50, valueOffset: 50, serviceName: 'service-name-0' },
{ valueWidth: 100, valueOffset: 100, serviceName: 'service-name-1' },
{ valueWidth: 150, valueOffset: 150, serviceName: 'service-name-2' },
];
const expectedColors = [
{ input: items[0].serviceName, output: [0, 0, 0] },
{ input: items[1].serviceName, output: [1, 1, 1] },
{ input: items[2].serviceName, output: [2, 2, 2] },
];
const cHeight =
items.length < MIN_TOTAL_HEIGHT ? MIN_TOTAL_HEIGHT : Math.min(items.length, MAX_TOTAL_HEIGHT);
const expectedDrawings = [
getBgFillRect(),
...items.map((item, i) => {
const { valueWidth, valueOffset } = item;
const color = expectedColors[i].output;
const fillStyle = `rgba(${color.concat(ITEM_ALPHA).join()})`;
const height = Math.min(MAX_ITEM_HEIGHT, Math.max(MIN_ITEM_HEIGHT, cHeight / items.length));
const width = (valueWidth / totalValueWidth) * getCanvasWidth();
const x = (valueOffset / totalValueWidth) * getCanvasWidth();
const y = (cHeight / items.length) * i;
return { fillStyle, height, width, x, y };
}),
];
const canvas = new Canvas();
const getFillColor = getColorFactory();
renderIntoCanvas(canvas, items, totalValueWidth, getFillColor);
expect(getFillColor.inputOutput).toEqual(expectedColors);
expect(canvas.getContext.mock.calls).toEqual([['2d', { alpha: false }]]);
expect(canvas.contexts.length).toBe(1);
expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawings);
});
});
describe('when there are many items', () => {
it('sets the height when there are many items', () => {
const canvas = new Canvas();
const items = [];
for (let i = 0; i < MIN_TOTAL_HEIGHT + 1; i++) {
items.push(basicItem);
}
expect(canvas.height !== canvas.height).toBe(true);
renderIntoCanvas(canvas, items, 150, getColorFactory());
expect(canvas.height).toBe(items.length);
});
it('draws the map', () => {
const totalValueWidth = 4000;
const items = _range(MIN_TOTAL_HEIGHT * 10).map(i => ({
valueWidth: i,
valueOffset: i,
serviceName: `service-name-${i}`,
}));
const expectedColors = items.map((item, i) => ({
input: item.serviceName,
output: [i, i, i],
}));
const expectedDrawings = [
getBgFillRect(items),
...items.map((item, i) => {
const { valueWidth, valueOffset } = item;
const color = expectedColors[i].output;
const fillStyle = `rgba(${color.concat(ITEM_ALPHA).join()})`;
const height = MIN_ITEM_HEIGHT;
const width = Math.max(MIN_ITEM_WIDTH, (valueWidth / totalValueWidth) * getCanvasWidth());
const x = (valueOffset / totalValueWidth) * getCanvasWidth();
const y = (MAX_TOTAL_HEIGHT / items.length) * i;
return { fillStyle, height, width, x, y };
}),
];
const canvas = new Canvas();
const getFillColor = getColorFactory();
renderIntoCanvas(canvas, items, totalValueWidth, getFillColor);
expect(getFillColor.inputOutput).toEqual(expectedColors);
expect(canvas.getContext.mock.calls).toEqual([['2d', { alpha: false }]]);
expect(canvas.contexts.length).toBe(1);
expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawings);
});
});
});

@ -0,0 +1,62 @@
// 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 '../..';
// exported for tests
export const BG_COLOR = '#fff';
export const ITEM_ALPHA = 0.8;
export const MIN_ITEM_HEIGHT = 2;
export const MAX_TOTAL_HEIGHT = 200;
export const MIN_ITEM_WIDTH = 10;
export const MIN_TOTAL_HEIGHT = 60;
export const MAX_ITEM_HEIGHT = 6;
export default function renderIntoCanvas(
canvas: HTMLCanvasElement,
items: Array<{ valueWidth: number; valueOffset: number; serviceName: string }>,
totalValueWidth: number,
getFillColor: (serviceName: string) => [number, number, number]
) {
const fillCache: Map<string, string | TNil> = new Map();
const cHeight = items.length < MIN_TOTAL_HEIGHT ? MIN_TOTAL_HEIGHT : Math.min(items.length, MAX_TOTAL_HEIGHT);
const cWidth = window.innerWidth * 2;
// eslint-disable-next-line no-param-reassign
canvas.width = cWidth;
// eslint-disable-next-line no-param-reassign
canvas.height = cHeight;
const itemHeight = Math.min(MAX_ITEM_HEIGHT, Math.max(MIN_ITEM_HEIGHT, cHeight / items.length));
const itemYChange = cHeight / items.length;
const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, cWidth, cHeight);
for (let i = 0; i < items.length; i++) {
const { valueWidth, valueOffset, serviceName } = items[i];
const x = (valueOffset / totalValueWidth) * cWidth;
let width = (valueWidth / totalValueWidth) * cWidth;
if (width < MIN_ITEM_WIDTH) {
width = MIN_ITEM_WIDTH;
}
let fillStyle = fillCache.get(serviceName);
if (!fillStyle) {
fillStyle = `rgba(${getFillColor(serviceName)
.concat(ITEM_ALPHA)
.join()})`;
fillCache.set(serviceName, fillStyle);
}
ctx.fillStyle = fillStyle;
ctx.fillRect(x, i * itemYChange, width, itemHeight);
}
}

@ -0,0 +1,80 @@
// 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 SpanGraph from './SpanGraph';
import TracePageHeader, { HEADER_ITEMS } from './TracePageHeader';
import LabeledList from '../common/LabeledList';
import traceGenerator from '../demo/trace-generators';
import { getTraceName } from '../model/trace-viewer';
import transformTraceData from '../model/transform-trace-data';
describe('<TracePageHeader>', () => {
const trace = transformTraceData(traceGenerator.trace({}));
const defaultProps = {
trace,
showArchiveButton: false,
showShortcutsHelp: false,
showStandaloneLink: false,
showViewOptions: false,
textFilter: '',
updateTextFilter: () => {},
};
let wrapper;
beforeEach(() => {
wrapper = shallow(<TracePageHeader {...defaultProps} />);
});
it('renders a <header />', () => {
expect(wrapper.find('header').length).toBe(1);
});
it('renders an empty <div> if a trace is not present', () => {
wrapper = mount(<TracePageHeader {...defaultProps} trace={null} />);
expect(wrapper.children().length).toBe(0);
});
it('renders the trace title', () => {
expect(wrapper.find({ traceName: getTraceName(trace.spans) })).toBeTruthy();
});
it('renders the header items', () => {
wrapper.find('.horizontal .item').forEach((item, i) => {
expect(item.contains(HEADER_ITEMS[i].title)).toBeTruthy();
expect(item.contains(HEADER_ITEMS[i].renderer(defaultProps.trace))).toBeTruthy();
});
});
it('renders a <SpanGraph>', () => {
expect(wrapper.find(SpanGraph).length).toBe(1);
});
describe('observes the visibility toggles for various UX elements', () => {
it('hides the minimap when hideMap === true', () => {
expect(wrapper.find(SpanGraph).length).toBe(1);
wrapper.setProps({ hideMap: true });
expect(wrapper.find(SpanGraph).length).toBe(0);
});
it('hides the summary when hideSummary === true', () => {
expect(wrapper.find(LabeledList).length).toBe(1);
wrapper.setProps({ hideSummary: true });
expect(wrapper.find(LabeledList).length).toBe(0);
});
});
});

@ -0,0 +1,293 @@
// Copyright (c) 2017 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as React from 'react';
import _get from 'lodash/get';
import _maxBy from 'lodash/maxBy';
import _values from 'lodash/values';
import MdKeyboardArrowRight from 'react-icons/lib/md/keyboard-arrow-right';
import { css } from 'emotion';
import cx from 'classnames';
import SpanGraph from './SpanGraph';
import TracePageSearchBar from './TracePageSearchBar';
import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '..';
import LabeledList from '../common/LabeledList';
import TraceName from '../common/TraceName';
import { getTraceName } from '../model/trace-viewer';
import { TNil } from '../types';
import { Trace } from '..';
import { formatDatetime, formatDuration } from '../utils/date';
import { getTraceLinks } from '../model/link-patterns';
import ExternalLinks from '../common/ExternalLinks';
import { createStyle } from '../Theme';
import { uTxMuted } from '../uberUtilityStyles';
const getStyles = createStyle(() => {
const TracePageHeaderOverviewItemValueDetail = css`
label: TracePageHeaderOverviewItemValueDetail;
color: #aaa;
`;
return {
TracePageHeader: css`
label: TracePageHeader;
& > :first-child {
border-bottom: 1px solid #e8e8e8;
}
& > :nth-child(2) {
background-color: #eee;
border-bottom: 1px solid #e4e4e4;
}
& > :last-child {
background-color: #f8f8f8;
border-bottom: 1px solid #ccc;
}
`,
TracePageHeaderTitleRow: css`
label: TracePageHeaderTitleRow;
align-items: center;
background-color: #fff;
display: flex;
`,
TracePageHeaderBack: css`
label: TracePageHeaderBack;
align-items: center;
align-self: stretch;
background-color: #fafafa;
border-bottom: 1px solid #ddd;
border-right: 1px solid #ddd;
color: inherit;
display: flex;
font-size: 1.4rem;
padding: 0 1rem;
margin-bottom: -1px;
&:hover {
background-color: #f0f0f0;
border-color: #ccc;
}
`,
TracePageHeaderTitleLink: css`
label: TracePageHeaderTitleLink;
align-items: center;
color: rgba(0, 0, 0, 0.85);
display: flex;
flex: 1;
&:hover * {
text-decoration: underline;
}
&:hover > *,
&:hover small {
text-decoration: none;
}
`,
TracePageHeaderDetailToggle: css`
label: TracePageHeaderDetailToggle;
font-size: 2.5rem;
transition: transform 0.07s ease-out;
`,
TracePageHeaderDetailToggleExpanded: css`
label: TracePageHeaderDetailToggleExpanded;
transform: rotate(90deg);
`,
TracePageHeaderTitle: css`
label: TracePageHeaderTitle;
color: inherit;
flex: 1;
font-size: 1.7em;
line-height: 1em;
margin: 0 0 0 0.5em;
padding: 0.5em 0;
`,
TracePageHeaderTitleCollapsible: css`
label: TracePageHeaderTitleCollapsible;
margin-left: 0;
`,
TracePageHeaderOverviewItems: css`
label: TracePageHeaderOverviewItems;
border-bottom: 1px solid #e4e4e4;
padding: 0.25rem 0.5rem;
`,
TracePageHeaderOverviewItemValueDetail,
TracePageHeaderOverviewItemValue: css`
label: TracePageHeaderOverviewItemValue;
&:hover > .${TracePageHeaderOverviewItemValueDetail} {
color: unset;
}
`,
TracePageHeaderArchiveIcon: css`
label: TracePageHeaderArchiveIcon;
font-size: 1.78em;
margin-right: 0.15em;
`,
};
});
type TracePageHeaderEmbedProps = {
canCollapse: boolean;
clearSearch: () => void;
focusUiFindMatches: () => void;
hideMap: boolean;
hideSummary: boolean;
nextResult: () => void;
onSlimViewClicked: () => void;
onTraceGraphViewClicked: () => void;
prevResult: () => void;
resultCount: number;
slimView: boolean;
textFilter: string | TNil;
trace: Trace;
traceGraphView: boolean;
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
viewRange: ViewRange;
searchValue: string;
onSearchValueChange: (value: string) => void;
hideSearchButtons?: boolean;
};
export const HEADER_ITEMS = [
{
key: 'timestamp',
label: 'Trace Start',
renderer: (trace: Trace) => {
const styles = getStyles();
const dateStr = formatDatetime(trace.startTime);
const match = dateStr.match(/^(.+)(:\d\d\.\d+)$/);
return match ? (
<span className={styles.TracePageHeaderOverviewItemValue}>
{match[1]}
<span className={styles.TracePageHeaderOverviewItemValueDetail}>{match[2]}</span>
</span>
) : (
dateStr
);
},
},
{
key: 'duration',
label: 'Duration',
renderer: (trace: Trace) => formatDuration(trace.duration),
},
{
key: 'service-count',
label: 'Services',
renderer: (trace: Trace) => new Set(_values(trace.processes).map(p => p.serviceName)).size,
},
{
key: 'depth',
label: 'Depth',
renderer: (trace: Trace) => _get(_maxBy(trace.spans, 'depth'), 'depth', 0) + 1,
},
{
key: 'span-count',
label: 'Total Spans',
renderer: (trace: Trace) => trace.spans.length,
},
];
export default function TracePageHeader(props: TracePageHeaderEmbedProps) {
const {
canCollapse,
clearSearch,
focusUiFindMatches,
hideMap,
hideSummary,
nextResult,
onSlimViewClicked,
prevResult,
resultCount,
slimView,
textFilter,
trace,
traceGraphView,
updateNextViewRangeTime,
updateViewRangeTime,
viewRange,
searchValue,
onSearchValueChange,
hideSearchButtons,
} = props;
if (!trace) {
return null;
}
const links = getTraceLinks(trace);
const summaryItems =
!hideSummary &&
!slimView &&
HEADER_ITEMS.map(item => {
const { renderer, ...rest } = item;
return { ...rest, value: renderer(trace) };
});
const styles = getStyles();
const title = (
<h1 className={cx(styles.TracePageHeaderTitle, canCollapse && styles.TracePageHeaderTitleCollapsible)}>
<TraceName traceName={getTraceName(trace.spans)} />{' '}
<small className={uTxMuted}>{trace.traceID.slice(0, 7)}</small>
</h1>
);
return (
<header className={styles.TracePageHeader}>
<div className={styles.TracePageHeaderTitleRow}>
{links && links.length > 0 && <ExternalLinks links={links} className={styles.TracePageHeaderBack} />}
{canCollapse ? (
<a
className={styles.TracePageHeaderTitleLink}
onClick={onSlimViewClicked}
role="switch"
aria-checked={!slimView}
>
<MdKeyboardArrowRight
className={cx(
styles.TracePageHeaderDetailToggle,
!slimView && styles.TracePageHeaderDetailToggleExpanded
)}
/>
{title}
</a>
) : (
title
)}
<TracePageSearchBar
clearSearch={clearSearch}
focusUiFindMatches={focusUiFindMatches}
nextResult={nextResult}
prevResult={prevResult}
resultCount={resultCount}
textFilter={textFilter}
navigable={!traceGraphView}
searchValue={searchValue}
onSearchValueChange={onSearchValueChange}
hideSearchButtons={hideSearchButtons}
/>
</div>
{summaryItems && <LabeledList className={styles.TracePageHeaderOverviewItems} items={summaryItems} />}
{!hideMap && !slimView && (
<SpanGraph
trace={trace}
viewRange={viewRange}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
/>
)}
</header>
);
}

@ -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 IN_TRACE_SEARCH = 'in-trace-search';

@ -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 React from 'react';
import { shallow } from 'enzyme';
import * as markers from './TracePageSearchBar.markers';
import TracePageSearchBar, { getStyles } from './TracePageSearchBar';
import UiFindInput from '../common/UiFindInput';
const defaultProps = {
forwardedRef: React.createRef(),
navigable: true,
nextResult: () => {},
prevResult: () => {},
resultCount: 0,
textFilter: 'something',
};
describe('<TracePageSearchBar>', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(<TracePageSearchBar {...defaultProps} />);
});
describe('truthy textFilter', () => {
it('renders UiFindInput with correct props', () => {
const renderedUiFindInput = wrapper.find(UiFindInput);
const suffix = shallow(renderedUiFindInput.prop('inputProps').suffix);
expect(renderedUiFindInput.prop('inputProps')).toEqual(
expect.objectContaining({
'data-test': markers.IN_TRACE_SEARCH,
name: 'search',
})
);
expect(suffix.hasClass(getStyles().TracePageSearchBarCount)).toBe(true);
expect(suffix.text()).toBe(String(defaultProps.resultCount));
});
it('renders buttons', () => {
const buttons = wrapper.find('UIButton');
expect(buttons.length).toBe(4);
buttons.forEach(button => {
expect(button.hasClass(getStyles().TracePageSearchBarBtn)).toBe(true);
expect(button.hasClass(getStyles().TracePageSearchBarBtnDisabled)).toBe(false);
expect(button.prop('disabled')).toBe(false);
});
expect(wrapper.find('UIButton[icon="up"]').prop('onClick')).toBe(defaultProps.prevResult);
expect(wrapper.find('UIButton[icon="down"]').prop('onClick')).toBe(defaultProps.nextResult);
expect(wrapper.find('UIButton[icon="close"]').prop('onClick')).toBe(defaultProps.clearSearch);
});
it('hides navigation buttons when not navigable', () => {
wrapper.setProps({ navigable: false });
const button = wrapper.find('UIButton');
expect(button.length).toBe(1);
expect(button.prop('icon')).toBe('close');
});
});
describe('falsy textFilter', () => {
beforeEach(() => {
wrapper.setProps({ textFilter: '' });
});
it('renders UiFindInput with correct props', () => {
expect(wrapper.find(UiFindInput).prop('inputProps').suffix).toBe(null);
});
it('renders buttons', () => {
const buttons = wrapper.find('UIButton');
expect(buttons.length).toBe(4);
buttons.forEach(button => {
expect(button.hasClass(getStyles().TracePageSearchBarBtn)).toBe(true);
expect(button.hasClass(getStyles().TracePageSearchBarBtnDisabled)).toBe(true);
expect(button.prop('disabled')).toBe(true);
});
});
});
});

@ -0,0 +1,144 @@
// 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 * as React from 'react';
import cx from 'classnames';
import IoAndroidLocate from 'react-icons/lib/io/android-locate';
import { css } from 'emotion';
import * as markers from './TracePageSearchBar.markers';
import UiFindInput from '../common/UiFindInput';
import { TNil } from '../types';
import { UIButton, UIInputGroup } from '../uiElementsContext';
import { createStyle } from '../Theme';
import { ubFlexAuto, ubJustifyEnd } from '../uberUtilityStyles';
export const getStyles = createStyle(() => {
return {
TracePageSearchBar: css`
label: TracePageSearchBar;
`,
TracePageSearchBarBar: css`
label: TracePageSearchBarBar;
max-width: 20rem;
transition: max-width 0.5s;
&:focus-within {
max-width: 100%;
}
`,
TracePageSearchBarCount: css`
label: TracePageSearchBarCount;
opacity: 0.6;
`,
TracePageSearchBarBtn: css`
label: TracePageSearchBarBtn;
border-left: none;
transition: 0.2s;
`,
TracePageSearchBarBtnDisabled: css`
label: TracePageSearchBarBtnDisabled;
opacity: 0.5;
`,
TracePageSearchBarLocateBtn: css`
label: TracePageSearchBarLocateBtn;
padding: 1px 8px 4px;
`,
};
});
type TracePageSearchBarProps = {
textFilter: string | TNil;
prevResult: () => void;
nextResult: () => void;
clearSearch: () => void;
focusUiFindMatches: () => void;
resultCount: number;
navigable: boolean;
searchValue: string;
onSearchValueChange: (value: string) => void;
hideSearchButtons?: boolean;
};
export default function TracePageSearchBar(props: TracePageSearchBarProps) {
const {
clearSearch,
focusUiFindMatches,
navigable,
nextResult,
prevResult,
resultCount,
textFilter,
onSearchValueChange,
searchValue,
hideSearchButtons,
} = props;
const styles = getStyles();
const count = textFilter ? <span className={styles.TracePageSearchBarCount}>{resultCount}</span> : null;
const btnClass = cx(styles.TracePageSearchBarBtn, { [styles.TracePageSearchBarBtnDisabled]: !textFilter });
const uiFindInputInputProps = {
'data-test': markers.IN_TRACE_SEARCH,
className: cx(styles.TracePageSearchBarBar, ubFlexAuto),
name: 'search',
suffix: count,
};
return (
<div className={styles.TracePageSearchBar}>
{/* style inline because compact overwrites the display */}
<UIInputGroup className={ubJustifyEnd} compact style={{ display: 'flex' }}>
<UiFindInput onChange={onSearchValueChange} value={searchValue} inputProps={uiFindInputInputProps} />
{!hideSearchButtons && (
<>
{navigable && (
<>
<UIButton
className={cx(btnClass, styles.TracePageSearchBarLocateBtn)}
disabled={!textFilter}
htmlType="button"
onClick={focusUiFindMatches}
>
<IoAndroidLocate />
</UIButton>
<UIButton
className={btnClass}
disabled={!textFilter}
htmlType="button"
icon="up"
onClick={prevResult}
/>
<UIButton
className={btnClass}
disabled={!textFilter}
htmlType="button"
icon="down"
onClick={nextResult}
/>
</>
)}
<UIButton
className={btnClass}
disabled={!textFilter}
htmlType="button"
icon="close"
onClick={clearSearch}
/>
</>
)}
</UIInputGroup>
</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 './TracePageHeader';

@ -101,6 +101,7 @@ type TimelineColumnResizerProps = {
max: number;
onChange: (newSize: number) => void;
position: number;
columnResizeHandleHeight: number;
};
type TimelineColumnResizerState = {
@ -164,8 +165,8 @@ export default class TimelineColumnResizer extends React.PureComponent<
render() {
let left;
let draggerStyle;
const { position } = this.props;
let draggerStyle: React.CSSProperties;
const { position, columnResizeHandleHeight } = this.props;
const { dragPosition } = this.state;
left = `${position * 100}%`;
const gripStyle = { left };
@ -188,6 +189,7 @@ export default class TimelineColumnResizer extends React.PureComponent<
} else {
draggerStyle = gripStyle;
}
draggerStyle.height = columnResizeHandleHeight;
const isDragging = isDraggingLeft || isDraggingRight;
return (

@ -34,6 +34,7 @@ const getStyles = createStyle(() => {
line-height: 38px;
width: 100%;
z-index: 4;
position: relative;
`,
title: css`
flex: 1;
@ -57,6 +58,7 @@ type TimelineHeaderRowProps = {
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
viewRangeTime: ViewRangeTime;
columnResizeHandleHeight: number;
};
export default function TimelineHeaderRow(props: TimelineHeaderRowProps) {
@ -72,6 +74,7 @@ export default function TimelineHeaderRow(props: TimelineHeaderRowProps) {
updateViewRangeTime,
updateNextViewRangeTime,
viewRangeTime,
columnResizeHandleHeight,
} = props;
const [viewStart, viewEnd] = viewRangeTime.current;
const styles = getStyles();
@ -95,7 +98,13 @@ export default function TimelineHeaderRow(props: TimelineHeaderRowProps) {
/>
<Ticks numTicks={numTicks} startTime={viewStart * duration} endTime={viewEnd * duration} showLabels />
</TimelineRow.Cell>
<TimelineColumnResizer position={nameColumnWidth} onChange={onColummWidthChange} min={0.2} max={0.85} />
<TimelineColumnResizer
columnResizeHandleHeight={columnResizeHandleHeight}
position={nameColumnWidth}
onChange={onColummWidthChange}
min={0.2}
max={0.85}
/>
</TimelineRow>
);
}

@ -33,6 +33,7 @@ type TExtractUiFindFromStateReturn = {
const getStyles = createStyle(() => {
return {
TraceTimelineViewer: css`
label: TraceTimelineViewer;
border-bottom: 1px solid #bbb;
& .json-markup {
@ -98,6 +99,11 @@ type TProps = TExtractUiFindFromStateReturn & {
linksGetter: (span: Span, items: KeyValuePair[], itemIndex: number) => Link[];
};
type State = {
// Will be set to real height of the component so it can be passed down to size some other elements.
height: number;
};
const NUM_TICKS = 5;
/**
@ -106,7 +112,12 @@ const NUM_TICKS = 5;
* re-render the ListView every time the cursor is moved on the trace minimap
* or `TimelineHeaderRow`.
*/
export default class TraceTimelineViewer extends React.PureComponent<TProps> {
export default class TraceTimelineViewer extends React.PureComponent<TProps, State> {
constructor(props: TProps) {
super(props);
this.state = { height: 0 };
}
componentDidMount() {
mergeShortcuts({
collapseAll: this.collapseAll,
@ -147,7 +158,10 @@ export default class TraceTimelineViewer extends React.PureComponent<TProps> {
return (
<ExternalLinkContext.Provider value={createLinkToExternalSpan}>
<div className={styles.TraceTimelineViewer}>
<div
className={styles.TraceTimelineViewer}
ref={(ref: HTMLDivElement | null) => ref && this.setState({ height: ref.getBoundingClientRect().height })}
>
<TimelineHeaderRow
duration={trace.duration}
nameColumnWidth={traceTimeline.spanNameColumnWidth}
@ -160,6 +174,7 @@ export default class TraceTimelineViewer extends React.PureComponent<TProps> {
viewRangeTime={viewRange.time}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
columnResizeHandleHeight={this.state.height}
/>
<VirtualizedTraceView
{...rest}

@ -0,0 +1,64 @@
// 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 { createStyle } from '../Theme';
const getStyles = createStyle(() => {
return {
BreakableText: css`
label: BreakableText;
display: inline-block;
white-space: pre;
`,
};
});
const WORD_RX = /\W*\w+\W*/g;
type Props = {
text: string;
className?: string;
wordRegexp?: RegExp;
};
// TODO typescript doesn't understand text or null as react nodes
// https://github.com/Microsoft/TypeScript/issues/21699
export default function BreakableText(
props: Props
): any /* React.ReactNode /* React.ReactElement | React.ReactElement[] \*\/ */ {
const { className, text, wordRegexp = WORD_RX } = props;
if (!text) {
return typeof text === 'string' ? text : null;
}
const spans = [];
wordRegexp.exec('');
// if the given text has no words, set the first match to the entire text
let match: RegExpExecArray | string[] | null = wordRegexp.exec(text) || [text];
while (match) {
spans.push(
<span key={`${text}-${spans.length}`} className={className || getStyles().BreakableText}>
{match[0]}
</span>
);
match = wordRegexp.exec(text);
}
return spans;
}
BreakableText.defaultProps = {
wordRegexp: WORD_RX,
};

@ -0,0 +1,59 @@
// 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 { UIDropdown, UIMenu, UIMenuItem } from '..';
import NewWindowIcon from './NewWindowIcon';
type Link = {
text: string;
url: string;
};
type ExternalLinksProps = {
links: Link[];
className?: string;
};
const LinkValue = (props: { href: string; title?: string; children?: React.ReactNode; className?: string }) => (
<a href={props.href} title={props.title} target="_blank" rel="noopener noreferrer" className={props.className}>
{props.children} <NewWindowIcon />
</a>
);
// export for testing
export 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>
);
export default function ExternalLinks(props: ExternalLinksProps) {
const { links } = props;
if (links.length === 1) {
return <LinkValue href={links[0].url} title={links[0].text} className={props.className} />;
}
return (
<UIDropdown overlay={linkValueList(links)} placement="bottomRight" trigger={['click']}>
<a className={props.className}>
<NewWindowIcon isLarge />
</a>
</UIDropdown>
);
}

@ -0,0 +1,78 @@
// 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 cx from 'classnames';
import { css, keyframes } from 'emotion';
import { createStyle } from '../Theme';
import { UIIcon } from '../uiElementsContext';
const getStyles = createStyle(() => {
const LoadingIndicatorColorAnim = keyframes`
/*
rgb(0, 128, 128) == teal
rgba(0, 128, 128, 0.3) == #bedfdf
*/
from {
color: #bedfdf;
}
to {
color: teal;
}
`;
return {
LoadingIndicator: css`
label: LoadingIndicator;
animation: ${LoadingIndicatorColorAnim} 1s infinite alternate;
font-size: 36px;
/* outline / stroke the loading indicator */
text-shadow: -0.5px 0 rgba(0, 128, 128, 0.6), 0 0.5px rgba(0, 128, 128, 0.6), 0.5px 0 rgba(0, 128, 128, 0.6),
0 -0.5px rgba(0, 128, 128, 0.6);
`,
LoadingIndicatorCentered: css`
label: LoadingIndicatorCentered;
display: block;
margin-left: auto;
margin-right: auto;
`,
LoadingIndicatorSmall: css`
label: LoadingIndicatorSmall;
font-size: 0.7em;
`,
};
});
type LoadingIndicatorProps = {
centered?: boolean;
className?: string;
small?: boolean;
};
export default function LoadingIndicator(props: LoadingIndicatorProps) {
const { centered, className, small, ...rest } = props;
const styles = getStyles();
const cls = cx(styles.LoadingIndicator, {
[styles.LoadingIndicatorCentered]: centered,
[styles.LoadingIndicatorSmall]: small,
className,
});
return <UIIcon type="loading" className={cls} {...rest} />;
}
LoadingIndicator.defaultProps = {
centered: false,
className: undefined,
small: false,
};

@ -0,0 +1,65 @@
// 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 BreakableText from './BreakableText';
import LoadingIndicator from './LoadingIndicator';
import { fetchedState, FALLBACK_TRACE_NAME } from '../constants';
import { FetchedState, TNil } from '../types';
import { ApiError } from '../types/api-error';
import { createStyle } from '../Theme';
const getStyles = createStyle(() => {
return {
TraceNameError: css`
color: #c00;
`,
};
});
type Props = {
className?: string;
error?: ApiError | TNil;
state?: FetchedState | TNil;
traceName?: string | TNil;
};
export default function TraceName(props: Props) {
const { className, error, state, traceName } = props;
const isErred = state === fetchedState.ERROR;
let title: string | React.ReactNode = traceName || FALLBACK_TRACE_NAME;
const styles = getStyles();
let errorCssClass = '';
if (isErred) {
errorCssClass = styles.TraceNameError;
let titleStr = '';
if (error) {
titleStr = typeof error === 'string' ? error : error.message || String(error);
}
if (!titleStr) {
titleStr = 'Error: Unknown error';
}
title = titleStr;
title = <BreakableText text={titleStr} />;
} else if (state === fetchedState.LOADING) {
title = <LoadingIndicator small />;
} else {
const text = String(traceName || FALLBACK_TRACE_NAME);
title = <BreakableText text={text} />;
}
return <span className={`TraceName ${errorCssClass} ${className || ''}`}>{title}</span>;
}

@ -0,0 +1,65 @@
// 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 { shallow } from 'enzyme';
import debounceMock from 'lodash/debounce';
import UiFindInput from './UiFindInput';
import {UIInput} from "../uiElementsContext";
jest.mock('lodash/debounce');
describe('UiFindInput', () => {
const flushMock = jest.fn();
const uiFind = 'uiFind';
const ownInputValue = 'ownInputValue';
const props = {
uiFind: undefined,
history: {
replace: () => {},
},
location: {
search: null,
},
};
let wrapper;
beforeAll(() => {
debounceMock.mockImplementation(fn => {
function debounceFunction(...args) {
fn(...args);
}
debounceFunction.flush = flushMock;
return debounceFunction;
});
});
beforeEach(() => {
flushMock.mockReset();
wrapper = shallow(<UiFindInput {...props} />);
});
describe('rendering', () => {
it('renders as expected', () => {
expect(wrapper).toMatchSnapshot();
});
it('renders props.uiFind when state.ownInputValue is `undefined`', () => {
wrapper.setProps({value: uiFind});
expect(wrapper.find(UIInput).prop('value')).toBe(uiFind);
});
})
});

@ -0,0 +1,66 @@
// 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 { TNil } from '../types/index';
import { UIIcon, UIInput } from '../uiElementsContext';
type Props = {
allowClear?: boolean;
inputProps: Record<string, any>;
location: Location;
match: any;
trackFindFunction?: (str: string | TNil) => void;
value: string | undefined;
onChange: (value: string) => void;
};
export default class UiFindInput extends React.PureComponent<Props> {
static defaultProps: Partial<Props> = {
inputProps: {},
trackFindFunction: undefined,
value: undefined,
};
clearUiFind = () => {
this.props.onChange('');
};
componentWillUnmount(): void {
console.log('unomuet');
}
render() {
const { allowClear, inputProps, value } = this.props;
const suffix = (
<>
{allowClear && value && value.length && <UIIcon type="close" onClick={this.clearUiFind} />}
{inputProps.suffix}
</>
);
return (
<UIInput
autosize={null}
placeholder="Find..."
{...inputProps}
onChange={e => this.props.onChange(e.target.value)}
suffix={suffix}
value={value}
/>
);
}
}

@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UiFindInput rendering renders as expected 1`] = `
<UIInput
autosize={null}
onChange={[Function]}
placeholder="Find..."
suffix={<React.Fragment />}
/>
`;

@ -1,10 +1,12 @@
export { default as TraceTimelineViewer } from './TraceTimelineViewer';
export { default as TracePageHeader } from './TracePageHeader';
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';
export { default as filterSpans } from './utils/filter-spans';
import { onlyUpdateForKeys } from 'recompose';

@ -17,7 +17,7 @@ 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';
import { Span, Link, KeyValuePair, Trace } from '..';
const parameterRegExp = /#\{([^{}]*)\}/g;
@ -36,7 +36,7 @@ type ProcessedLinkPattern = {
parameters: string[];
};
type TLinksRV = { url: string; text: string }[];
type TLinksRV = Array<{ url: string; text: string }>;
function getParamNames(str: string) {
const names = new Set<string>();
@ -143,7 +143,7 @@ function callTemplate(template: ProcessedTemplate, data: any) {
export function computeTraceLink(linkPatterns: ProcessedLinkPattern[], trace: Trace) {
const result: TLinksRV = [];
const validKeys = (Object.keys(trace) as (keyof Trace)[]).filter(
const validKeys = (Object.keys(trace) as Array<keyof Trace>).filter(
key => typeof trace[key] === 'string' || trace[key] === 'number'
);
@ -188,7 +188,7 @@ export function computeLinks(
if (spanTags) {
type = 'tags';
}
const result: { url: string; text: string }[] = [];
const result: Array<{ url: string; text: string }> = [];
linkPatterns.forEach(pattern => {
if (pattern.type(type) && pattern.key(item.key) && pattern.value(item.value)) {
const parameterValues: Record<string, any> = {};
@ -242,7 +242,9 @@ const processedLinks: ProcessedLinkPattern[] = (getConfigValue('linkPatterns') |
export const getTraceLinks: (trace: Trace | undefined) => TLinksRV = memoize(10)((trace: Trace | undefined) => {
const result: TLinksRV = [];
if (!trace) return result;
if (!trace) {
return result;
}
return computeTraceLink(processedLinks, trace);
});

@ -19,7 +19,6 @@ import { Span } from '../types/trace';
* @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,20 @@
// 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';
export function getTraceName(spans: Span[]): string {
const span = spans.filter(sp => !sp.references || !sp.references.length)[0];
return span ? `${span.process.serviceName}: ${span.operationName}` : '';
}

@ -19,7 +19,7 @@
// TODO: Everett Tech Debt: Fix KeyValuePair types
export type KeyValuePair = {
key: string;
type: string;
type?: string;
value: any;
};

@ -22,6 +22,10 @@ export const ubPx2 = css`
padding-right: 0.5rem;
`;
export const ubPb2 = css`
padding-bottom: 0.5rem;
`;
export const ubFlex = css`
display: flex;
`;
@ -55,3 +59,11 @@ export const uTxEllipsis = css`
export const uWidth100 = css`
width: 100%;
`;
export const uTxMuted = css`
color: #aaa;
`;
export const ubJustifyEnd = css`
justify-content: flex-end;
`;

@ -135,6 +135,7 @@ export type ButtonProps = {
htmlType?: ButtonHTMLType;
icon?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
};
export const UIButton = function UIButton(props: ButtonProps) {
@ -162,7 +163,42 @@ export const UIDivider = function UIDivider(props: DividerProps) {
);
};
type Elements = {
export type InputProps = {
autosize?: boolean | null;
placeholder?: string;
onChange: (value: React.ChangeEvent<HTMLInputElement>) => void;
suffix: React.ReactNode;
value?: string;
};
export const UIInput: React.FC<InputProps> = function UIInput(props: InputProps) {
return (
<GetElementsContext>
{(elements: Elements) => {
return <elements.Input {...props} />;
}}
</GetElementsContext>
);
};
export type InputGroupProps = {
className?: string;
compact?: boolean;
style?: React.CSSProperties;
children?: React.ReactNode;
};
export const UIInputGroup = function UIInputGroup(props: InputGroupProps) {
return (
<GetElementsContext>
{(elements: Elements) => {
return <elements.InputGroup {...props} />;
}}
</GetElementsContext>
);
};
export type Elements = {
Popover: React.ComponentType<PopoverProps>;
Tooltip: React.ComponentType<TooltipProps>;
Icon: React.ComponentType<IconProps>;
@ -171,6 +207,8 @@ type Elements = {
MenuItem: React.ComponentType<MenuItemProps>;
Button: React.ComponentType<ButtonProps>;
Divider: React.ComponentType<DividerProps>;
Input: React.ComponentType<InputProps>;
InputGroup: React.ComponentType<InputGroupProps>;
};
/**

@ -0,0 +1,184 @@
// 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 filterSpans from './filter-spans';
describe('filterSpans', () => {
// span0 contains strings that end in 0 or 1
const spanID0 = 'span-id-0';
const span0 = {
spanID: spanID0,
operationName: 'operationName0',
process: {
serviceName: 'serviceName0',
tags: [
{
key: 'processTagKey0',
value: 'processTagValue0',
},
{
key: 'processTagKey1',
value: 'processTagValue1',
},
],
},
tags: [
{
key: 'tagKey0',
value: 'tagValue0',
},
{
key: 'tagKey1',
value: 'tagValue1',
},
],
logs: [
{
fields: [
{
key: 'logFieldKey0',
value: 'logFieldValue0',
},
{
key: 'logFieldKey1',
value: 'logFieldValue1',
},
],
},
],
};
// span2 contains strings that end in 1 or 2, for overlap with span0
// KVs in span2 have different numbers for key and value to facilitate excludesKey testing
const spanID2 = 'span-id-2';
const span2 = {
spanID: spanID2,
operationName: 'operationName2',
process: {
serviceName: 'serviceName2',
tags: [
{
key: 'processTagKey2',
value: 'processTagValue1',
},
{
key: 'processTagKey1',
value: 'processTagValue2',
},
],
},
tags: [
{
key: 'tagKey2',
value: 'tagValue1',
},
{
key: 'tagKey1',
value: 'tagValue2',
},
],
logs: [
{
fields: [
{
key: 'logFieldKey2',
value: 'logFieldValue1',
},
{
key: 'logFieldKey1',
value: 'logFieldValue2',
},
],
},
],
};
const spans = [span0, span2];
it('should return `null` if spans is falsy', () => {
expect(filterSpans('operationName', null)).toBe(null);
});
it('should return spans whose spanID exactly match a filter', () => {
expect(filterSpans('spanID', spans)).toEqual(new Set([]));
expect(filterSpans(spanID0, spans)).toEqual(new Set([spanID0]));
expect(filterSpans(spanID2, spans)).toEqual(new Set([spanID2]));
});
it('should return spans whose operationName match a filter', () => {
expect(filterSpans('operationName', spans)).toEqual(new Set([spanID0, spanID2]));
expect(filterSpans('operationName0', spans)).toEqual(new Set([spanID0]));
expect(filterSpans('operationName2', spans)).toEqual(new Set([spanID2]));
});
it('should return spans whose serviceName match a filter', () => {
expect(filterSpans('serviceName', spans)).toEqual(new Set([spanID0, spanID2]));
expect(filterSpans('serviceName0', spans)).toEqual(new Set([spanID0]));
expect(filterSpans('serviceName2', spans)).toEqual(new Set([spanID2]));
});
it("should return spans whose tags' kv.key match a filter", () => {
expect(filterSpans('tagKey1', spans)).toEqual(new Set([spanID0, spanID2]));
expect(filterSpans('tagKey0', spans)).toEqual(new Set([spanID0]));
expect(filterSpans('tagKey2', spans)).toEqual(new Set([spanID2]));
});
it("should return spans whose tags' kv.value match a filter", () => {
expect(filterSpans('tagValue1', spans)).toEqual(new Set([spanID0, spanID2]));
expect(filterSpans('tagValue0', spans)).toEqual(new Set([spanID0]));
expect(filterSpans('tagValue2', spans)).toEqual(new Set([spanID2]));
});
it("should exclude span whose tags' kv.value or kv.key match a filter if the key matches an excludeKey", () => {
expect(filterSpans('tagValue1 -tagKey2', spans)).toEqual(new Set([spanID0]));
expect(filterSpans('tagValue1 -tagKey1', spans)).toEqual(new Set([spanID2]));
});
it('should return spans whose logs have a field whose kv.key match a filter', () => {
expect(filterSpans('logFieldKey1', spans)).toEqual(new Set([spanID0, spanID2]));
expect(filterSpans('logFieldKey0', spans)).toEqual(new Set([spanID0]));
expect(filterSpans('logFieldKey2', spans)).toEqual(new Set([spanID2]));
});
it('should return spans whose logs have a field whose kv.value match a filter', () => {
expect(filterSpans('logFieldValue1', spans)).toEqual(new Set([spanID0, spanID2]));
expect(filterSpans('logFieldValue0', spans)).toEqual(new Set([spanID0]));
expect(filterSpans('logFieldValue2', spans)).toEqual(new Set([spanID2]));
});
it('should exclude span whose logs have a field whose kv.value or kv.key match a filter if the key matches an excludeKey', () => {
expect(filterSpans('logFieldValue1 -logFieldKey2', spans)).toEqual(new Set([spanID0]));
expect(filterSpans('logFieldValue1 -logFieldKey1', spans)).toEqual(new Set([spanID2]));
});
it("should return spans whose process.tags' kv.key match a filter", () => {
expect(filterSpans('processTagKey1', spans)).toEqual(new Set([spanID0, spanID2]));
expect(filterSpans('processTagKey0', spans)).toEqual(new Set([spanID0]));
expect(filterSpans('processTagKey2', spans)).toEqual(new Set([spanID2]));
});
it("should return spans whose process.processTags' kv.value match a filter", () => {
expect(filterSpans('processTagValue1', spans)).toEqual(new Set([spanID0, spanID2]));
expect(filterSpans('processTagValue0', spans)).toEqual(new Set([spanID0]));
expect(filterSpans('processTagValue2', spans)).toEqual(new Set([spanID2]));
});
it("should exclude span whose process.processTags' kv.value or kv.key match a filter if the key matches an excludeKey", () => {
expect(filterSpans('processTagValue1 -processTagKey2', spans)).toEqual(new Set([spanID0]));
expect(filterSpans('processTagValue1 -processTagKey1', spans)).toEqual(new Set([spanID2]));
});
// This test may false positive if other tests are failing
it('should return an empty set if no spans match the filter', () => {
expect(filterSpans('-processTagKey1', spans)).toEqual(new Set());
});
});

@ -0,0 +1,67 @@
// 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 { KeyValuePair, Span } from '../types/trace';
import { TNil } from '../types';
export default function filterSpans(textFilter: string, spans: Span[] | TNil) {
if (!spans) {
return null;
}
// if a span field includes at least one filter in includeFilters, the span is a match
const includeFilters: string[] = [];
// values with keys that include text in any one of the excludeKeys will be ignored
const excludeKeys: string[] = [];
// split textFilter by whitespace, remove empty strings, and extract includeFilters and excludeKeys
textFilter
.split(/\s+/)
.filter(Boolean)
.forEach(w => {
if (w[0] === '-') {
excludeKeys.push(w.substr(1).toLowerCase());
} else {
includeFilters.push(w.toLowerCase());
}
});
const isTextInFilters = (filters: string[], text: string) =>
filters.some(filter => text.toLowerCase().includes(filter));
const isTextInKeyValues = (kvs: KeyValuePair[]) =>
kvs
? kvs.some(kv => {
// ignore checking key and value for a match if key is in excludeKeys
if (isTextInFilters(excludeKeys, kv.key)) {
return false;
}
// match if key or value matches an item in includeFilters
return isTextInFilters(includeFilters, kv.key) || isTextInFilters(includeFilters, kv.value.toString());
})
: false;
const isSpanAMatch = (span: Span) =>
isTextInFilters(includeFilters, span.operationName) ||
isTextInFilters(includeFilters, span.process.serviceName) ||
isTextInKeyValues(span.tags) ||
span.logs.some(log => isTextInKeyValues(log.fields)) ||
isTextInKeyValues(span.process.tags) ||
includeFilters.some(filter => filter === span.spanID);
// declare as const because need to disambiguate the type
const rv: Set<string> = new Set(spans.filter(isSpanAMatch).map((span: Span) => span.spanID));
return rv;
}

@ -19,7 +19,7 @@ import { toggleGraph } from './state/actions';
import { Provider } from 'react-redux';
import { configureStore } from 'app/store/configureStore';
import { SecondaryActions } from './SecondaryActions';
import { TraceView } from './TraceView';
import { TraceView } from './TraceView/TraceView';
const dummyProps: ExploreProps = {
changeSize: jest.fn(),

@ -59,7 +59,7 @@ import { getTimeZone } from '../profile/state/selectors';
import { ErrorContainer } from './ErrorContainer';
import { scanStopAction } from './state/actionTypes';
import { ExploreGraphPanel } from './ExploreGraphPanel';
import { TraceView } from './TraceView';
import { TraceView } from './TraceView/TraceView';
import { SecondaryActions } from './SecondaryActions';
const getStyles = stylesFactory(() => {
@ -73,18 +73,6 @@ const getStyles = stylesFactory(() => {
button: css`
margin: 1em 4px 0 0;
`,
// Utility class for iframe parents so that we can show iframe content with reasonable height instead of squished
// or some random explicit height.
fullHeight: css`
label: fullHeight;
height: 100%;
`,
iframe: css`
label: iframe;
border: none;
width: 100%;
height: 100%;
`,
};
});
@ -330,22 +318,14 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onClickRichHistoryButton={this.toggleShowRichHistory}
/>
<ErrorContainer queryError={queryError} />
<AutoSizer className={styles.fullHeight} onResize={this.onResize} disableHeight>
<AutoSizer onResize={this.onResize} disableHeight>
{({ width }) => {
if (width === 0) {
return null;
}
return (
<main
className={cx(
styles.logsMain,
// We need height to be 100% for tracing iframe to look good but in case of metrics mode
// it makes graph and table also full page high when they do not need to be.
mode === ExploreMode.Tracing && styles.fullHeight
)}
style={{ width }}
>
<main className={cx(styles.logsMain)} style={{ width }}>
<ErrorBoundaryAlert>
{showStartPage && StartPage && (
<div className={'grafana-info-box grafana-info-box--max-lg'}>

@ -1,182 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TraceView } from './TraceView';
import { SpanData, TraceData, TraceTimelineViewer } from '@jaegertracing/jaeger-ui-components';
describe('TraceView', () => {
it('renders TraceTimelineViewer', () => {
const wrapper = shallow(<TraceView trace={response} />);
expect(wrapper.find(TraceTimelineViewer)).toHaveLength(1);
});
it('toggles detailState', () => {
const wrapper = shallow(<TraceView trace={response} />);
let viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.detailStates.size).toBe(0);
viewer.props().detailToggle('1');
viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.detailStates.size).toBe(1);
expect(viewer.props().traceTimeline.detailStates.get('1')).not.toBeUndefined();
viewer.props().detailToggle('1');
viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.detailStates.size).toBe(0);
});
it('toggles children visibility', () => {
const wrapper = shallow(<TraceView trace={response} />);
let viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
viewer.props().childrenToggle('1');
viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(1);
expect(viewer.props().traceTimeline.childrenHiddenIDs.has('1')).toBeTruthy();
viewer.props().childrenToggle('1');
viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
});
it('toggles adds and removes hover indent guides', () => {
const wrapper = shallow(<TraceView trace={response} />);
let viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.hoverIndentGuideIds.size).toBe(0);
viewer.props().addHoverIndentGuideId('1');
viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.hoverIndentGuideIds.size).toBe(1);
expect(viewer.props().traceTimeline.hoverIndentGuideIds.has('1')).toBeTruthy();
viewer.props().removeHoverIndentGuideId('1');
viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.hoverIndentGuideIds.size).toBe(0);
});
it('toggles collapses and expands one level of spans', () => {
const wrapper = shallow(<TraceView trace={response} />);
let viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
const spans = viewer.props().trace.spans;
viewer.props().collapseOne(spans);
viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(1);
expect(viewer.props().traceTimeline.childrenHiddenIDs.has('3fb050342773d333')).toBeTruthy();
viewer.props().expandOne(spans);
viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
});
it('toggles collapses and expands all levels', () => {
const wrapper = shallow(<TraceView trace={response} />);
let viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
const spans = viewer.props().trace.spans;
viewer.props().collapseAll(spans);
viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(2);
expect(viewer.props().traceTimeline.childrenHiddenIDs.has('3fb050342773d333')).toBeTruthy();
expect(viewer.props().traceTimeline.childrenHiddenIDs.has('1ed38015486087ca')).toBeTruthy();
viewer.props().expandAll();
viewer = wrapper.find(TraceTimelineViewer);
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
});
});
const response: TraceData & { spans: SpanData[] } = {
traceID: '1ed38015486087ca',
spans: [
{
traceID: '1ed38015486087ca',
spanID: '1ed38015486087ca',
flags: 1,
operationName: 'HTTP POST - api_prom_push',
references: [] as any,
startTime: 1585244579835187,
duration: 1098,
tags: [
{ key: 'sampler.type', type: 'string', value: 'const' },
{ key: 'sampler.param', type: 'bool', value: true },
{ key: 'span.kind', type: 'string', value: 'server' },
{ key: 'http.method', type: 'string', value: 'POST' },
{ key: 'http.url', type: 'string', value: '/api/prom/push' },
{ key: 'component', type: 'string', value: 'net/http' },
{ key: 'http.status_code', type: 'int64', value: 204 },
{ key: 'internal.span.format', type: 'string', value: 'proto' },
],
logs: [
{
timestamp: 1585244579835229,
fields: [{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[start reading]' }],
},
{
timestamp: 1585244579835241,
fields: [
{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[decompress]' },
{ key: 'size', type: 'int64', value: 315 },
],
},
{
timestamp: 1585244579835245,
fields: [
{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[unmarshal]' },
{ key: 'size', type: 'int64', value: 446 },
],
},
],
processID: 'p1',
warnings: null as any,
},
{
traceID: '1ed38015486087ca',
spanID: '3fb050342773d333',
flags: 1,
operationName: '/logproto.Pusher/Push',
references: [{ refType: 'CHILD_OF', traceID: '1ed38015486087ca', spanID: '1ed38015486087ca' }],
startTime: 1585244579835341,
duration: 921,
tags: [
{ key: 'span.kind', type: 'string', value: 'client' },
{ key: 'component', type: 'string', value: 'gRPC' },
{ key: 'internal.span.format', type: 'string', value: 'proto' },
],
logs: [],
processID: 'p1',
warnings: null,
},
{
traceID: '1ed38015486087ca',
spanID: '35118c298fc91f68',
flags: 1,
operationName: '/logproto.Pusher/Push',
references: [{ refType: 'CHILD_OF', traceID: '1ed38015486087ca', spanID: '3fb050342773d333' }],
startTime: 1585244579836040,
duration: 36,
tags: [
{ key: 'span.kind', type: 'string', value: 'server' },
{ key: 'component', type: 'string', value: 'gRPC' },
{ key: 'internal.span.format', type: 'string', value: 'proto' },
],
logs: [] as any,
processID: 'p1',
warnings: null as any,
},
],
processes: {
p1: {
serviceName: 'loki-all',
tags: [
{ key: 'client-uuid', type: 'string', value: '2a59d08899ef6a8a' },
{ key: 'hostname', type: 'string', value: '0080b530fae3' },
{ key: 'ip', type: 'string', value: '172.18.0.6' },
{ key: 'jaeger.version', type: 'string', value: 'Go-2.20.1' },
],
},
},
warnings: null as any,
};

@ -1,247 +0,0 @@
import {
DetailState,
KeyValuePair,
Link,
Log,
Span,
// SpanData,
// SpanReference,
Trace,
TraceTimelineViewer,
TTraceTimeline,
UIElementsContext,
ViewRangeTimeUpdate,
transformTraceData,
SpanData,
TraceData,
} from '@jaegertracing/jaeger-ui-components';
import React, { useState } from 'react';
type Props = {
trace: TraceData & { spans: SpanData[] };
};
export function TraceView(props: Props) {
/**
* Track whether details are open per span.
*/
const [detailStates, setDetailStates] = useState(new Map<string, DetailState>());
/**
* Track whether span is collapsed, meaning its children spans are hidden.
*/
const [childrenHiddenIDs, setChildrenHiddenIDs] = useState(new Set<string>());
/**
* For some reason this is used internally to handle hover state of indent guide. As indent guides are separate
* components per each row/span and you need to highlight all in multiple rows to make the effect of single line
* they need this kind of common imperative state changes.
*
* Ideally would be changed to trace view internal state.
*/
const [hoverIndentGuideIds, setHoverIndentGuideIds] = useState(new Set<string>());
/**
* Keeps state of resizable name column
*/
const [spanNameColumnWidth, setSpanNameColumnWidth] = useState(0.25);
function toggleDetail(spanID: string) {
const newDetailStates = new Map(detailStates);
if (newDetailStates.has(spanID)) {
newDetailStates.delete(spanID);
} else {
newDetailStates.set(spanID, new DetailState());
}
setDetailStates(newDetailStates);
}
function expandOne(spans: Span[]) {
if (childrenHiddenIDs.size === 0) {
return;
}
let prevExpandedDepth = -1;
let expandNextHiddenSpan = true;
const newChildrenHiddenIDs = spans.reduce((res, s) => {
if (s.depth <= prevExpandedDepth) {
expandNextHiddenSpan = true;
}
if (expandNextHiddenSpan && res.has(s.spanID)) {
res.delete(s.spanID);
expandNextHiddenSpan = false;
prevExpandedDepth = s.depth;
}
return res;
}, new Set(childrenHiddenIDs));
setChildrenHiddenIDs(newChildrenHiddenIDs);
}
function collapseOne(spans: Span[]) {
if (shouldDisableCollapse(spans, childrenHiddenIDs)) {
return;
}
let nearestCollapsedAncestor: Span | undefined;
const newChildrenHiddenIDs = spans.reduce((res, curSpan) => {
if (nearestCollapsedAncestor && curSpan.depth <= nearestCollapsedAncestor.depth) {
res.add(nearestCollapsedAncestor.spanID);
if (curSpan.hasChildren) {
nearestCollapsedAncestor = curSpan;
}
} else if (curSpan.hasChildren && !res.has(curSpan.spanID)) {
nearestCollapsedAncestor = curSpan;
}
return res;
}, new Set(childrenHiddenIDs));
// The last one
if (nearestCollapsedAncestor) {
newChildrenHiddenIDs.add(nearestCollapsedAncestor.spanID);
}
setChildrenHiddenIDs(newChildrenHiddenIDs);
}
function expandAll() {
setChildrenHiddenIDs(new Set<string>());
}
function collapseAll(spans: Span[]) {
if (shouldDisableCollapse(spans, childrenHiddenIDs)) {
return;
}
const newChildrenHiddenIDs = spans.reduce((res, s) => {
if (s.hasChildren) {
res.add(s.spanID);
}
return res;
}, new Set<string>());
setChildrenHiddenIDs(newChildrenHiddenIDs);
}
function childrenToggle(spanID: string) {
const newChildrenHiddenIDs = new Set(childrenHiddenIDs);
if (childrenHiddenIDs.has(spanID)) {
newChildrenHiddenIDs.delete(spanID);
} else {
newChildrenHiddenIDs.add(spanID);
}
setChildrenHiddenIDs(newChildrenHiddenIDs);
}
function detailLogItemToggle(spanID: string, log: Log) {
const old = detailStates.get(spanID);
if (!old) {
return;
}
const detailState = old.toggleLogItem(log);
const newDetailStates = new Map(detailStates);
newDetailStates.set(spanID, detailState);
return setDetailStates(newDetailStates);
}
function addHoverIndentGuideId(spanID: string) {
setHoverIndentGuideIds(prevState => {
const newHoverIndentGuideIds = new Set(prevState);
newHoverIndentGuideIds.add(spanID);
return newHoverIndentGuideIds;
});
}
function removeHoverIndentGuideId(spanID: string) {
setHoverIndentGuideIds(prevState => {
const newHoverIndentGuideIds = new Set(prevState);
newHoverIndentGuideIds.delete(spanID);
return newHoverIndentGuideIds;
});
}
const traceProp = transformTraceData(props.trace);
return (
<UIElementsContext.Provider
value={{
Popover: (() => null as any) as any,
Tooltip: (() => null as any) as any,
Icon: (() => null as any) as any,
Dropdown: (() => null as any) as any,
Menu: (() => null as any) as any,
MenuItem: (() => null as any) as any,
Button: (() => null as any) as any,
Divider: (() => null as any) as any,
}}
>
<TraceTimelineViewer
registerAccessors={() => {}}
scrollToFirstVisibleSpan={() => {}}
findMatchesIDs={null}
trace={traceProp}
traceTimeline={
{
childrenHiddenIDs,
detailStates,
hoverIndentGuideIds,
shouldScrollToFirstUiFindMatch: false,
spanNameColumnWidth,
traceID: '50b96206cf81dd64',
} as TTraceTimeline
}
updateNextViewRangeTime={(update: ViewRangeTimeUpdate) => {}}
updateViewRangeTime={() => {}}
viewRange={{ time: { current: [0, 1], cursor: null } }}
focusSpan={() => {}}
createLinkToExternalSpan={() => ''}
setSpanNameColumnWidth={setSpanNameColumnWidth}
collapseAll={collapseAll}
collapseOne={collapseOne}
expandAll={expandAll}
expandOne={expandOne}
childrenToggle={childrenToggle}
clearShouldScrollToFirstUiFindMatch={() => {}}
detailLogItemToggle={detailLogItemToggle}
detailLogsToggle={makeDetailSubsectionToggle('logs', detailStates, setDetailStates)}
detailWarningsToggle={makeDetailSubsectionToggle('warnings', detailStates, setDetailStates)}
detailReferencesToggle={makeDetailSubsectionToggle('references', detailStates, setDetailStates)}
detailProcessToggle={makeDetailSubsectionToggle('process', detailStates, setDetailStates)}
detailTagsToggle={makeDetailSubsectionToggle('tags', detailStates, setDetailStates)}
detailToggle={toggleDetail}
setTrace={(trace: Trace | null, uiFind: string | null) => {}}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
linksGetter={(span: Span, items: KeyValuePair[], itemIndex: number) => [] as Link[]}
uiFind={undefined}
/>
</UIElementsContext.Provider>
);
}
function shouldDisableCollapse(allSpans: Span[], hiddenSpansIds: Set<string>) {
const allParentSpans = allSpans.filter(s => s.hasChildren);
return allParentSpans.length === hiddenSpansIds.size;
}
function makeDetailSubsectionToggle(
subSection: 'tags' | 'process' | 'logs' | 'warnings' | 'references',
detailStates: Map<string, DetailState>,
setDetailStates: (detailStates: Map<string, DetailState>) => void
) {
return (spanID: string) => {
const old = detailStates.get(spanID);
if (!old) {
return;
}
let detailState;
if (subSection === 'tags') {
detailState = old.toggleTags();
} else if (subSection === 'process') {
detailState = old.toggleProcess();
} else if (subSection === 'warnings') {
detailState = old.toggleWarnings();
} else if (subSection === 'references') {
detailState = old.toggleReferences();
} else {
detailState = old.toggleLogs();
}
const newDetailStates = new Map(detailStates);
newDetailStates.set(spanID, detailState);
setDetailStates(newDetailStates);
};
}

@ -0,0 +1,216 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TraceView } from './TraceView';
import { SpanData, TraceData, TracePageHeader, TraceTimelineViewer } from '@jaegertracing/jaeger-ui-components';
function renderTraceView() {
const wrapper = shallow(<TraceView trace={response} />);
return {
timeline: wrapper.find(TraceTimelineViewer),
header: wrapper.find(TracePageHeader),
wrapper,
};
}
describe('TraceView', () => {
it('renders TraceTimelineViewer', () => {
const { timeline, header } = renderTraceView();
expect(timeline).toHaveLength(1);
expect(header).toHaveLength(1);
});
it('toggles detailState', () => {
let { timeline, wrapper } = renderTraceView();
expect(timeline.props().traceTimeline.detailStates.size).toBe(0);
timeline.props().detailToggle('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.detailStates.size).toBe(1);
expect(timeline.props().traceTimeline.detailStates.get('1')).not.toBeUndefined();
timeline.props().detailToggle('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.detailStates.size).toBe(0);
});
it('toggles children visibility', () => {
let { timeline, wrapper } = renderTraceView();
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
timeline.props().childrenToggle('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(1);
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('1')).toBeTruthy();
timeline.props().childrenToggle('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
});
it('toggles adds and removes hover indent guides', () => {
let { timeline, wrapper } = renderTraceView();
expect(timeline.props().traceTimeline.hoverIndentGuideIds.size).toBe(0);
timeline.props().addHoverIndentGuideId('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.hoverIndentGuideIds.size).toBe(1);
expect(timeline.props().traceTimeline.hoverIndentGuideIds.has('1')).toBeTruthy();
timeline.props().removeHoverIndentGuideId('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.hoverIndentGuideIds.size).toBe(0);
});
it('toggles collapses and expands one level of spans', () => {
let { timeline, wrapper } = renderTraceView();
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
const spans = timeline.props().trace.spans;
timeline.props().collapseOne(spans);
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(1);
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('3fb050342773d333')).toBeTruthy();
timeline.props().expandOne(spans);
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
});
it('toggles collapses and expands all levels', () => {
let { timeline, wrapper } = renderTraceView();
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
const spans = timeline.props().trace.spans;
timeline.props().collapseAll(spans);
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(2);
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('3fb050342773d333')).toBeTruthy();
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('1ed38015486087ca')).toBeTruthy();
timeline.props().expandAll();
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
});
it('searches for spans', () => {
let { wrapper, header } = renderTraceView();
header.props().onSearchValueChange('HTTP POST - api_prom_push');
const timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().findMatchesIDs.has('1ed38015486087ca')).toBeTruthy();
});
it('change viewRange', () => {
let { header, timeline, wrapper } = renderTraceView();
const defaultRange = { time: { current: [0, 1] } };
expect(timeline.props().viewRange).toEqual(defaultRange);
expect(header.props().viewRange).toEqual(defaultRange);
header.props().updateViewRangeTime(0.2, 0.8);
let newRange = { time: { current: [0.2, 0.8] } };
timeline = wrapper.find(TraceTimelineViewer);
header = wrapper.find(TracePageHeader);
expect(timeline.props().viewRange).toEqual(newRange);
expect(header.props().viewRange).toEqual(newRange);
newRange = { time: { current: [0.3, 0.7] } };
timeline.props().updateViewRangeTime(0.3, 0.7);
timeline = wrapper.find(TraceTimelineViewer);
header = wrapper.find(TracePageHeader);
expect(timeline.props().viewRange).toEqual(newRange);
expect(header.props().viewRange).toEqual(newRange);
});
});
const response: TraceData & { spans: SpanData[] } = {
traceID: '1ed38015486087ca',
spans: [
{
traceID: '1ed38015486087ca',
spanID: '1ed38015486087ca',
flags: 1,
operationName: 'HTTP POST - api_prom_push',
references: [] as any,
startTime: 1585244579835187,
duration: 1098,
tags: [
{ key: 'sampler.type', type: 'string', value: 'const' },
{ key: 'sampler.param', type: 'bool', value: true },
{ key: 'span.kind', type: 'string', value: 'server' },
{ key: 'http.method', type: 'string', value: 'POST' },
{ key: 'http.url', type: 'string', value: '/api/prom/push' },
{ key: 'component', type: 'string', value: 'net/http' },
{ key: 'http.status_code', type: 'int64', value: 204 },
{ key: 'internal.span.format', type: 'string', value: 'proto' },
],
logs: [
{
timestamp: 1585244579835229,
fields: [{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[start reading]' }],
},
{
timestamp: 1585244579835241,
fields: [
{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[decompress]' },
{ key: 'size', type: 'int64', value: 315 },
],
},
{
timestamp: 1585244579835245,
fields: [
{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[unmarshal]' },
{ key: 'size', type: 'int64', value: 446 },
],
},
],
processID: 'p1',
warnings: null as any,
},
{
traceID: '1ed38015486087ca',
spanID: '3fb050342773d333',
flags: 1,
operationName: '/logproto.Pusher/Push',
references: [{ refType: 'CHILD_OF', traceID: '1ed38015486087ca', spanID: '1ed38015486087ca' }],
startTime: 1585244579835341,
duration: 921,
tags: [
{ key: 'span.kind', type: 'string', value: 'client' },
{ key: 'component', type: 'string', value: 'gRPC' },
{ key: 'internal.span.format', type: 'string', value: 'proto' },
],
logs: [],
processID: 'p1',
warnings: null,
},
{
traceID: '1ed38015486087ca',
spanID: '35118c298fc91f68',
flags: 1,
operationName: '/logproto.Pusher/Push',
references: [{ refType: 'CHILD_OF', traceID: '1ed38015486087ca', spanID: '3fb050342773d333' }],
startTime: 1585244579836040,
duration: 36,
tags: [
{ key: 'span.kind', type: 'string', value: 'server' },
{ key: 'component', type: 'string', value: 'gRPC' },
{ key: 'internal.span.format', type: 'string', value: 'proto' },
],
logs: [] as any,
processID: 'p1',
warnings: null as any,
},
],
processes: {
p1: {
serviceName: 'loki-all',
tags: [
{ key: 'client-uuid', type: 'string', value: '2a59d08899ef6a8a' },
{ key: 'hostname', type: 'string', value: '0080b530fae3' },
{ key: 'ip', type: 'string', value: '172.18.0.6' },
{ key: 'jaeger.version', type: 'string', value: 'Go-2.20.1' },
],
},
},
warnings: null as any,
};

@ -0,0 +1,119 @@
import React, { useState } from 'react';
import {
KeyValuePair,
Link,
Span,
Trace,
TraceTimelineViewer,
TTraceTimeline,
UIElementsContext,
transformTraceData,
SpanData,
TraceData,
TracePageHeader,
} from '@jaegertracing/jaeger-ui-components';
import { UIElements } from './uiElements';
import { useViewRange } from './useViewRange';
import { useSearch } from './useSearch';
import { useChildrenState } from './useChildrenState';
import { useDetailState } from './useDetailState';
import { useHoverIndentGuide } from './useHoverIndentGuide';
type Props = {
trace: TraceData & { spans: SpanData[] };
};
export function TraceView(props: Props) {
const { expandOne, collapseOne, childrenToggle, collapseAll, childrenHiddenIDs, expandAll } = useChildrenState();
const {
detailStates,
toggleDetail,
detailLogItemToggle,
detailLogsToggle,
detailProcessToggle,
detailReferencesToggle,
detailTagsToggle,
detailWarningsToggle,
} = useDetailState();
const { removeHoverIndentGuideId, addHoverIndentGuideId, hoverIndentGuideIds } = useHoverIndentGuide();
const { viewRange, updateViewRangeTime, updateNextViewRangeTime } = useViewRange();
/**
* Keeps state of resizable name column width
*/
const [spanNameColumnWidth, setSpanNameColumnWidth] = useState(0.25);
/**
* State of the top minimap, slim means it is collapsed.
*/
const [slim, setSlim] = useState(false);
const traceProp = transformTraceData(props.trace);
const { search, setSearch, spanFindMatches } = useSearch(traceProp?.spans);
return (
<UIElementsContext.Provider value={UIElements}>
<TracePageHeader
canCollapse={true}
clearSearch={() => {}}
focusUiFindMatches={() => {}}
hideMap={false}
hideSummary={false}
nextResult={() => {}}
onSlimViewClicked={() => setSlim(!slim)}
onTraceGraphViewClicked={() => {}}
prevResult={() => {}}
resultCount={0}
slimView={slim}
textFilter={null}
trace={traceProp}
traceGraphView={false}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
viewRange={viewRange}
searchValue={search}
onSearchValueChange={setSearch}
hideSearchButtons={true}
/>
<TraceTimelineViewer
registerAccessors={() => {}}
scrollToFirstVisibleSpan={() => {}}
findMatchesIDs={spanFindMatches}
trace={traceProp}
traceTimeline={
{
childrenHiddenIDs,
detailStates,
hoverIndentGuideIds,
shouldScrollToFirstUiFindMatch: false,
spanNameColumnWidth,
traceID: '50b96206cf81dd64',
} as TTraceTimeline
}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
viewRange={viewRange}
focusSpan={() => {}}
createLinkToExternalSpan={() => ''}
setSpanNameColumnWidth={setSpanNameColumnWidth}
collapseAll={collapseAll}
collapseOne={collapseOne}
expandAll={expandAll}
expandOne={expandOne}
childrenToggle={childrenToggle}
clearShouldScrollToFirstUiFindMatch={() => {}}
detailLogItemToggle={detailLogItemToggle}
detailLogsToggle={detailLogsToggle}
detailWarningsToggle={detailWarningsToggle}
detailReferencesToggle={detailReferencesToggle}
detailProcessToggle={detailProcessToggle}
detailTagsToggle={detailTagsToggle}
detailToggle={toggleDetail}
setTrace={(trace: Trace | null, uiFind: string | null) => {}}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
linksGetter={(span: Span, items: KeyValuePair[], itemIndex: number) => [] as Link[]}
uiFind={search}
/>
</UIElementsContext.Provider>
);
}

@ -0,0 +1,45 @@
import React from 'react';
import { ButtonProps, Elements } from '@jaegertracing/jaeger-ui-components';
import { Button, Input } from '@grafana/ui';
/**
* Right now Jaeger components need some UI elements to be injected. This is to get rid of AntD UI library that was
* used by default.
*/
// This needs to be static to prevent remounting on every render.
export const UIElements: Elements = {
Popover: (() => null as any) as any,
Tooltip: (() => null as any) as any,
Icon: (() => null as any) as any,
Dropdown: (() => null as any) as any,
Menu: (() => null as any) as any,
MenuItem: (() => null as any) as any,
Button: ({ onClick, children, className }: ButtonProps) => (
<Button variant={'secondary'} onClick={onClick} className={className}>
{children}
</Button>
),
Divider,
Input: props => <Input {...props} />,
InputGroup: ({ children, className, style }) => (
<span className={className} style={style}>
{children}
</span>
),
};
function Divider({ className }: { className?: string }) {
return (
<div
style={{
display: 'inline-block',
background: '#e8e8e8',
width: '1px',
height: '0.9em',
margin: '0 8px',
}}
className={className}
/>
);
}

@ -0,0 +1,65 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useChildrenState } from './useChildrenState';
import { Span } from '@jaegertracing/jaeger-ui-components';
describe('useChildrenState', () => {
describe('childrenToggle', () => {
it('toggles children state', async () => {
const { result } = renderHook(() => useChildrenState());
expect(result.current.childrenHiddenIDs.size).toBe(0);
act(() => result.current.childrenToggle('testId'));
expect(result.current.childrenHiddenIDs.size).toBe(1);
expect(result.current.childrenHiddenIDs.has('testId')).toBe(true);
act(() => result.current.childrenToggle('testId'));
expect(result.current.childrenHiddenIDs.size).toBe(0);
});
});
describe('expandAll', () => {
it('expands all', async () => {
const { result } = renderHook(() => useChildrenState());
act(() => result.current.childrenToggle('testId1'));
act(() => result.current.childrenToggle('testId2'));
expect(result.current.childrenHiddenIDs.size).toBe(2);
act(() => result.current.expandAll());
expect(result.current.childrenHiddenIDs.size).toBe(0);
});
});
describe('collapseAll', () => {
it('hides spans that have children', async () => {
const { result } = renderHook(() => useChildrenState());
act(() =>
result.current.collapseAll([
{ spanID: 'span1', hasChildren: true } as Span,
{ spanID: 'span2', hasChildren: false } as Span,
])
);
expect(result.current.childrenHiddenIDs.size).toBe(1);
expect(result.current.childrenHiddenIDs.has('span1')).toBe(true);
});
it('does nothing if already collapsed', async () => {
const { result } = renderHook(() => useChildrenState());
act(() => result.current.childrenToggle('span1'));
act(() =>
result.current.collapseAll([
{ spanID: 'span1', hasChildren: true } as Span,
{ spanID: 'span2', hasChildren: false } as Span,
])
);
expect(result.current.childrenHiddenIDs.size).toBe(1);
expect(result.current.childrenHiddenIDs.has('span1')).toBe(true);
});
});
// Other function are not yet used.
});

@ -0,0 +1,94 @@
import { useState } from 'react';
import { Span } from '@jaegertracing/jaeger-ui-components';
/**
* Children state means whether spans are collapsed or not. Also provides some functions to manipulate that state.
*/
export function useChildrenState() {
const [childrenHiddenIDs, setChildrenHiddenIDs] = useState(new Set<string>());
function expandOne(spans: Span[]) {
if (childrenHiddenIDs.size === 0) {
return;
}
let prevExpandedDepth = -1;
let expandNextHiddenSpan = true;
const newChildrenHiddenIDs = spans.reduce((res, s) => {
if (s.depth <= prevExpandedDepth) {
expandNextHiddenSpan = true;
}
if (expandNextHiddenSpan && res.has(s.spanID)) {
res.delete(s.spanID);
expandNextHiddenSpan = false;
prevExpandedDepth = s.depth;
}
return res;
}, new Set(childrenHiddenIDs));
setChildrenHiddenIDs(newChildrenHiddenIDs);
}
function collapseOne(spans: Span[]) {
if (shouldDisableCollapse(spans, childrenHiddenIDs)) {
return;
}
let nearestCollapsedAncestor: Span | undefined;
const newChildrenHiddenIDs = spans.reduce((res, curSpan) => {
if (nearestCollapsedAncestor && curSpan.depth <= nearestCollapsedAncestor.depth) {
res.add(nearestCollapsedAncestor.spanID);
if (curSpan.hasChildren) {
nearestCollapsedAncestor = curSpan;
}
} else if (curSpan.hasChildren && !res.has(curSpan.spanID)) {
nearestCollapsedAncestor = curSpan;
}
return res;
}, new Set(childrenHiddenIDs));
// The last one
if (nearestCollapsedAncestor) {
newChildrenHiddenIDs.add(nearestCollapsedAncestor.spanID);
}
setChildrenHiddenIDs(newChildrenHiddenIDs);
}
function expandAll() {
setChildrenHiddenIDs(new Set<string>());
}
function collapseAll(spans: Span[]) {
if (shouldDisableCollapse(spans, childrenHiddenIDs)) {
return;
}
const newChildrenHiddenIDs = spans.reduce((res, s) => {
if (s.hasChildren) {
res.add(s.spanID);
}
return res;
}, new Set<string>());
setChildrenHiddenIDs(newChildrenHiddenIDs);
}
function childrenToggle(spanID: string) {
const newChildrenHiddenIDs = new Set(childrenHiddenIDs);
if (childrenHiddenIDs.has(spanID)) {
newChildrenHiddenIDs.delete(spanID);
} else {
newChildrenHiddenIDs.add(spanID);
}
setChildrenHiddenIDs(newChildrenHiddenIDs);
}
return {
childrenHiddenIDs,
expandOne,
collapseOne,
expandAll,
collapseAll,
childrenToggle,
};
}
function shouldDisableCollapse(allSpans: Span[], hiddenSpansIds: Set<string>) {
const allParentSpans = allSpans.filter(s => s.hasChildren);
return allParentSpans.length === hiddenSpansIds.size;
}

@ -0,0 +1,56 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { Log } from '@jaegertracing/jaeger-ui-components';
import { useDetailState } from './useDetailState';
describe('useDetailState', () => {
it('toggles detail', async () => {
const { result } = renderHook(() => useDetailState());
expect(result.current.detailStates.size).toBe(0);
act(() => result.current.toggleDetail('span1'));
expect(result.current.detailStates.size).toBe(1);
expect(result.current.detailStates.has('span1')).toBe(true);
act(() => result.current.toggleDetail('span1'));
expect(result.current.detailStates.size).toBe(0);
});
it('toggles logs and logs items', async () => {
const { result } = renderHook(() => useDetailState());
act(() => result.current.toggleDetail('span1'));
act(() => result.current.detailLogsToggle('span1'));
expect(result.current.detailStates.get('span1').logs.isOpen).toBe(true);
const log = { timestamp: 1 } as Log;
act(() => result.current.detailLogItemToggle('span1', log));
expect(result.current.detailStates.get('span1').logs.openedItems.has(log)).toBe(true);
});
it('toggles warnings', async () => {
const { result } = renderHook(() => useDetailState());
act(() => result.current.toggleDetail('span1'));
act(() => result.current.detailWarningsToggle('span1'));
expect(result.current.detailStates.get('span1').isWarningsOpen).toBe(true);
});
it('toggles references', async () => {
const { result } = renderHook(() => useDetailState());
act(() => result.current.toggleDetail('span1'));
act(() => result.current.detailReferencesToggle('span1'));
expect(result.current.detailStates.get('span1').isReferencesOpen).toBe(true);
});
it('toggles processes', async () => {
const { result } = renderHook(() => useDetailState());
act(() => result.current.toggleDetail('span1'));
act(() => result.current.detailProcessToggle('span1'));
expect(result.current.detailStates.get('span1').isProcessOpen).toBe(true);
});
it('toggles tags', async () => {
const { result } = renderHook(() => useDetailState());
act(() => result.current.toggleDetail('span1'));
act(() => result.current.detailTagsToggle('span1'));
expect(result.current.detailStates.get('span1').isTagsOpen).toBe(true);
});
});

@ -0,0 +1,70 @@
import { useState } from 'react';
import { DetailState, Log } from '@jaegertracing/jaeger-ui-components';
/**
* Keeps state of the span detail. This means whether span details are open but also state of each detail subitem
* like logs or tags.
*/
export function useDetailState() {
const [detailStates, setDetailStates] = useState(new Map<string, DetailState>());
function toggleDetail(spanID: string) {
const newDetailStates = new Map(detailStates);
if (newDetailStates.has(spanID)) {
newDetailStates.delete(spanID);
} else {
newDetailStates.set(spanID, new DetailState());
}
setDetailStates(newDetailStates);
}
function detailLogItemToggle(spanID: string, log: Log) {
const old = detailStates.get(spanID);
if (!old) {
return;
}
const detailState = old.toggleLogItem(log);
const newDetailStates = new Map(detailStates);
newDetailStates.set(spanID, detailState);
return setDetailStates(newDetailStates);
}
return {
detailStates,
toggleDetail,
detailLogItemToggle,
detailLogsToggle: makeDetailSubsectionToggle('logs', detailStates, setDetailStates),
detailWarningsToggle: makeDetailSubsectionToggle('warnings', detailStates, setDetailStates),
detailReferencesToggle: makeDetailSubsectionToggle('references', detailStates, setDetailStates),
detailProcessToggle: makeDetailSubsectionToggle('process', detailStates, setDetailStates),
detailTagsToggle: makeDetailSubsectionToggle('tags', detailStates, setDetailStates),
};
}
function makeDetailSubsectionToggle(
subSection: 'tags' | 'process' | 'logs' | 'warnings' | 'references',
detailStates: Map<string, DetailState>,
setDetailStates: (detailStates: Map<string, DetailState>) => void
) {
return (spanID: string) => {
const old = detailStates.get(spanID);
if (!old) {
return;
}
let detailState;
if (subSection === 'tags') {
detailState = old.toggleTags();
} else if (subSection === 'process') {
detailState = old.toggleProcess();
} else if (subSection === 'warnings') {
detailState = old.toggleWarnings();
} else if (subSection === 'references') {
detailState = old.toggleReferences();
} else {
detailState = old.toggleLogs();
}
const newDetailStates = new Map(detailStates);
newDetailStates.set(spanID, detailState);
setDetailStates(newDetailStates);
};
}

@ -0,0 +1,16 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useHoverIndentGuide } from './useHoverIndentGuide';
describe('useHoverIndentGuide', () => {
it('adds and removes indent guide ids', async () => {
const { result } = renderHook(() => useHoverIndentGuide());
expect(result.current.hoverIndentGuideIds.size).toBe(0);
act(() => result.current.addHoverIndentGuideId('span1'));
expect(result.current.hoverIndentGuideIds.size).toBe(1);
expect(result.current.hoverIndentGuideIds.has('span1')).toBe(true);
act(() => result.current.removeHoverIndentGuideId('span1'));
expect(result.current.hoverIndentGuideIds.size).toBe(0);
});
});

@ -0,0 +1,30 @@
import { useState } from 'react';
/**
* This is used internally to handle hover state of indent guide. As indent guides are separate
* components per each row/span and you need to highlight all in multiple rows to make the effect of single line
* they need this kind of common imperative state changes.
*
* Ideally would be changed to trace view internal state.
*/
export function useHoverIndentGuide() {
const [hoverIndentGuideIds, setHoverIndentGuideIds] = useState(new Set<string>());
function addHoverIndentGuideId(spanID: string) {
setHoverIndentGuideIds(prevState => {
const newHoverIndentGuideIds = new Set(prevState);
newHoverIndentGuideIds.add(spanID);
return newHoverIndentGuideIds;
});
}
function removeHoverIndentGuideId(spanID: string) {
setHoverIndentGuideIds(prevState => {
const newHoverIndentGuideIds = new Set(prevState);
newHoverIndentGuideIds.delete(spanID);
return newHoverIndentGuideIds;
});
}
return { hoverIndentGuideIds, addHoverIndentGuideId, removeHoverIndentGuideId };
}

@ -0,0 +1,44 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useSearch } from './useSearch';
import { Span } from '@jaegertracing/jaeger-ui-components';
describe('useSearch', () => {
it('returns matching span IDs', async () => {
const { result } = renderHook(() =>
useSearch([
{
spanID: 'span1',
operationName: 'operation1',
process: {
serviceName: 'service1',
tags: [],
},
tags: [],
logs: [],
} as Span,
{
spanID: 'span2',
operationName: 'operation2',
process: {
serviceName: 'service2',
tags: [],
},
tags: [],
logs: [],
} as Span,
])
);
act(() => result.current.setSearch('service1'));
expect(result.current.spanFindMatches.size).toBe(1);
expect(result.current.spanFindMatches.has('span1')).toBe(true);
});
it('works without spans', async () => {
const { result } = renderHook(() => useSearch());
act(() => result.current.setSearch('service1'));
expect(result.current.spanFindMatches).toBe(undefined);
});
});

@ -0,0 +1,15 @@
import { useState } from 'react';
import { Span, filterSpans } from '@jaegertracing/jaeger-ui-components';
/**
* Controls the state of search input that highlights spans if they match the search string.
* @param spans
*/
export function useSearch(spans?: Span[]) {
const [search, setSearch] = useState('');
let spanFindMatches: Set<string> | undefined;
if (search && spans) {
spanFindMatches = filterSpans(search, spans);
}
return { search, setSearch, spanFindMatches };
}

@ -0,0 +1,25 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useViewRange } from './useViewRange';
describe('useViewRange', () => {
it('defaults to full range', async () => {
const { result } = renderHook(() => useViewRange());
expect(result.current.viewRange).toEqual({ time: { current: [0, 1] } });
});
describe('updateNextViewRangeTime', () => {
it('updates time', async () => {
const { result } = renderHook(() => useViewRange());
act(() => result.current.updateNextViewRangeTime({ cursor: 0.5 }));
expect(result.current.viewRange).toEqual({ time: { current: [0, 1], cursor: 0.5 } });
});
});
describe('updateViewRangeTime', () => {
it('updates time', async () => {
const { result } = renderHook(() => useViewRange());
act(() => result.current.updateViewRangeTime(0.1, 0.2));
expect(result.current.viewRange).toEqual({ time: { current: [0.1, 0.2] } });
});
});
});

@ -0,0 +1,35 @@
import { useState } from 'react';
import { ViewRangeTimeUpdate, ViewRange } from '@jaegertracing/jaeger-ui-components';
/**
* Controls state of the zoom function that can be used through minimap in header or on the timeline. ViewRange contains
* state not only for current range that is showing but range that is currently being selected by the user.
*/
export function useViewRange() {
const [viewRange, setViewRange] = useState<ViewRange>({
time: {
current: [0, 1],
},
});
function updateNextViewRangeTime(update: ViewRangeTimeUpdate) {
setViewRange(
(prevRange): ViewRange => {
const time = { ...prevRange.time, ...update };
return { ...prevRange, time };
}
);
}
function updateViewRangeTime(start: number, end: number) {
const current: [number, number] = [start, end];
const time = { current };
setViewRange(
(prevRange): ViewRange => {
return { ...prevRange, time };
}
);
}
return { viewRange, updateViewRangeTime, updateNextViewRangeTime };
}

@ -25,7 +25,7 @@ export class Wrapper extends Component<WrapperProps> {
return (
<div className="page-scrollbar-wrapper">
<CustomScrollbar autoHeightMin={'100%'} autoHeightMax={''} className="custom-scrollbar--page">
<div style={{ height: '100%' }} className="explore-wrapper">
<div className="explore-wrapper">
<ErrorBoundaryAlert style="page">
<Explore exploreId={ExploreId.left} />
</ErrorBoundaryAlert>

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save