mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
d04dce6a37
commit
008bee8f27
17
conf/provisioning/datasources/loki_test.yaml
Normal file
17
conf/provisioning/datasources/loki_test.yaml
Normal file
@ -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>
|
||||
);
|
||||
}
|
15
packages/jaeger-ui-components/src/TracePageHeader/index.tsx
Normal file
15
packages/jaeger-ui-components/src/TracePageHeader/index.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export { default } from './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}
|
||||
|
64
packages/jaeger-ui-components/src/common/BreakableText.tsx
Normal file
64
packages/jaeger-ui-components/src/common/BreakableText.tsx
Normal file
@ -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,
|
||||
};
|
59
packages/jaeger-ui-components/src/common/ExternalLinks.tsx
Normal file
59
packages/jaeger-ui-components/src/common/ExternalLinks.tsx
Normal file
@ -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,
|
||||
};
|
65
packages/jaeger-ui-components/src/common/TraceName.tsx
Normal file
65
packages/jaeger-ui-components/src/common/TraceName.tsx
Normal file
@ -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>;
|
||||
}
|
65
packages/jaeger-ui-components/src/common/UiFindInput.test.js
Normal file
65
packages/jaeger-ui-components/src/common/UiFindInput.test.js
Normal file
@ -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);
|
||||
});
|
||||
})
|
||||
});
|
66
packages/jaeger-ui-components/src/common/UiFindInput.tsx
Normal file
66
packages/jaeger-ui-components/src/common/UiFindInput.tsx
Normal file
@ -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;
|
||||
|
20
packages/jaeger-ui-components/src/model/trace-viewer.ts
Normal file
20
packages/jaeger-ui-components/src/model/trace-viewer.ts
Normal file
@ -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>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
184
packages/jaeger-ui-components/src/utils/filter-spans.test.js
Normal file
184
packages/jaeger-ui-components/src/utils/filter-spans.test.js
Normal file
@ -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());
|
||||
});
|
||||
});
|
67
packages/jaeger-ui-components/src/utils/filter-spans.tsx
Normal file
67
packages/jaeger-ui-components/src/utils/filter-spans.tsx
Normal file
@ -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);
|
||||
};
|
||||
}
|
216
public/app/features/explore/TraceView/TraceView.test.tsx
Normal file
216
public/app/features/explore/TraceView/TraceView.test.tsx
Normal file
@ -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,
|
||||
};
|
119
public/app/features/explore/TraceView/TraceView.tsx
Normal file
119
public/app/features/explore/TraceView/TraceView.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
45
public/app/features/explore/TraceView/uiElements.tsx
Normal file
45
public/app/features/explore/TraceView/uiElements.tsx
Normal file
@ -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.
|
||||
});
|
94
public/app/features/explore/TraceView/useChildrenState.ts
Normal file
94
public/app/features/explore/TraceView/useChildrenState.ts
Normal file
@ -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;
|
||||
}
|
56
public/app/features/explore/TraceView/useDetailState.test.ts
Normal file
56
public/app/features/explore/TraceView/useDetailState.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
70
public/app/features/explore/TraceView/useDetailState.ts
Normal file
70
public/app/features/explore/TraceView/useDetailState.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
30
public/app/features/explore/TraceView/useHoverIndentGuide.ts
Normal file
30
public/app/features/explore/TraceView/useHoverIndentGuide.ts
Normal file
@ -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 };
|
||||
}
|
44
public/app/features/explore/TraceView/useSearch.test.ts
Normal file
44
public/app/features/explore/TraceView/useSearch.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
15
public/app/features/explore/TraceView/useSearch.ts
Normal file
15
public/app/features/explore/TraceView/useSearch.ts
Normal file
@ -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 };
|
||||
}
|
25
public/app/features/explore/TraceView/useViewRange.test.ts
Normal file
25
public/app/features/explore/TraceView/useViewRange.test.ts
Normal file
@ -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] } });
|
||||
});
|
||||
});
|
||||
});
|
35
public/app/features/explore/TraceView/useViewRange.ts
Normal file
35
public/app/features/explore/TraceView/useViewRange.ts
Normal file
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user