mirror of https://github.com/grafana/grafana
Tracing: Adds header and minimap (#23315)
* Add integration with Jeager Add Jaeger datasource and modify derived fields in loki to allow for opening a trace in Jager in separate split. Modifies build so that this branch docker images are pushed to docker hub Add a traceui dir with docker-compose and provision files for demoing.:wq * Enable docker logger plugin to send logs to loki * Add placeholder zipkin datasource * Fixed rebase issues, added enhanceDataFrame to non-legacy code path * Trace selector for jaeger query field * Fix logs default mode for Loki * Fix loading jaeger query field services on split * Updated grafana image in traceui/compose file * Fix prettier error * Hide behind feature flag, clean up unused code. * Fix tests * Fix tests * Cleanup code and review feedback * Remove traceui directory * Remove circle build changes * Fix feature toggles object * Fix merge issues * Add trace ui in Explore * WIP * WIP * WIP * Make jaeger datasource return trace data instead of link * Allow js in jest tests * Return data from Jaeger datasource * Take yarn.lock from master * Fix missing component * Update yarn lock * Fix some ts and lint errors * Fix merge * Fix type errors * Make tests pass again * Add tests * Fix es5 compatibility * Add header with minimap * Fix sizing issue due to column resizer handle * Fix issues with sizing, search functionality, duplicate react, tests * Refactor TraceView component, fix tests * Fix type errors * Add tests for hooks Co-authored-by: David Kaltschmidt <david.kaltschmidt@gmail.com>pull/23441/head
parent
d04dce6a37
commit
008bee8f27
@ -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" |
||||
|
||||
|
@ -0,0 +1,32 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
|
||||
import CanvasSpanGraph from './CanvasSpanGraph'; |
||||
|
||||
describe('<CanvasSpanGraph>', () => { |
||||
it('renders without exploding', () => { |
||||
const items = [{ valueWidth: 1, valueOffset: 1, serviceName: 'service-name-0' }]; |
||||
const wrapper = shallow(<CanvasSpanGraph items={[]} valueWidth={4000} />); |
||||
expect(wrapper).toBeDefined(); |
||||
wrapper.instance()._setCanvasRef({ |
||||
getContext: () => ({ |
||||
fillRect: () => {}, |
||||
}), |
||||
}); |
||||
wrapper.setProps({ items }); |
||||
}); |
||||
}); |
@ -0,0 +1,73 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react'; |
||||
import { css } from 'emotion'; |
||||
|
||||
import renderIntoCanvas from './render-into-canvas'; |
||||
import colorGenerator from '../../utils/color-generator'; |
||||
import { TNil } from '../../types'; |
||||
|
||||
import { createStyle } from '../../Theme'; |
||||
|
||||
const getStyles = createStyle(() => { |
||||
return { |
||||
CanvasSpanGraph: css` |
||||
label: CanvasSpanGraph; |
||||
background: #fafafa; |
||||
height: 60px; |
||||
position: absolute; |
||||
width: 100%; |
||||
`,
|
||||
}; |
||||
}); |
||||
|
||||
type CanvasSpanGraphProps = { |
||||
items: Array<{ valueWidth: number; valueOffset: number; serviceName: string }>; |
||||
valueWidth: number; |
||||
}; |
||||
|
||||
const getColor = (hex: string) => colorGenerator.getRgbColorByKey(hex); |
||||
|
||||
export default class CanvasSpanGraph extends React.PureComponent<CanvasSpanGraphProps> { |
||||
_canvasElm: HTMLCanvasElement | TNil; |
||||
|
||||
constructor(props: CanvasSpanGraphProps) { |
||||
super(props); |
||||
this._canvasElm = undefined; |
||||
} |
||||
|
||||
componentDidMount() { |
||||
this._draw(); |
||||
} |
||||
|
||||
componentDidUpdate() { |
||||
this._draw(); |
||||
} |
||||
|
||||
_setCanvasRef = (elm: HTMLCanvasElement | TNil) => { |
||||
this._canvasElm = elm; |
||||
}; |
||||
|
||||
_draw() { |
||||
if (this._canvasElm) { |
||||
const { valueWidth: totalValueWidth, items } = this.props; |
||||
renderIntoCanvas(this._canvasElm, items, totalValueWidth, getColor); |
||||
} |
||||
} |
||||
|
||||
render() { |
||||
return <canvas className={getStyles().CanvasSpanGraph} ref={this._setCanvasRef} />; |
||||
} |
||||
} |
@ -0,0 +1,44 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
|
||||
import GraphTicks from './GraphTicks'; |
||||
|
||||
describe('<GraphTicks>', () => { |
||||
const defaultProps = { |
||||
items: [ |
||||
{ valueWidth: 100, valueOffset: 25, serviceName: 'a' }, |
||||
{ valueWidth: 100, valueOffset: 50, serviceName: 'b' }, |
||||
], |
||||
valueWidth: 200, |
||||
numTicks: 4, |
||||
}; |
||||
|
||||
let ticksG; |
||||
|
||||
beforeEach(() => { |
||||
const wrapper = shallow(<GraphTicks {...defaultProps} />); |
||||
ticksG = wrapper.find('[data-test="ticks"]'); |
||||
}); |
||||
|
||||
it('creates a <g> for ticks', () => { |
||||
expect(ticksG.length).toBe(1); |
||||
}); |
||||
|
||||
it('creates a line for each ticks excluding the first and last', () => { |
||||
expect(ticksG.find('line').length).toBe(defaultProps.numTicks - 1); |
||||
}); |
||||
}); |
@ -0,0 +1,47 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react'; |
||||
import { css } from 'emotion'; |
||||
import { createStyle } from '../../Theme'; |
||||
|
||||
const getStyles = createStyle(() => { |
||||
return { |
||||
GraphTick: css` |
||||
label: GraphTick; |
||||
stroke: #aaa; |
||||
stroke-width: 1px; |
||||
`,
|
||||
}; |
||||
}); |
||||
|
||||
type GraphTicksProps = { |
||||
numTicks: number; |
||||
}; |
||||
|
||||
export default function GraphTicks(props: GraphTicksProps) { |
||||
const { numTicks } = props; |
||||
const ticks = []; |
||||
// i starts at 1, limit is `i < numTicks` so the first and last ticks aren't drawn
|
||||
for (let i = 1; i < numTicks; i++) { |
||||
const x = `${(i / numTicks) * 100}%`; |
||||
ticks.push(<line className={getStyles().GraphTick} x1={x} y1="0%" x2={x} y2="100%" key={i / numTicks} />); |
||||
} |
||||
|
||||
return ( |
||||
<g data-test="ticks" aria-hidden="true"> |
||||
{ticks} |
||||
</g> |
||||
); |
||||
} |
@ -0,0 +1,62 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
import sinon from 'sinon'; |
||||
|
||||
import Scrubber, { getStyles } from './Scrubber'; |
||||
|
||||
describe('<Scrubber>', () => { |
||||
const defaultProps = { |
||||
onMouseDown: sinon.spy(), |
||||
position: 0, |
||||
}; |
||||
|
||||
let wrapper; |
||||
|
||||
beforeEach(() => { |
||||
wrapper = shallow(<Scrubber {...defaultProps} />); |
||||
}); |
||||
|
||||
it('contains the proper svg components', () => { |
||||
const styles = getStyles(); |
||||
expect( |
||||
wrapper.matchesElement( |
||||
<g> |
||||
<g className={styles.ScrubberHandles}> |
||||
<rect className={styles.ScrubberHandleExpansion} /> |
||||
<rect className={styles.ScrubberHandle} /> |
||||
</g> |
||||
<line className={styles.ScrubberLine} /> |
||||
</g> |
||||
) |
||||
).toBeTruthy(); |
||||
}); |
||||
|
||||
it('calculates the correct x% for a timestamp', () => { |
||||
wrapper = shallow(<Scrubber {...defaultProps} position={0.5} />); |
||||
const line = wrapper.find('line').first(); |
||||
const rect = wrapper.find('rect').first(); |
||||
expect(line.prop('x1')).toBe('50%'); |
||||
expect(line.prop('x2')).toBe('50%'); |
||||
expect(rect.prop('x')).toBe('50%'); |
||||
}); |
||||
|
||||
it('supports onMouseDown', () => { |
||||
const event = {}; |
||||
wrapper.find(`.${getStyles().ScrubberHandles}`).prop('onMouseDown')(event); |
||||
expect(defaultProps.onMouseDown.calledWith(event)).toBeTruthy(); |
||||
}); |
||||
}); |
@ -0,0 +1,108 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react'; |
||||
import cx from 'classnames'; |
||||
|
||||
import { createStyle } from '../../Theme'; |
||||
import { css } from 'emotion'; |
||||
|
||||
export const getStyles = createStyle(() => { |
||||
const ScrubberHandleExpansion = css` |
||||
label: ScrubberHandleExpansion; |
||||
cursor: col-resize; |
||||
fill-opacity: 0; |
||||
fill: #44f; |
||||
`;
|
||||
const ScrubberHandle = css` |
||||
label: ScrubberHandle; |
||||
cursor: col-resize; |
||||
fill: #555; |
||||
`;
|
||||
const ScrubberLine = css` |
||||
label: ScrubberLine; |
||||
pointer-events: none; |
||||
stroke: #555; |
||||
`;
|
||||
return { |
||||
ScrubberDragging: css` |
||||
label: ScrubberDragging; |
||||
& .${ScrubberHandleExpansion} { |
||||
fill-opacity: 1; |
||||
} |
||||
& .${ScrubberHandle} { |
||||
fill: #44f; |
||||
} |
||||
& > .${ScrubberLine} { |
||||
stroke: #44f; |
||||
} |
||||
`,
|
||||
ScrubberHandles: css` |
||||
label: ScrubberHandles; |
||||
&:hover > .${ScrubberHandleExpansion} { |
||||
fill-opacity: 1; |
||||
} |
||||
&:hover > .${ScrubberHandle} { |
||||
fill: #44f; |
||||
} |
||||
&:hover + .${ScrubberLine} { |
||||
stroke: #44f; |
||||
} |
||||
`,
|
||||
ScrubberHandleExpansion, |
||||
ScrubberHandle, |
||||
ScrubberLine, |
||||
}; |
||||
}); |
||||
|
||||
type ScrubberProps = { |
||||
isDragging: boolean; |
||||
position: number; |
||||
onMouseDown: (evt: React.MouseEvent<any>) => void; |
||||
onMouseEnter: (evt: React.MouseEvent<any>) => void; |
||||
onMouseLeave: (evt: React.MouseEvent<any>) => void; |
||||
}; |
||||
|
||||
export default function Scrubber({ isDragging, onMouseDown, onMouseEnter, onMouseLeave, position }: ScrubberProps) { |
||||
const xPercent = `${position * 100}%`; |
||||
const styles = getStyles(); |
||||
const className = cx({ [styles.ScrubberDragging]: isDragging }); |
||||
return ( |
||||
<g className={className}> |
||||
<g |
||||
className={styles.ScrubberHandles} |
||||
onMouseDown={onMouseDown} |
||||
onMouseEnter={onMouseEnter} |
||||
onMouseLeave={onMouseLeave} |
||||
> |
||||
{/* handleExpansion is only visible when `isDragging` is true */} |
||||
<rect |
||||
x={xPercent} |
||||
className={styles.ScrubberHandleExpansion} |
||||
style={{ transform: `translate(-4.5px)` }} |
||||
width="9" |
||||
height="20" |
||||
/> |
||||
<rect |
||||
x={xPercent} |
||||
className={styles.ScrubberHandle} |
||||
style={{ transform: `translate(-1.5px)` }} |
||||
width="3" |
||||
height="20" |
||||
/> |
||||
</g> |
||||
<line className={styles.ScrubberLine} y2="100%" x1={xPercent} x2={xPercent} /> |
||||
</g> |
||||
); |
||||
} |
@ -0,0 +1,59 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
|
||||
import TickLabels from './TickLabels'; |
||||
|
||||
describe('<TickLabels>', () => { |
||||
const defaultProps = { |
||||
numTicks: 4, |
||||
duration: 5000, |
||||
}; |
||||
|
||||
let wrapper; |
||||
let ticks; |
||||
|
||||
beforeEach(() => { |
||||
wrapper = shallow(<TickLabels {...defaultProps} />); |
||||
ticks = wrapper.find('[data-test="tick"]'); |
||||
}); |
||||
|
||||
it('renders the right number of ticks', () => { |
||||
expect(ticks.length).toBe(defaultProps.numTicks + 1); |
||||
}); |
||||
|
||||
it('places the first tick on the left', () => { |
||||
const firstTick = ticks.first(); |
||||
expect(firstTick.prop('style')).toEqual({ left: '0%' }); |
||||
}); |
||||
|
||||
it('places the last tick on the right', () => { |
||||
const lastTick = ticks.last(); |
||||
expect(lastTick.prop('style')).toEqual({ right: '0%' }); |
||||
}); |
||||
|
||||
it('places middle ticks at proper intervals', () => { |
||||
const positions = ['25%', '50%', '75%']; |
||||
positions.forEach((pos, i) => { |
||||
const tick = ticks.at(i + 1); |
||||
expect(tick.prop('style')).toEqual({ left: pos }); |
||||
}); |
||||
}); |
||||
|
||||
it("doesn't explode if no trace is present", () => { |
||||
expect(() => shallow(<TickLabels {...defaultProps} trace={null} />)).not.toThrow(); |
||||
}); |
||||
}); |
@ -0,0 +1,60 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react'; |
||||
|
||||
import { formatDuration } from '../../utils/date'; |
||||
|
||||
import { createStyle } from '../../Theme'; |
||||
import { css } from 'emotion'; |
||||
|
||||
const getStyles = createStyle(() => { |
||||
return { |
||||
TickLabels: css` |
||||
label: TickLabels; |
||||
height: 1rem; |
||||
position: relative; |
||||
`,
|
||||
TickLabelsLabel: css` |
||||
label: TickLabelsLabel; |
||||
color: #717171; |
||||
font-size: 0.7rem; |
||||
position: absolute; |
||||
user-select: none; |
||||
`,
|
||||
}; |
||||
}); |
||||
|
||||
type TickLabelsProps = { |
||||
numTicks: number; |
||||
duration: number; |
||||
}; |
||||
|
||||
export default function TickLabels(props: TickLabelsProps) { |
||||
const { numTicks, duration } = props; |
||||
const styles = getStyles(); |
||||
|
||||
const ticks = []; |
||||
for (let i = 0; i < numTicks + 1; i++) { |
||||
const portion = i / numTicks; |
||||
const style = portion === 1 ? { right: '0%' } : { left: `${portion * 100}%` }; |
||||
ticks.push( |
||||
<div key={portion} className={styles.TickLabelsLabel} style={style} data-test="tick"> |
||||
{formatDuration(duration * portion)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return <div className={styles.TickLabels}>{ticks}</div>; |
||||
} |
@ -0,0 +1,328 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { shallow } from 'enzyme'; |
||||
import React from 'react'; |
||||
|
||||
import GraphTicks from './GraphTicks'; |
||||
import Scrubber from './Scrubber'; |
||||
import ViewingLayer, { dragTypes, getStyles } from './ViewingLayer'; |
||||
import { EUpdateTypes } from '../../utils/DraggableManager'; |
||||
import { polyfill as polyfillAnimationFrame } from '../../utils/test/requestAnimationFrame'; |
||||
|
||||
function getViewRange(viewStart, viewEnd) { |
||||
return { |
||||
time: { |
||||
current: [viewStart, viewEnd], |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
describe('<SpanGraph>', () => { |
||||
polyfillAnimationFrame(window); |
||||
|
||||
let props; |
||||
let wrapper; |
||||
|
||||
beforeEach(() => { |
||||
props = { |
||||
height: 60, |
||||
numTicks: 5, |
||||
updateNextViewRangeTime: jest.fn(), |
||||
updateViewRangeTime: jest.fn(), |
||||
viewRange: getViewRange(0, 1), |
||||
}; |
||||
wrapper = shallow(<ViewingLayer {...props} />); |
||||
}); |
||||
|
||||
describe('_getDraggingBounds()', () => { |
||||
beforeEach(() => { |
||||
props = { ...props, viewRange: getViewRange(0.1, 0.9) }; |
||||
wrapper = shallow(<ViewingLayer {...props} />); |
||||
wrapper.instance()._setRoot({ |
||||
getBoundingClientRect() { |
||||
return { left: 10, width: 100 }; |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('throws if _root is not set', () => { |
||||
const instance = wrapper.instance(); |
||||
instance._root = null; |
||||
expect(() => instance._getDraggingBounds(dragTypes.REFRAME)).toThrow(); |
||||
}); |
||||
|
||||
it('returns the correct bounds for reframe', () => { |
||||
const bounds = wrapper.instance()._getDraggingBounds(dragTypes.REFRAME); |
||||
expect(bounds).toEqual({ |
||||
clientXLeft: 10, |
||||
width: 100, |
||||
maxValue: 1, |
||||
minValue: 0, |
||||
}); |
||||
}); |
||||
|
||||
it('returns the correct bounds for shiftStart', () => { |
||||
const bounds = wrapper.instance()._getDraggingBounds(dragTypes.SHIFT_START); |
||||
expect(bounds).toEqual({ |
||||
clientXLeft: 10, |
||||
width: 100, |
||||
maxValue: 0.9, |
||||
minValue: 0, |
||||
}); |
||||
}); |
||||
|
||||
it('returns the correct bounds for shiftEnd', () => { |
||||
const bounds = wrapper.instance()._getDraggingBounds(dragTypes.SHIFT_END); |
||||
expect(bounds).toEqual({ |
||||
clientXLeft: 10, |
||||
width: 100, |
||||
maxValue: 1, |
||||
minValue: 0.1, |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('DraggableManager callbacks', () => { |
||||
describe('reframe', () => { |
||||
it('handles mousemove', () => { |
||||
const value = 0.5; |
||||
wrapper.instance()._handleReframeMouseMove({ value }); |
||||
const calls = props.updateNextViewRangeTime.mock.calls; |
||||
expect(calls).toEqual([[{ cursor: value }]]); |
||||
}); |
||||
|
||||
it('handles mouseleave', () => { |
||||
wrapper.instance()._handleReframeMouseLeave(); |
||||
const calls = props.updateNextViewRangeTime.mock.calls; |
||||
expect(calls).toEqual([[{ cursor: null }]]); |
||||
}); |
||||
|
||||
describe('drag update', () => { |
||||
it('handles sans anchor', () => { |
||||
const value = 0.5; |
||||
wrapper.instance()._handleReframeDragUpdate({ value }); |
||||
const calls = props.updateNextViewRangeTime.mock.calls; |
||||
expect(calls).toEqual([[{ reframe: { anchor: value, shift: value } }]]); |
||||
}); |
||||
|
||||
it('handles the existing anchor', () => { |
||||
const value = 0.5; |
||||
const anchor = 0.1; |
||||
const time = { ...props.viewRange.time, reframe: { anchor } }; |
||||
props = { ...props, viewRange: { time } }; |
||||
wrapper = shallow(<ViewingLayer {...props} />); |
||||
wrapper.instance()._handleReframeDragUpdate({ value }); |
||||
const calls = props.updateNextViewRangeTime.mock.calls; |
||||
expect(calls).toEqual([[{ reframe: { anchor, shift: value } }]]); |
||||
}); |
||||
}); |
||||
|
||||
describe('drag end', () => { |
||||
let manager; |
||||
|
||||
beforeEach(() => { |
||||
manager = { resetBounds: jest.fn() }; |
||||
}); |
||||
|
||||
it('handles sans anchor', () => { |
||||
const value = 0.5; |
||||
wrapper.instance()._handleReframeDragEnd({ manager, value }); |
||||
expect(manager.resetBounds.mock.calls).toEqual([[]]); |
||||
const calls = props.updateViewRangeTime.mock.calls; |
||||
expect(calls).toEqual([[value, value, 'minimap']]); |
||||
}); |
||||
|
||||
it('handles dragged left (anchor is greater)', () => { |
||||
const value = 0.5; |
||||
const anchor = 0.6; |
||||
const time = { ...props.viewRange.time, reframe: { anchor } }; |
||||
props = { ...props, viewRange: { time } }; |
||||
wrapper = shallow(<ViewingLayer {...props} />); |
||||
wrapper.instance()._handleReframeDragEnd({ manager, value }); |
||||
|
||||
expect(manager.resetBounds.mock.calls).toEqual([[]]); |
||||
const calls = props.updateViewRangeTime.mock.calls; |
||||
expect(calls).toEqual([[value, anchor, 'minimap']]); |
||||
}); |
||||
|
||||
it('handles dragged right (anchor is less)', () => { |
||||
const value = 0.5; |
||||
const anchor = 0.4; |
||||
const time = { ...props.viewRange.time, reframe: { anchor } }; |
||||
props = { ...props, viewRange: { time } }; |
||||
wrapper = shallow(<ViewingLayer {...props} />); |
||||
wrapper.instance()._handleReframeDragEnd({ manager, value }); |
||||
|
||||
expect(manager.resetBounds.mock.calls).toEqual([[]]); |
||||
const calls = props.updateViewRangeTime.mock.calls; |
||||
expect(calls).toEqual([[anchor, value, 'minimap']]); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('scrubber', () => { |
||||
it('prevents the cursor from being drawn on scrubber mouseover', () => { |
||||
wrapper.instance()._handleScrubberEnterLeave({ type: EUpdateTypes.MouseEnter }); |
||||
expect(wrapper.state('preventCursorLine')).toBe(true); |
||||
}); |
||||
|
||||
it('prevents the cursor from being drawn on scrubber mouseleave', () => { |
||||
wrapper.instance()._handleScrubberEnterLeave({ type: EUpdateTypes.MouseLeave }); |
||||
expect(wrapper.state('preventCursorLine')).toBe(false); |
||||
}); |
||||
|
||||
describe('drag start and update', () => { |
||||
it('stops propagation on drag start', () => { |
||||
const stopPropagation = jest.fn(); |
||||
const update = { |
||||
event: { stopPropagation }, |
||||
type: EUpdateTypes.DragStart, |
||||
}; |
||||
wrapper.instance()._handleScrubberDragUpdate(update); |
||||
expect(stopPropagation.mock.calls).toEqual([[]]); |
||||
}); |
||||
|
||||
it('updates the viewRange for shiftStart and shiftEnd', () => { |
||||
const instance = wrapper.instance(); |
||||
const value = 0.5; |
||||
const cases = [ |
||||
{ |
||||
dragUpdate: { |
||||
value, |
||||
tag: dragTypes.SHIFT_START, |
||||
type: EUpdateTypes.DragMove, |
||||
}, |
||||
viewRangeUpdate: { shiftStart: value }, |
||||
}, |
||||
{ |
||||
dragUpdate: { |
||||
value, |
||||
tag: dragTypes.SHIFT_END, |
||||
type: EUpdateTypes.DragMove, |
||||
}, |
||||
viewRangeUpdate: { shiftEnd: value }, |
||||
}, |
||||
]; |
||||
cases.forEach(_case => { |
||||
instance._handleScrubberDragUpdate(_case.dragUpdate); |
||||
expect(props.updateNextViewRangeTime).lastCalledWith(_case.viewRangeUpdate); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
it('updates the view on drag end', () => { |
||||
const instance = wrapper.instance(); |
||||
const [viewStart, viewEnd] = props.viewRange.time.current; |
||||
const value = 0.5; |
||||
const cases = [ |
||||
{ |
||||
dragUpdate: { |
||||
value, |
||||
manager: { resetBounds: jest.fn() }, |
||||
tag: dragTypes.SHIFT_START, |
||||
}, |
||||
viewRangeUpdate: [value, viewEnd], |
||||
}, |
||||
{ |
||||
dragUpdate: { |
||||
value, |
||||
manager: { resetBounds: jest.fn() }, |
||||
tag: dragTypes.SHIFT_END, |
||||
}, |
||||
viewRangeUpdate: [viewStart, value], |
||||
}, |
||||
]; |
||||
cases.forEach(_case => { |
||||
const { manager } = _case.dragUpdate; |
||||
wrapper.setState({ preventCursorLine: true }); |
||||
expect(wrapper.state('preventCursorLine')).toBe(true); |
||||
instance._handleScrubberDragEnd(_case.dragUpdate); |
||||
expect(wrapper.state('preventCursorLine')).toBe(false); |
||||
expect(manager.resetBounds.mock.calls).toEqual([[]]); |
||||
expect(props.updateViewRangeTime).lastCalledWith(..._case.viewRangeUpdate, 'minimap'); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('.ViewingLayer--resetZoom', () => { |
||||
it('should not render .ViewingLayer--resetZoom if props.viewRange.time.current = [0,1]', () => { |
||||
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(0); |
||||
wrapper.setProps({ viewRange: { time: { current: [0, 1] } } }); |
||||
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(0); |
||||
}); |
||||
|
||||
it('should render ViewingLayer--resetZoom if props.viewRange.time.current[0] !== 0', () => { |
||||
// If the test fails on the following expect statement, this may be a false negative
|
||||
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(0); |
||||
wrapper.setProps({ viewRange: { time: { current: [0.1, 1] } } }); |
||||
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(1); |
||||
}); |
||||
|
||||
it('should render ViewingLayer--resetZoom if props.viewRange.time.current[1] !== 1', () => { |
||||
// If the test fails on the following expect statement, this may be a false negative
|
||||
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(0); |
||||
wrapper.setProps({ viewRange: { time: { current: [0, 0.9] } } }); |
||||
expect(wrapper.find(`.${getStyles().ViewingLayerResetZoom}`).length).toBe(1); |
||||
}); |
||||
|
||||
it('should call props.updateViewRangeTime when clicked', () => { |
||||
wrapper.setProps({ viewRange: { time: { current: [0.1, 0.9] } } }); |
||||
const resetZoomButton = wrapper.find(`.${getStyles().ViewingLayerResetZoom}`); |
||||
// If the test fails on the following expect statement, this may be a false negative caused
|
||||
// by a regression to rendering.
|
||||
expect(resetZoomButton.length).toBe(1); |
||||
|
||||
resetZoomButton.simulate('click'); |
||||
expect(props.updateViewRangeTime).lastCalledWith(0, 1); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
it('renders a <GraphTicks />', () => { |
||||
expect(wrapper.find(GraphTicks).length).toBe(1); |
||||
}); |
||||
|
||||
it('renders a filtering box if leftBound exists', () => { |
||||
const _props = { ...props, viewRange: getViewRange(0.2, 1) }; |
||||
wrapper = shallow(<ViewingLayer {..._props} />); |
||||
|
||||
const leftBox = wrapper.find(`.${getStyles().ViewingLayerInactive}`); |
||||
expect(leftBox.length).toBe(1); |
||||
const width = Number(leftBox.prop('width').slice(0, -1)); |
||||
const x = leftBox.prop('x'); |
||||
expect(Math.round(width)).toBe(20); |
||||
expect(x).toBe(0); |
||||
}); |
||||
|
||||
it('renders a filtering box if rightBound exists', () => { |
||||
const _props = { ...props, viewRange: getViewRange(0, 0.8) }; |
||||
wrapper = shallow(<ViewingLayer {..._props} />); |
||||
|
||||
const rightBox = wrapper.find(`.${getStyles().ViewingLayerInactive}`); |
||||
expect(rightBox.length).toBe(1); |
||||
const width = Number(rightBox.prop('width').slice(0, -1)); |
||||
const x = Number(rightBox.prop('x').slice(0, -1)); |
||||
expect(Math.round(width)).toBe(20); |
||||
expect(x).toBe(80); |
||||
}); |
||||
|
||||
it('renders handles for the timeRangeFilter', () => { |
||||
const [viewStart, viewEnd] = props.viewRange.time.current; |
||||
let scrubber = <Scrubber position={viewStart} />; |
||||
expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy(); |
||||
scrubber = <Scrubber position={viewEnd} />; |
||||
expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy(); |
||||
}); |
||||
}); |
@ -0,0 +1,408 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import cx from 'classnames'; |
||||
import * as React from 'react'; |
||||
import { css } from 'emotion'; |
||||
|
||||
import GraphTicks from './GraphTicks'; |
||||
import Scrubber from './Scrubber'; |
||||
import { TUpdateViewRangeTimeFunction, UIButton, ViewRange, ViewRangeTimeUpdate } from '../..'; |
||||
import { TNil } from '../..'; |
||||
import DraggableManager, { DraggableBounds, DraggingUpdate, EUpdateTypes } from '../../utils/DraggableManager'; |
||||
|
||||
import { createStyle } from '../../Theme'; |
||||
|
||||
export const getStyles = createStyle(() => { |
||||
// Need this cause emotion will merge emotion generated classes into single className if used with cx from emotion
|
||||
// package and the selector won't work
|
||||
const ViewingLayerResetZoomHoverClassName = 'JaegerUiComponents__ViewingLayerResetZoomHoverClassName'; |
||||
const ViewingLayerResetZoom = css` |
||||
label: ViewingLayerResetZoom; |
||||
display: none; |
||||
position: absolute; |
||||
right: 1%; |
||||
top: 10%; |
||||
z-index: 1; |
||||
`;
|
||||
return { |
||||
ViewingLayer: css` |
||||
label: ViewingLayer; |
||||
cursor: vertical-text; |
||||
position: relative; |
||||
z-index: 1; |
||||
&:hover > .${ViewingLayerResetZoomHoverClassName} { |
||||
display: unset; |
||||
} |
||||
`,
|
||||
ViewingLayerGraph: css` |
||||
label: ViewingLayerGraph; |
||||
border: 1px solid #999; |
||||
/* need !important here to overcome something from semantic UI */ |
||||
overflow: visible !important; |
||||
position: relative; |
||||
transform-origin: 0 0; |
||||
width: 100%; |
||||
`,
|
||||
ViewingLayerInactive: css` |
||||
label: ViewingLayerInactive; |
||||
fill: rgba(214, 214, 214, 0.5); |
||||
`,
|
||||
ViewingLayerCursorGuide: css` |
||||
label: ViewingLayerCursorGuide; |
||||
stroke: #f44; |
||||
stroke-width: 1; |
||||
`,
|
||||
ViewingLayerDraggedShift: css` |
||||
label: ViewingLayerDraggedShift; |
||||
fill-opacity: 0.2; |
||||
`,
|
||||
ViewingLayerDrag: css` |
||||
label: ViewingLayerDrag; |
||||
fill: #44f; |
||||
`,
|
||||
ViewingLayerFullOverlay: css` |
||||
label: ViewingLayerFullOverlay; |
||||
bottom: 0; |
||||
cursor: col-resize; |
||||
left: 0; |
||||
position: fixed; |
||||
right: 0; |
||||
top: 0; |
||||
user-select: none; |
||||
`,
|
||||
ViewingLayerResetZoom, |
||||
ViewingLayerResetZoomHoverClassName, |
||||
}; |
||||
}); |
||||
|
||||
type ViewingLayerProps = { |
||||
height: number; |
||||
numTicks: number; |
||||
updateViewRangeTime: TUpdateViewRangeTimeFunction; |
||||
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void; |
||||
viewRange: ViewRange; |
||||
}; |
||||
|
||||
type ViewingLayerState = { |
||||
/** |
||||
* Cursor line should not be drawn when the mouse is over the scrubber handle. |
||||
*/ |
||||
preventCursorLine: boolean; |
||||
}; |
||||
|
||||
/** |
||||
* Designate the tags for the different dragging managers. Exported for tests. |
||||
*/ |
||||
export const dragTypes = { |
||||
/** |
||||
* Tag for dragging the right scrubber, e.g. end of the current view range. |
||||
*/ |
||||
SHIFT_END: 'SHIFT_END', |
||||
/** |
||||
* Tag for dragging the left scrubber, e.g. start of the current view range. |
||||
*/ |
||||
SHIFT_START: 'SHIFT_START', |
||||
/** |
||||
* Tag for dragging a new view range. |
||||
*/ |
||||
REFRAME: 'REFRAME', |
||||
}; |
||||
|
||||
/** |
||||
* Returns the layout information for drawing the view-range differential, e.g. |
||||
* show what will change when the mouse is released. Basically, this is the |
||||
* difference from the start of the drag to the current position. |
||||
* |
||||
* @returns {{ x: string, width: string, leadginX: string }} |
||||
*/ |
||||
function getNextViewLayout(start: number, position: number) { |
||||
const [left, right] = start < position ? [start, position] : [position, start]; |
||||
return { |
||||
x: `${left * 100}%`, |
||||
width: `${(right - left) * 100}%`, |
||||
leadingX: `${position * 100}%`, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* `ViewingLayer` is rendered on top of the Canvas rendering of the minimap and |
||||
* handles showing the current view range and handles mouse UX for modifying it. |
||||
*/ |
||||
export default class ViewingLayer extends React.PureComponent<ViewingLayerProps, ViewingLayerState> { |
||||
state: ViewingLayerState; |
||||
|
||||
_root: Element | TNil; |
||||
|
||||
/** |
||||
* `_draggerReframe` handles clicking and dragging on the `ViewingLayer` to |
||||
* redefined the view range. |
||||
*/ |
||||
_draggerReframe: DraggableManager; |
||||
|
||||
/** |
||||
* `_draggerStart` handles dragging the left scrubber to adjust the start of |
||||
* the view range. |
||||
*/ |
||||
_draggerStart: DraggableManager; |
||||
|
||||
/** |
||||
* `_draggerEnd` handles dragging the right scrubber to adjust the end of |
||||
* the view range. |
||||
*/ |
||||
_draggerEnd: DraggableManager; |
||||
|
||||
constructor(props: ViewingLayerProps) { |
||||
super(props); |
||||
|
||||
this._draggerReframe = new DraggableManager({ |
||||
getBounds: this._getDraggingBounds, |
||||
onDragEnd: this._handleReframeDragEnd, |
||||
onDragMove: this._handleReframeDragUpdate, |
||||
onDragStart: this._handleReframeDragUpdate, |
||||
onMouseMove: this._handleReframeMouseMove, |
||||
onMouseLeave: this._handleReframeMouseLeave, |
||||
tag: dragTypes.REFRAME, |
||||
}); |
||||
|
||||
this._draggerStart = new DraggableManager({ |
||||
getBounds: this._getDraggingBounds, |
||||
onDragEnd: this._handleScrubberDragEnd, |
||||
onDragMove: this._handleScrubberDragUpdate, |
||||
onDragStart: this._handleScrubberDragUpdate, |
||||
onMouseEnter: this._handleScrubberEnterLeave, |
||||
onMouseLeave: this._handleScrubberEnterLeave, |
||||
tag: dragTypes.SHIFT_START, |
||||
}); |
||||
|
||||
this._draggerEnd = new DraggableManager({ |
||||
getBounds: this._getDraggingBounds, |
||||
onDragEnd: this._handleScrubberDragEnd, |
||||
onDragMove: this._handleScrubberDragUpdate, |
||||
onDragStart: this._handleScrubberDragUpdate, |
||||
onMouseEnter: this._handleScrubberEnterLeave, |
||||
onMouseLeave: this._handleScrubberEnterLeave, |
||||
tag: dragTypes.SHIFT_END, |
||||
}); |
||||
|
||||
this._root = undefined; |
||||
this.state = { |
||||
preventCursorLine: false, |
||||
}; |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
this._draggerReframe.dispose(); |
||||
this._draggerEnd.dispose(); |
||||
this._draggerStart.dispose(); |
||||
} |
||||
|
||||
_setRoot = (elm: SVGElement | TNil) => { |
||||
this._root = elm; |
||||
}; |
||||
|
||||
_getDraggingBounds = (tag: string | TNil): DraggableBounds => { |
||||
if (!this._root) { |
||||
throw new Error('invalid state'); |
||||
} |
||||
const { left: clientXLeft, width } = this._root.getBoundingClientRect(); |
||||
const [viewStart, viewEnd] = this.props.viewRange.time.current; |
||||
let maxValue = 1; |
||||
let minValue = 0; |
||||
if (tag === dragTypes.SHIFT_START) { |
||||
maxValue = viewEnd; |
||||
} else if (tag === dragTypes.SHIFT_END) { |
||||
minValue = viewStart; |
||||
} |
||||
return { clientXLeft, maxValue, minValue, width }; |
||||
}; |
||||
|
||||
_handleReframeMouseMove = ({ value }: DraggingUpdate) => { |
||||
this.props.updateNextViewRangeTime({ cursor: value }); |
||||
}; |
||||
|
||||
_handleReframeMouseLeave = () => { |
||||
this.props.updateNextViewRangeTime({ cursor: null }); |
||||
}; |
||||
|
||||
_handleReframeDragUpdate = ({ value }: DraggingUpdate) => { |
||||
const shift = value; |
||||
const { time } = this.props.viewRange; |
||||
const anchor = time.reframe ? time.reframe.anchor : shift; |
||||
const update = { reframe: { anchor, shift } }; |
||||
this.props.updateNextViewRangeTime(update); |
||||
}; |
||||
|
||||
_handleReframeDragEnd = ({ manager, value }: DraggingUpdate) => { |
||||
const { time } = this.props.viewRange; |
||||
const anchor = time.reframe ? time.reframe.anchor : value; |
||||
const [start, end] = value < anchor ? [value, anchor] : [anchor, value]; |
||||
manager.resetBounds(); |
||||
this.props.updateViewRangeTime(start, end, 'minimap'); |
||||
}; |
||||
|
||||
_handleScrubberEnterLeave = ({ type }: DraggingUpdate) => { |
||||
const preventCursorLine = type === EUpdateTypes.MouseEnter; |
||||
this.setState({ preventCursorLine }); |
||||
}; |
||||
|
||||
_handleScrubberDragUpdate = ({ event, tag, type, value }: DraggingUpdate) => { |
||||
if (type === EUpdateTypes.DragStart) { |
||||
event.stopPropagation(); |
||||
} |
||||
if (tag === dragTypes.SHIFT_START) { |
||||
this.props.updateNextViewRangeTime({ shiftStart: value }); |
||||
} else if (tag === dragTypes.SHIFT_END) { |
||||
this.props.updateNextViewRangeTime({ shiftEnd: value }); |
||||
} |
||||
}; |
||||
|
||||
_handleScrubberDragEnd = ({ manager, tag, value }: DraggingUpdate) => { |
||||
const [viewStart, viewEnd] = this.props.viewRange.time.current; |
||||
let update: [number, number]; |
||||
if (tag === dragTypes.SHIFT_START) { |
||||
update = [value, viewEnd]; |
||||
} else if (tag === dragTypes.SHIFT_END) { |
||||
update = [viewStart, value]; |
||||
} else { |
||||
// to satisfy flow
|
||||
throw new Error('bad state'); |
||||
} |
||||
manager.resetBounds(); |
||||
this.setState({ preventCursorLine: false }); |
||||
this.props.updateViewRangeTime(update[0], update[1], 'minimap'); |
||||
}; |
||||
|
||||
/** |
||||
* Resets the zoom to fully zoomed out. |
||||
*/ |
||||
_resetTimeZoomClickHandler = () => { |
||||
this.props.updateViewRangeTime(0, 1); |
||||
}; |
||||
|
||||
/** |
||||
* Renders the difference between where the drag started and the current |
||||
* position, e.g. the red or blue highlight. |
||||
* |
||||
* @returns React.Node[] |
||||
*/ |
||||
_getMarkers(from: number, to: number) { |
||||
const styles = getStyles(); |
||||
const layout = getNextViewLayout(from, to); |
||||
return [ |
||||
<rect |
||||
key="fill" |
||||
className={cx(styles.ViewingLayerDraggedShift, styles.ViewingLayerDrag)} |
||||
x={layout.x} |
||||
y="0" |
||||
width={layout.width} |
||||
height={this.props.height - 2} |
||||
/>, |
||||
<rect |
||||
key="edge" |
||||
className={cx(styles.ViewingLayerDrag)} |
||||
x={layout.leadingX} |
||||
y="0" |
||||
width="1" |
||||
height={this.props.height - 2} |
||||
/>, |
||||
]; |
||||
} |
||||
|
||||
render() { |
||||
const { height, viewRange, numTicks } = this.props; |
||||
const { preventCursorLine } = this.state; |
||||
const { current, cursor, shiftStart, shiftEnd, reframe } = viewRange.time; |
||||
const haveNextTimeRange = shiftStart != null || shiftEnd != null || reframe != null; |
||||
const [viewStart, viewEnd] = current; |
||||
let leftInactive = 0; |
||||
if (viewStart) { |
||||
leftInactive = viewStart * 100; |
||||
} |
||||
let rightInactive = 100; |
||||
if (viewEnd) { |
||||
rightInactive = 100 - viewEnd * 100; |
||||
} |
||||
let cursorPosition: string | undefined; |
||||
if (!haveNextTimeRange && cursor != null && !preventCursorLine) { |
||||
cursorPosition = `${cursor * 100}%`; |
||||
} |
||||
const styles = getStyles(); |
||||
|
||||
return ( |
||||
<div aria-hidden className={styles.ViewingLayer} style={{ height }}> |
||||
{(viewStart !== 0 || viewEnd !== 1) && ( |
||||
<UIButton |
||||
onClick={this._resetTimeZoomClickHandler} |
||||
className={cx(styles.ViewingLayerResetZoom, styles.ViewingLayerResetZoomHoverClassName)} |
||||
htmlType="button" |
||||
> |
||||
Reset Selection |
||||
</UIButton> |
||||
)} |
||||
<svg |
||||
height={height} |
||||
className={styles.ViewingLayerGraph} |
||||
ref={this._setRoot} |
||||
onMouseDown={this._draggerReframe.handleMouseDown} |
||||
onMouseLeave={this._draggerReframe.handleMouseLeave} |
||||
onMouseMove={this._draggerReframe.handleMouseMove} |
||||
> |
||||
{leftInactive > 0 && ( |
||||
<rect x={0} y={0} height="100%" width={`${leftInactive}%`} className={styles.ViewingLayerInactive} /> |
||||
)} |
||||
{rightInactive > 0 && ( |
||||
<rect |
||||
x={`${100 - rightInactive}%`} |
||||
y={0} |
||||
height="100%" |
||||
width={`${rightInactive}%`} |
||||
className={styles.ViewingLayerInactive} |
||||
/> |
||||
)} |
||||
<GraphTicks numTicks={numTicks} /> |
||||
{cursorPosition && ( |
||||
<line |
||||
className={styles.ViewingLayerCursorGuide} |
||||
x1={cursorPosition} |
||||
y1="0" |
||||
x2={cursorPosition} |
||||
y2={height - 2} |
||||
strokeWidth="1" |
||||
/> |
||||
)} |
||||
{shiftStart != null && this._getMarkers(viewStart, shiftStart)} |
||||
{shiftEnd != null && this._getMarkers(viewEnd, shiftEnd)} |
||||
<Scrubber |
||||
isDragging={shiftStart != null} |
||||
onMouseDown={this._draggerStart.handleMouseDown} |
||||
onMouseEnter={this._draggerStart.handleMouseEnter} |
||||
onMouseLeave={this._draggerStart.handleMouseLeave} |
||||
position={viewStart || 0} |
||||
/> |
||||
<Scrubber |
||||
isDragging={shiftEnd != null} |
||||
position={viewEnd || 1} |
||||
onMouseDown={this._draggerEnd.handleMouseDown} |
||||
onMouseEnter={this._draggerEnd.handleMouseEnter} |
||||
onMouseLeave={this._draggerEnd.handleMouseLeave} |
||||
/> |
||||
{reframe != null && this._getMarkers(reframe.anchor, reframe.shift)} |
||||
</svg> |
||||
{/* fullOverlay updates the mouse cursor blocks mouse events */} |
||||
{haveNextTimeRange && <div className={styles.ViewingLayerFullOverlay} />} |
||||
</div> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,76 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
|
||||
import CanvasSpanGraph from './CanvasSpanGraph'; |
||||
import SpanGraph from './index'; |
||||
import TickLabels from './TickLabels'; |
||||
import ViewingLayer from './ViewingLayer'; |
||||
import traceGenerator from '../../demo/trace-generators'; |
||||
import transformTraceData from '../../model/transform-trace-data'; |
||||
import { polyfill as polyfillAnimationFrame } from '../../utils/test/requestAnimationFrame'; |
||||
|
||||
describe('<SpanGraph>', () => { |
||||
polyfillAnimationFrame(window); |
||||
|
||||
const trace = transformTraceData(traceGenerator.trace({})); |
||||
const props = { |
||||
trace, |
||||
updateViewRangeTime: () => {}, |
||||
viewRange: { |
||||
time: { |
||||
current: [0, 1], |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
let wrapper; |
||||
|
||||
beforeEach(() => { |
||||
wrapper = shallow(<SpanGraph {...props} />); |
||||
}); |
||||
|
||||
it('renders a <CanvasSpanGraph />', () => { |
||||
expect(wrapper.find(CanvasSpanGraph).length).toBe(1); |
||||
}); |
||||
|
||||
it('renders a <TickLabels />', () => { |
||||
expect(wrapper.find(TickLabels).length).toBe(1); |
||||
}); |
||||
|
||||
it('returns a <div> if a trace is not provided', () => { |
||||
wrapper = shallow(<SpanGraph {...props} trace={null} />); |
||||
expect(wrapper.matchesElement(<div />)).toBeTruthy(); |
||||
}); |
||||
|
||||
it('passes the number of ticks to render to components', () => { |
||||
const tickHeader = wrapper.find(TickLabels); |
||||
const viewingLayer = wrapper.find(ViewingLayer); |
||||
expect(tickHeader.prop('numTicks')).toBeGreaterThan(1); |
||||
expect(viewingLayer.prop('numTicks')).toBeGreaterThan(1); |
||||
expect(tickHeader.prop('numTicks')).toBe(viewingLayer.prop('numTicks')); |
||||
}); |
||||
|
||||
it('passes items to CanvasSpanGraph', () => { |
||||
const canvasGraph = wrapper.find(CanvasSpanGraph).first(); |
||||
const items = trace.spans.map(span => ({ |
||||
valueOffset: span.relativeStartTime, |
||||
valueWidth: span.duration, |
||||
serviceName: span.process.serviceName, |
||||
})); |
||||
expect(canvasGraph.prop('items')).toEqual(items); |
||||
}); |
||||
}); |
@ -0,0 +1,102 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react'; |
||||
import cx from 'classnames'; |
||||
|
||||
import CanvasSpanGraph from './CanvasSpanGraph'; |
||||
import TickLabels from './TickLabels'; |
||||
import ViewingLayer from './ViewingLayer'; |
||||
import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '../..'; |
||||
import { Span, Trace } from '../..'; |
||||
import { ubPb2, ubPx2, ubRelative } from '../../uberUtilityStyles'; |
||||
|
||||
const DEFAULT_HEIGHT = 60; |
||||
const TIMELINE_TICK_INTERVAL = 4; |
||||
|
||||
type SpanGraphProps = { |
||||
height?: number; |
||||
trace: Trace; |
||||
viewRange: ViewRange; |
||||
updateViewRangeTime: TUpdateViewRangeTimeFunction; |
||||
updateNextViewRangeTime: (nextUpdate: ViewRangeTimeUpdate) => void; |
||||
}; |
||||
|
||||
/** |
||||
* Store `items` in state so they are not regenerated every render. Otherwise, |
||||
* the canvas graph will re-render itself every time. |
||||
*/ |
||||
type SpanGraphState = { |
||||
items: Array<{ |
||||
valueOffset: number; |
||||
valueWidth: number; |
||||
serviceName: string; |
||||
}>; |
||||
}; |
||||
|
||||
function getItem(span: Span) { |
||||
return { |
||||
valueOffset: span.relativeStartTime, |
||||
valueWidth: span.duration, |
||||
serviceName: span.process.serviceName, |
||||
}; |
||||
} |
||||
|
||||
export default class SpanGraph extends React.PureComponent<SpanGraphProps, SpanGraphState> { |
||||
state: SpanGraphState; |
||||
|
||||
static defaultProps = { |
||||
height: DEFAULT_HEIGHT, |
||||
}; |
||||
|
||||
constructor(props: SpanGraphProps) { |
||||
super(props); |
||||
const { trace } = props; |
||||
this.state = { |
||||
items: trace ? trace.spans.map(getItem) : [], |
||||
}; |
||||
} |
||||
|
||||
componentWillReceiveProps(nextProps: SpanGraphProps) { |
||||
const { trace } = nextProps; |
||||
if (this.props.trace !== trace) { |
||||
this.setState({ |
||||
items: trace ? trace.spans.map(getItem) : [], |
||||
}); |
||||
} |
||||
} |
||||
|
||||
render() { |
||||
const { height, trace, viewRange, updateNextViewRangeTime, updateViewRangeTime } = this.props; |
||||
if (!trace) { |
||||
return <div />; |
||||
} |
||||
const { items } = this.state; |
||||
return ( |
||||
<div className={cx(ubPb2, ubPx2)}> |
||||
<TickLabels numTicks={TIMELINE_TICK_INTERVAL} duration={trace.duration} /> |
||||
<div className={ubRelative}> |
||||
<CanvasSpanGraph valueWidth={trace.duration} items={items} /> |
||||
<ViewingLayer |
||||
viewRange={viewRange} |
||||
numTicks={TIMELINE_TICK_INTERVAL} |
||||
height={height || DEFAULT_HEIGHT} |
||||
updateViewRangeTime={updateViewRangeTime} |
||||
updateNextViewRangeTime={updateNextViewRangeTime} |
||||
/> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,199 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import _range from 'lodash/range'; |
||||
|
||||
import renderIntoCanvas, { |
||||
BG_COLOR, |
||||
ITEM_ALPHA, |
||||
MIN_ITEM_HEIGHT, |
||||
MAX_TOTAL_HEIGHT, |
||||
MIN_ITEM_WIDTH, |
||||
MIN_TOTAL_HEIGHT, |
||||
MAX_ITEM_HEIGHT, |
||||
} from './render-into-canvas'; |
||||
|
||||
const getCanvasWidth = () => window.innerWidth * 2; |
||||
const getBgFillRect = items => ({ |
||||
fillStyle: BG_COLOR, |
||||
height: |
||||
!items || items.length < MIN_TOTAL_HEIGHT ? MIN_TOTAL_HEIGHT : Math.min(MAX_TOTAL_HEIGHT, items.length), |
||||
width: getCanvasWidth(), |
||||
x: 0, |
||||
y: 0, |
||||
}); |
||||
|
||||
describe('renderIntoCanvas()', () => { |
||||
const basicItem = { valueWidth: 100, valueOffset: 50, serviceName: 'some-name' }; |
||||
|
||||
class CanvasContext { |
||||
constructor() { |
||||
this.fillStyle = undefined; |
||||
this.fillRectAccumulator = []; |
||||
} |
||||
|
||||
fillRect(x, y, width, height) { |
||||
const fillStyle = this.fillStyle; |
||||
this.fillRectAccumulator.push({ |
||||
fillStyle, |
||||
height, |
||||
width, |
||||
x, |
||||
y, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
class Canvas { |
||||
constructor() { |
||||
this.contexts = []; |
||||
this.height = NaN; |
||||
this.width = NaN; |
||||
this.getContext = jest.fn(this._getContext.bind(this)); |
||||
} |
||||
|
||||
_getContext() { |
||||
const ctx = new CanvasContext(); |
||||
this.contexts.push(ctx); |
||||
return ctx; |
||||
} |
||||
} |
||||
|
||||
function getColorFactory() { |
||||
let i = 0; |
||||
const inputOutput = []; |
||||
function getFakeColor(str) { |
||||
const rv = [i, i, i]; |
||||
i++; |
||||
inputOutput.push({ |
||||
input: str, |
||||
output: rv.slice(), |
||||
}); |
||||
return rv; |
||||
} |
||||
getFakeColor.inputOutput = inputOutput; |
||||
return getFakeColor; |
||||
} |
||||
|
||||
it('sets the width', () => { |
||||
const canvas = new Canvas(); |
||||
expect(canvas.width !== canvas.width).toBe(true); |
||||
renderIntoCanvas(canvas, [basicItem], 150, getColorFactory()); |
||||
expect(canvas.width).toBe(getCanvasWidth()); |
||||
}); |
||||
|
||||
describe('when there are limited number of items', () => { |
||||
it('sets the height', () => { |
||||
const canvas = new Canvas(); |
||||
expect(canvas.height !== canvas.height).toBe(true); |
||||
renderIntoCanvas(canvas, [basicItem], 150, getColorFactory()); |
||||
expect(canvas.height).toBe(MIN_TOTAL_HEIGHT); |
||||
}); |
||||
|
||||
it('draws the background', () => { |
||||
const expectedDrawing = [getBgFillRect()]; |
||||
const canvas = new Canvas(); |
||||
const items = []; |
||||
const totalValueWidth = 4000; |
||||
const getFillColor = getColorFactory(); |
||||
renderIntoCanvas(canvas, items, totalValueWidth, getFillColor); |
||||
expect(canvas.getContext.mock.calls).toEqual([['2d', { alpha: false }]]); |
||||
expect(canvas.contexts.length).toBe(1); |
||||
expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawing); |
||||
}); |
||||
|
||||
it('draws the map', () => { |
||||
const totalValueWidth = 4000; |
||||
const items = [ |
||||
{ valueWidth: 50, valueOffset: 50, serviceName: 'service-name-0' }, |
||||
{ valueWidth: 100, valueOffset: 100, serviceName: 'service-name-1' }, |
||||
{ valueWidth: 150, valueOffset: 150, serviceName: 'service-name-2' }, |
||||
]; |
||||
const expectedColors = [ |
||||
{ input: items[0].serviceName, output: [0, 0, 0] }, |
||||
{ input: items[1].serviceName, output: [1, 1, 1] }, |
||||
{ input: items[2].serviceName, output: [2, 2, 2] }, |
||||
]; |
||||
const cHeight = |
||||
items.length < MIN_TOTAL_HEIGHT ? MIN_TOTAL_HEIGHT : Math.min(items.length, MAX_TOTAL_HEIGHT); |
||||
|
||||
const expectedDrawings = [ |
||||
getBgFillRect(), |
||||
...items.map((item, i) => { |
||||
const { valueWidth, valueOffset } = item; |
||||
const color = expectedColors[i].output; |
||||
const fillStyle = `rgba(${color.concat(ITEM_ALPHA).join()})`; |
||||
const height = Math.min(MAX_ITEM_HEIGHT, Math.max(MIN_ITEM_HEIGHT, cHeight / items.length)); |
||||
const width = (valueWidth / totalValueWidth) * getCanvasWidth(); |
||||
const x = (valueOffset / totalValueWidth) * getCanvasWidth(); |
||||
const y = (cHeight / items.length) * i; |
||||
return { fillStyle, height, width, x, y }; |
||||
}), |
||||
]; |
||||
const canvas = new Canvas(); |
||||
const getFillColor = getColorFactory(); |
||||
renderIntoCanvas(canvas, items, totalValueWidth, getFillColor); |
||||
expect(getFillColor.inputOutput).toEqual(expectedColors); |
||||
expect(canvas.getContext.mock.calls).toEqual([['2d', { alpha: false }]]); |
||||
expect(canvas.contexts.length).toBe(1); |
||||
expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawings); |
||||
}); |
||||
}); |
||||
|
||||
describe('when there are many items', () => { |
||||
it('sets the height when there are many items', () => { |
||||
const canvas = new Canvas(); |
||||
const items = []; |
||||
for (let i = 0; i < MIN_TOTAL_HEIGHT + 1; i++) { |
||||
items.push(basicItem); |
||||
} |
||||
expect(canvas.height !== canvas.height).toBe(true); |
||||
renderIntoCanvas(canvas, items, 150, getColorFactory()); |
||||
expect(canvas.height).toBe(items.length); |
||||
}); |
||||
|
||||
it('draws the map', () => { |
||||
const totalValueWidth = 4000; |
||||
const items = _range(MIN_TOTAL_HEIGHT * 10).map(i => ({ |
||||
valueWidth: i, |
||||
valueOffset: i, |
||||
serviceName: `service-name-${i}`, |
||||
})); |
||||
const expectedColors = items.map((item, i) => ({ |
||||
input: item.serviceName, |
||||
output: [i, i, i], |
||||
})); |
||||
const expectedDrawings = [ |
||||
getBgFillRect(items), |
||||
...items.map((item, i) => { |
||||
const { valueWidth, valueOffset } = item; |
||||
const color = expectedColors[i].output; |
||||
const fillStyle = `rgba(${color.concat(ITEM_ALPHA).join()})`; |
||||
const height = MIN_ITEM_HEIGHT; |
||||
const width = Math.max(MIN_ITEM_WIDTH, (valueWidth / totalValueWidth) * getCanvasWidth()); |
||||
const x = (valueOffset / totalValueWidth) * getCanvasWidth(); |
||||
const y = (MAX_TOTAL_HEIGHT / items.length) * i; |
||||
return { fillStyle, height, width, x, y }; |
||||
}), |
||||
]; |
||||
const canvas = new Canvas(); |
||||
const getFillColor = getColorFactory(); |
||||
renderIntoCanvas(canvas, items, totalValueWidth, getFillColor); |
||||
expect(getFillColor.inputOutput).toEqual(expectedColors); |
||||
expect(canvas.getContext.mock.calls).toEqual([['2d', { alpha: false }]]); |
||||
expect(canvas.contexts.length).toBe(1); |
||||
expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawings); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,62 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { TNil } from '../..'; |
||||
|
||||
// exported for tests
|
||||
export const BG_COLOR = '#fff'; |
||||
export const ITEM_ALPHA = 0.8; |
||||
export const MIN_ITEM_HEIGHT = 2; |
||||
export const MAX_TOTAL_HEIGHT = 200; |
||||
export const MIN_ITEM_WIDTH = 10; |
||||
export const MIN_TOTAL_HEIGHT = 60; |
||||
export const MAX_ITEM_HEIGHT = 6; |
||||
|
||||
export default function renderIntoCanvas( |
||||
canvas: HTMLCanvasElement, |
||||
items: Array<{ valueWidth: number; valueOffset: number; serviceName: string }>, |
||||
totalValueWidth: number, |
||||
getFillColor: (serviceName: string) => [number, number, number] |
||||
) { |
||||
const fillCache: Map<string, string | TNil> = new Map(); |
||||
const cHeight = items.length < MIN_TOTAL_HEIGHT ? MIN_TOTAL_HEIGHT : Math.min(items.length, MAX_TOTAL_HEIGHT); |
||||
const cWidth = window.innerWidth * 2; |
||||
// eslint-disable-next-line no-param-reassign
|
||||
canvas.width = cWidth; |
||||
// eslint-disable-next-line no-param-reassign
|
||||
canvas.height = cHeight; |
||||
const itemHeight = Math.min(MAX_ITEM_HEIGHT, Math.max(MIN_ITEM_HEIGHT, cHeight / items.length)); |
||||
const itemYChange = cHeight / items.length; |
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D; |
||||
ctx.fillStyle = BG_COLOR; |
||||
ctx.fillRect(0, 0, cWidth, cHeight); |
||||
for (let i = 0; i < items.length; i++) { |
||||
const { valueWidth, valueOffset, serviceName } = items[i]; |
||||
const x = (valueOffset / totalValueWidth) * cWidth; |
||||
let width = (valueWidth / totalValueWidth) * cWidth; |
||||
if (width < MIN_ITEM_WIDTH) { |
||||
width = MIN_ITEM_WIDTH; |
||||
} |
||||
let fillStyle = fillCache.get(serviceName); |
||||
if (!fillStyle) { |
||||
fillStyle = `rgba(${getFillColor(serviceName) |
||||
.concat(ITEM_ALPHA) |
||||
.join()})`;
|
||||
fillCache.set(serviceName, fillStyle); |
||||
} |
||||
ctx.fillStyle = fillStyle; |
||||
ctx.fillRect(x, i * itemYChange, width, itemHeight); |
||||
} |
||||
} |
@ -0,0 +1,80 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react'; |
||||
import { shallow, mount } from 'enzyme'; |
||||
|
||||
import SpanGraph from './SpanGraph'; |
||||
import TracePageHeader, { HEADER_ITEMS } from './TracePageHeader'; |
||||
import LabeledList from '../common/LabeledList'; |
||||
import traceGenerator from '../demo/trace-generators'; |
||||
import { getTraceName } from '../model/trace-viewer'; |
||||
import transformTraceData from '../model/transform-trace-data'; |
||||
|
||||
describe('<TracePageHeader>', () => { |
||||
const trace = transformTraceData(traceGenerator.trace({})); |
||||
const defaultProps = { |
||||
trace, |
||||
showArchiveButton: false, |
||||
showShortcutsHelp: false, |
||||
showStandaloneLink: false, |
||||
showViewOptions: false, |
||||
textFilter: '', |
||||
updateTextFilter: () => {}, |
||||
}; |
||||
|
||||
let wrapper; |
||||
|
||||
beforeEach(() => { |
||||
wrapper = shallow(<TracePageHeader {...defaultProps} />); |
||||
}); |
||||
|
||||
it('renders a <header />', () => { |
||||
expect(wrapper.find('header').length).toBe(1); |
||||
}); |
||||
|
||||
it('renders an empty <div> if a trace is not present', () => { |
||||
wrapper = mount(<TracePageHeader {...defaultProps} trace={null} />); |
||||
expect(wrapper.children().length).toBe(0); |
||||
}); |
||||
|
||||
it('renders the trace title', () => { |
||||
expect(wrapper.find({ traceName: getTraceName(trace.spans) })).toBeTruthy(); |
||||
}); |
||||
|
||||
it('renders the header items', () => { |
||||
wrapper.find('.horizontal .item').forEach((item, i) => { |
||||
expect(item.contains(HEADER_ITEMS[i].title)).toBeTruthy(); |
||||
expect(item.contains(HEADER_ITEMS[i].renderer(defaultProps.trace))).toBeTruthy(); |
||||
}); |
||||
}); |
||||
|
||||
it('renders a <SpanGraph>', () => { |
||||
expect(wrapper.find(SpanGraph).length).toBe(1); |
||||
}); |
||||
|
||||
describe('observes the visibility toggles for various UX elements', () => { |
||||
it('hides the minimap when hideMap === true', () => { |
||||
expect(wrapper.find(SpanGraph).length).toBe(1); |
||||
wrapper.setProps({ hideMap: true }); |
||||
expect(wrapper.find(SpanGraph).length).toBe(0); |
||||
}); |
||||
|
||||
it('hides the summary when hideSummary === true', () => { |
||||
expect(wrapper.find(LabeledList).length).toBe(1); |
||||
wrapper.setProps({ hideSummary: true }); |
||||
expect(wrapper.find(LabeledList).length).toBe(0); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,293 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react'; |
||||
import _get from 'lodash/get'; |
||||
import _maxBy from 'lodash/maxBy'; |
||||
import _values from 'lodash/values'; |
||||
import MdKeyboardArrowRight from 'react-icons/lib/md/keyboard-arrow-right'; |
||||
import { css } from 'emotion'; |
||||
import cx from 'classnames'; |
||||
|
||||
import SpanGraph from './SpanGraph'; |
||||
import TracePageSearchBar from './TracePageSearchBar'; |
||||
import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '..'; |
||||
import LabeledList from '../common/LabeledList'; |
||||
import TraceName from '../common/TraceName'; |
||||
import { getTraceName } from '../model/trace-viewer'; |
||||
import { TNil } from '../types'; |
||||
import { Trace } from '..'; |
||||
import { formatDatetime, formatDuration } from '../utils/date'; |
||||
import { getTraceLinks } from '../model/link-patterns'; |
||||
|
||||
import ExternalLinks from '../common/ExternalLinks'; |
||||
import { createStyle } from '../Theme'; |
||||
import { uTxMuted } from '../uberUtilityStyles'; |
||||
|
||||
const getStyles = createStyle(() => { |
||||
const TracePageHeaderOverviewItemValueDetail = css` |
||||
label: TracePageHeaderOverviewItemValueDetail; |
||||
color: #aaa; |
||||
`;
|
||||
return { |
||||
TracePageHeader: css` |
||||
label: TracePageHeader; |
||||
& > :first-child { |
||||
border-bottom: 1px solid #e8e8e8; |
||||
} |
||||
& > :nth-child(2) { |
||||
background-color: #eee; |
||||
border-bottom: 1px solid #e4e4e4; |
||||
} |
||||
& > :last-child { |
||||
background-color: #f8f8f8; |
||||
border-bottom: 1px solid #ccc; |
||||
} |
||||
`,
|
||||
TracePageHeaderTitleRow: css` |
||||
label: TracePageHeaderTitleRow; |
||||
align-items: center; |
||||
background-color: #fff; |
||||
display: flex; |
||||
`,
|
||||
TracePageHeaderBack: css` |
||||
label: TracePageHeaderBack; |
||||
align-items: center; |
||||
align-self: stretch; |
||||
background-color: #fafafa; |
||||
border-bottom: 1px solid #ddd; |
||||
border-right: 1px solid #ddd; |
||||
color: inherit; |
||||
display: flex; |
||||
font-size: 1.4rem; |
||||
padding: 0 1rem; |
||||
margin-bottom: -1px; |
||||
&:hover { |
||||
background-color: #f0f0f0; |
||||
border-color: #ccc; |
||||
} |
||||
`,
|
||||
TracePageHeaderTitleLink: css` |
||||
label: TracePageHeaderTitleLink; |
||||
align-items: center; |
||||
color: rgba(0, 0, 0, 0.85); |
||||
display: flex; |
||||
flex: 1; |
||||
|
||||
&:hover * { |
||||
text-decoration: underline; |
||||
} |
||||
&:hover > *, |
||||
&:hover small { |
||||
text-decoration: none; |
||||
} |
||||
`,
|
||||
TracePageHeaderDetailToggle: css` |
||||
label: TracePageHeaderDetailToggle; |
||||
font-size: 2.5rem; |
||||
transition: transform 0.07s ease-out; |
||||
`,
|
||||
TracePageHeaderDetailToggleExpanded: css` |
||||
label: TracePageHeaderDetailToggleExpanded; |
||||
transform: rotate(90deg); |
||||
`,
|
||||
TracePageHeaderTitle: css` |
||||
label: TracePageHeaderTitle; |
||||
color: inherit; |
||||
flex: 1; |
||||
font-size: 1.7em; |
||||
line-height: 1em; |
||||
margin: 0 0 0 0.5em; |
||||
padding: 0.5em 0; |
||||
`,
|
||||
TracePageHeaderTitleCollapsible: css` |
||||
label: TracePageHeaderTitleCollapsible; |
||||
margin-left: 0; |
||||
`,
|
||||
TracePageHeaderOverviewItems: css` |
||||
label: TracePageHeaderOverviewItems; |
||||
border-bottom: 1px solid #e4e4e4; |
||||
padding: 0.25rem 0.5rem; |
||||
`,
|
||||
TracePageHeaderOverviewItemValueDetail, |
||||
TracePageHeaderOverviewItemValue: css` |
||||
label: TracePageHeaderOverviewItemValue; |
||||
&:hover > .${TracePageHeaderOverviewItemValueDetail} { |
||||
color: unset; |
||||
} |
||||
`,
|
||||
TracePageHeaderArchiveIcon: css` |
||||
label: TracePageHeaderArchiveIcon; |
||||
font-size: 1.78em; |
||||
margin-right: 0.15em; |
||||
`,
|
||||
}; |
||||
}); |
||||
|
||||
type TracePageHeaderEmbedProps = { |
||||
canCollapse: boolean; |
||||
clearSearch: () => void; |
||||
focusUiFindMatches: () => void; |
||||
hideMap: boolean; |
||||
hideSummary: boolean; |
||||
nextResult: () => void; |
||||
onSlimViewClicked: () => void; |
||||
onTraceGraphViewClicked: () => void; |
||||
prevResult: () => void; |
||||
resultCount: number; |
||||
slimView: boolean; |
||||
textFilter: string | TNil; |
||||
trace: Trace; |
||||
traceGraphView: boolean; |
||||
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void; |
||||
updateViewRangeTime: TUpdateViewRangeTimeFunction; |
||||
viewRange: ViewRange; |
||||
searchValue: string; |
||||
onSearchValueChange: (value: string) => void; |
||||
hideSearchButtons?: boolean; |
||||
}; |
||||
|
||||
export const HEADER_ITEMS = [ |
||||
{ |
||||
key: 'timestamp', |
||||
label: 'Trace Start', |
||||
renderer: (trace: Trace) => { |
||||
const styles = getStyles(); |
||||
const dateStr = formatDatetime(trace.startTime); |
||||
const match = dateStr.match(/^(.+)(:\d\d\.\d+)$/); |
||||
return match ? ( |
||||
<span className={styles.TracePageHeaderOverviewItemValue}> |
||||
{match[1]} |
||||
<span className={styles.TracePageHeaderOverviewItemValueDetail}>{match[2]}</span> |
||||
</span> |
||||
) : ( |
||||
dateStr |
||||
); |
||||
}, |
||||
}, |
||||
{ |
||||
key: 'duration', |
||||
label: 'Duration', |
||||
renderer: (trace: Trace) => formatDuration(trace.duration), |
||||
}, |
||||
{ |
||||
key: 'service-count', |
||||
label: 'Services', |
||||
renderer: (trace: Trace) => new Set(_values(trace.processes).map(p => p.serviceName)).size, |
||||
}, |
||||
{ |
||||
key: 'depth', |
||||
label: 'Depth', |
||||
renderer: (trace: Trace) => _get(_maxBy(trace.spans, 'depth'), 'depth', 0) + 1, |
||||
}, |
||||
{ |
||||
key: 'span-count', |
||||
label: 'Total Spans', |
||||
renderer: (trace: Trace) => trace.spans.length, |
||||
}, |
||||
]; |
||||
|
||||
export default function TracePageHeader(props: TracePageHeaderEmbedProps) { |
||||
const { |
||||
canCollapse, |
||||
clearSearch, |
||||
focusUiFindMatches, |
||||
hideMap, |
||||
hideSummary, |
||||
nextResult, |
||||
onSlimViewClicked, |
||||
prevResult, |
||||
resultCount, |
||||
slimView, |
||||
textFilter, |
||||
trace, |
||||
traceGraphView, |
||||
updateNextViewRangeTime, |
||||
updateViewRangeTime, |
||||
viewRange, |
||||
searchValue, |
||||
onSearchValueChange, |
||||
hideSearchButtons, |
||||
} = props; |
||||
|
||||
if (!trace) { |
||||
return null; |
||||
} |
||||
|
||||
const links = getTraceLinks(trace); |
||||
|
||||
const summaryItems = |
||||
!hideSummary && |
||||
!slimView && |
||||
HEADER_ITEMS.map(item => { |
||||
const { renderer, ...rest } = item; |
||||
return { ...rest, value: renderer(trace) }; |
||||
}); |
||||
|
||||
const styles = getStyles(); |
||||
|
||||
const title = ( |
||||
<h1 className={cx(styles.TracePageHeaderTitle, canCollapse && styles.TracePageHeaderTitleCollapsible)}> |
||||
<TraceName traceName={getTraceName(trace.spans)} />{' '} |
||||
<small className={uTxMuted}>{trace.traceID.slice(0, 7)}</small> |
||||
</h1> |
||||
); |
||||
|
||||
return ( |
||||
<header className={styles.TracePageHeader}> |
||||
<div className={styles.TracePageHeaderTitleRow}> |
||||
{links && links.length > 0 && <ExternalLinks links={links} className={styles.TracePageHeaderBack} />} |
||||
{canCollapse ? ( |
||||
<a |
||||
className={styles.TracePageHeaderTitleLink} |
||||
onClick={onSlimViewClicked} |
||||
role="switch" |
||||
aria-checked={!slimView} |
||||
> |
||||
<MdKeyboardArrowRight |
||||
className={cx( |
||||
styles.TracePageHeaderDetailToggle, |
||||
!slimView && styles.TracePageHeaderDetailToggleExpanded |
||||
)} |
||||
/> |
||||
{title} |
||||
</a> |
||||
) : ( |
||||
title |
||||
)} |
||||
<TracePageSearchBar |
||||
clearSearch={clearSearch} |
||||
focusUiFindMatches={focusUiFindMatches} |
||||
nextResult={nextResult} |
||||
prevResult={prevResult} |
||||
resultCount={resultCount} |
||||
textFilter={textFilter} |
||||
navigable={!traceGraphView} |
||||
searchValue={searchValue} |
||||
onSearchValueChange={onSearchValueChange} |
||||
hideSearchButtons={hideSearchButtons} |
||||
/> |
||||
</div> |
||||
{summaryItems && <LabeledList className={styles.TracePageHeaderOverviewItems} items={summaryItems} />} |
||||
{!hideMap && !slimView && ( |
||||
<SpanGraph |
||||
trace={trace} |
||||
viewRange={viewRange} |
||||
updateNextViewRangeTime={updateNextViewRangeTime} |
||||
updateViewRangeTime={updateViewRangeTime} |
||||
/> |
||||
)} |
||||
</header> |
||||
); |
||||
} |
@ -0,0 +1,15 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export const IN_TRACE_SEARCH = 'in-trace-search'; |
@ -0,0 +1,92 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
|
||||
import * as markers from './TracePageSearchBar.markers'; |
||||
import TracePageSearchBar, { getStyles } from './TracePageSearchBar'; |
||||
import UiFindInput from '../common/UiFindInput'; |
||||
|
||||
const defaultProps = { |
||||
forwardedRef: React.createRef(), |
||||
navigable: true, |
||||
nextResult: () => {}, |
||||
prevResult: () => {}, |
||||
resultCount: 0, |
||||
textFilter: 'something', |
||||
}; |
||||
|
||||
describe('<TracePageSearchBar>', () => { |
||||
let wrapper; |
||||
|
||||
beforeEach(() => { |
||||
wrapper = shallow(<TracePageSearchBar {...defaultProps} />); |
||||
}); |
||||
|
||||
describe('truthy textFilter', () => { |
||||
it('renders UiFindInput with correct props', () => { |
||||
const renderedUiFindInput = wrapper.find(UiFindInput); |
||||
const suffix = shallow(renderedUiFindInput.prop('inputProps').suffix); |
||||
expect(renderedUiFindInput.prop('inputProps')).toEqual( |
||||
expect.objectContaining({ |
||||
'data-test': markers.IN_TRACE_SEARCH, |
||||
name: 'search', |
||||
}) |
||||
); |
||||
expect(suffix.hasClass(getStyles().TracePageSearchBarCount)).toBe(true); |
||||
expect(suffix.text()).toBe(String(defaultProps.resultCount)); |
||||
}); |
||||
|
||||
it('renders buttons', () => { |
||||
const buttons = wrapper.find('UIButton'); |
||||
expect(buttons.length).toBe(4); |
||||
buttons.forEach(button => { |
||||
expect(button.hasClass(getStyles().TracePageSearchBarBtn)).toBe(true); |
||||
expect(button.hasClass(getStyles().TracePageSearchBarBtnDisabled)).toBe(false); |
||||
expect(button.prop('disabled')).toBe(false); |
||||
}); |
||||
expect(wrapper.find('UIButton[icon="up"]').prop('onClick')).toBe(defaultProps.prevResult); |
||||
expect(wrapper.find('UIButton[icon="down"]').prop('onClick')).toBe(defaultProps.nextResult); |
||||
expect(wrapper.find('UIButton[icon="close"]').prop('onClick')).toBe(defaultProps.clearSearch); |
||||
}); |
||||
|
||||
it('hides navigation buttons when not navigable', () => { |
||||
wrapper.setProps({ navigable: false }); |
||||
const button = wrapper.find('UIButton'); |
||||
expect(button.length).toBe(1); |
||||
expect(button.prop('icon')).toBe('close'); |
||||
}); |
||||
}); |
||||
|
||||
describe('falsy textFilter', () => { |
||||
beforeEach(() => { |
||||
wrapper.setProps({ textFilter: '' }); |
||||
}); |
||||
|
||||
it('renders UiFindInput with correct props', () => { |
||||
expect(wrapper.find(UiFindInput).prop('inputProps').suffix).toBe(null); |
||||
}); |
||||
|
||||
it('renders buttons', () => { |
||||
const buttons = wrapper.find('UIButton'); |
||||
expect(buttons.length).toBe(4); |
||||
buttons.forEach(button => { |
||||
expect(button.hasClass(getStyles().TracePageSearchBarBtn)).toBe(true); |
||||
expect(button.hasClass(getStyles().TracePageSearchBarBtnDisabled)).toBe(true); |
||||
expect(button.prop('disabled')).toBe(true); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,144 @@ |
||||
// Copyright (c) 2018 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react'; |
||||
import cx from 'classnames'; |
||||
import IoAndroidLocate from 'react-icons/lib/io/android-locate'; |
||||
import { css } from 'emotion'; |
||||
|
||||
import * as markers from './TracePageSearchBar.markers'; |
||||
import UiFindInput from '../common/UiFindInput'; |
||||
import { TNil } from '../types'; |
||||
|
||||
import { UIButton, UIInputGroup } from '../uiElementsContext'; |
||||
import { createStyle } from '../Theme'; |
||||
import { ubFlexAuto, ubJustifyEnd } from '../uberUtilityStyles'; |
||||
|
||||
export const getStyles = createStyle(() => { |
||||
return { |
||||
TracePageSearchBar: css` |
||||
label: TracePageSearchBar; |
||||
`,
|
||||
TracePageSearchBarBar: css` |
||||
label: TracePageSearchBarBar; |
||||
max-width: 20rem; |
||||
transition: max-width 0.5s; |
||||
&:focus-within { |
||||
max-width: 100%; |
||||
} |
||||
`,
|
||||
TracePageSearchBarCount: css` |
||||
label: TracePageSearchBarCount; |
||||
opacity: 0.6; |
||||
`,
|
||||
TracePageSearchBarBtn: css` |
||||
label: TracePageSearchBarBtn; |
||||
border-left: none; |
||||
transition: 0.2s; |
||||
`,
|
||||
TracePageSearchBarBtnDisabled: css` |
||||
label: TracePageSearchBarBtnDisabled; |
||||
opacity: 0.5; |
||||
`,
|
||||
TracePageSearchBarLocateBtn: css` |
||||
label: TracePageSearchBarLocateBtn; |
||||
padding: 1px 8px 4px; |
||||
`,
|
||||
}; |
||||
}); |
||||
|
||||
type TracePageSearchBarProps = { |
||||
textFilter: string | TNil; |
||||
prevResult: () => void; |
||||
nextResult: () => void; |
||||
clearSearch: () => void; |
||||
focusUiFindMatches: () => void; |
||||
resultCount: number; |
||||
navigable: boolean; |
||||
searchValue: string; |
||||
onSearchValueChange: (value: string) => void; |
||||
hideSearchButtons?: boolean; |
||||
}; |
||||
|
||||
export default function TracePageSearchBar(props: TracePageSearchBarProps) { |
||||
const { |
||||
clearSearch, |
||||
focusUiFindMatches, |
||||
navigable, |
||||
nextResult, |
||||
prevResult, |
||||
resultCount, |
||||
textFilter, |
||||
onSearchValueChange, |
||||
searchValue, |
||||
hideSearchButtons, |
||||
} = props; |
||||
const styles = getStyles(); |
||||
|
||||
const count = textFilter ? <span className={styles.TracePageSearchBarCount}>{resultCount}</span> : null; |
||||
|
||||
const btnClass = cx(styles.TracePageSearchBarBtn, { [styles.TracePageSearchBarBtnDisabled]: !textFilter }); |
||||
const uiFindInputInputProps = { |
||||
'data-test': markers.IN_TRACE_SEARCH, |
||||
className: cx(styles.TracePageSearchBarBar, ubFlexAuto), |
||||
name: 'search', |
||||
suffix: count, |
||||
}; |
||||
|
||||
return ( |
||||
<div className={styles.TracePageSearchBar}> |
||||
{/* style inline because compact overwrites the display */} |
||||
<UIInputGroup className={ubJustifyEnd} compact style={{ display: 'flex' }}> |
||||
<UiFindInput onChange={onSearchValueChange} value={searchValue} inputProps={uiFindInputInputProps} /> |
||||
{!hideSearchButtons && ( |
||||
<> |
||||
{navigable && ( |
||||
<> |
||||
<UIButton |
||||
className={cx(btnClass, styles.TracePageSearchBarLocateBtn)} |
||||
disabled={!textFilter} |
||||
htmlType="button" |
||||
onClick={focusUiFindMatches} |
||||
> |
||||
<IoAndroidLocate /> |
||||
</UIButton> |
||||
<UIButton |
||||
className={btnClass} |
||||
disabled={!textFilter} |
||||
htmlType="button" |
||||
icon="up" |
||||
onClick={prevResult} |
||||
/> |
||||
<UIButton |
||||
className={btnClass} |
||||
disabled={!textFilter} |
||||
htmlType="button" |
||||
icon="down" |
||||
onClick={nextResult} |
||||
/> |
||||
</> |
||||
)} |
||||
<UIButton |
||||
className={btnClass} |
||||
disabled={!textFilter} |
||||
htmlType="button" |
||||
icon="close" |
||||
onClick={clearSearch} |
||||
/> |
||||
</> |
||||
)} |
||||
</UIInputGroup> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,15 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export { default } from './TracePageHeader'; |
@ -0,0 +1,64 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react'; |
||||
import { css } from 'emotion'; |
||||
|
||||
import { createStyle } from '../Theme'; |
||||
|
||||
const getStyles = createStyle(() => { |
||||
return { |
||||
BreakableText: css` |
||||
label: BreakableText; |
||||
display: inline-block; |
||||
white-space: pre; |
||||
`,
|
||||
}; |
||||
}); |
||||
|
||||
const WORD_RX = /\W*\w+\W*/g; |
||||
|
||||
type Props = { |
||||
text: string; |
||||
className?: string; |
||||
wordRegexp?: RegExp; |
||||
}; |
||||
|
||||
// TODO typescript doesn't understand text or null as react nodes
|
||||
// https://github.com/Microsoft/TypeScript/issues/21699
|
||||
export default function BreakableText( |
||||
props: Props |
||||
): any /* React.ReactNode /* React.ReactElement | React.ReactElement[] \*\/ */ { |
||||
const { className, text, wordRegexp = WORD_RX } = props; |
||||
if (!text) { |
||||
return typeof text === 'string' ? text : null; |
||||
} |
||||
const spans = []; |
||||
wordRegexp.exec(''); |
||||
// if the given text has no words, set the first match to the entire text
|
||||
let match: RegExpExecArray | string[] | null = wordRegexp.exec(text) || [text]; |
||||
while (match) { |
||||
spans.push( |
||||
<span key={`${text}-${spans.length}`} className={className || getStyles().BreakableText}> |
||||
{match[0]} |
||||
</span> |
||||
); |
||||
match = wordRegexp.exec(text); |
||||
} |
||||
return spans; |
||||
} |
||||
|
||||
BreakableText.defaultProps = { |
||||
wordRegexp: WORD_RX, |
||||
}; |
@ -0,0 +1,59 @@ |
||||
// Copyright (c) 2019 The Jaeger Authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react'; |
||||
import { UIDropdown, UIMenu, UIMenuItem } from '..'; |
||||
import NewWindowIcon from './NewWindowIcon'; |
||||
|
||||
type Link = { |
||||
text: string; |
||||
url: string; |
||||
}; |
||||
|
||||
type ExternalLinksProps = { |
||||
links: Link[]; |
||||
className?: string; |
||||
}; |
||||
|
||||
const LinkValue = (props: { href: string; title?: string; children?: React.ReactNode; className?: string }) => ( |
||||
<a href={props.href} title={props.title} target="_blank" rel="noopener noreferrer" className={props.className}> |
||||
{props.children} <NewWindowIcon /> |
||||
</a> |
||||
); |
||||
|
||||
// export for testing
|
||||
export const linkValueList = (links: Link[]) => ( |
||||
<UIMenu> |
||||
{links.map(({ text, url }, index) => ( |
||||
// `index` is necessary in the key because url can repeat
|
||||
<UIMenuItem key={`${url}-${index}`}> |
||||
<LinkValue href={url}>{text}</LinkValue> |
||||
</UIMenuItem> |
||||
))} |
||||
</UIMenu> |
||||
); |
||||
|
||||
export default function ExternalLinks(props: ExternalLinksProps) { |
||||
const { links } = props; |
||||
if (links.length === 1) { |
||||
return <LinkValue href={links[0].url} title={links[0].text} className={props.className} />; |
||||
} |
||||
return ( |
||||
<UIDropdown overlay={linkValueList(links)} placement="bottomRight" trigger={['click']}> |
||||
<a className={props.className}> |
||||
<NewWindowIcon isLarge /> |
||||
</a> |
||||
</UIDropdown> |
||||
); |
||||
} |
@ -0,0 +1,78 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from 'react'; |
||||
import cx from 'classnames'; |
||||
import { css, keyframes } from 'emotion'; |
||||
|
||||
import { createStyle } from '../Theme'; |
||||
import { UIIcon } from '../uiElementsContext'; |
||||
|
||||
const getStyles = createStyle(() => { |
||||
const LoadingIndicatorColorAnim = keyframes` |
||||
/* |
||||
rgb(0, 128, 128) == teal |
||||
rgba(0, 128, 128, 0.3) == #bedfdf |
||||
*/ |
||||
from { |
||||
color: #bedfdf; |
||||
} |
||||
to { |
||||
color: teal; |
||||
} |
||||
`;
|
||||
return { |
||||
LoadingIndicator: css` |
||||
label: LoadingIndicator; |
||||
animation: ${LoadingIndicatorColorAnim} 1s infinite alternate; |
||||
font-size: 36px; |
||||
/* outline / stroke the loading indicator */ |
||||
text-shadow: -0.5px 0 rgba(0, 128, 128, 0.6), 0 0.5px rgba(0, 128, 128, 0.6), 0.5px 0 rgba(0, 128, 128, 0.6), |
||||
0 -0.5px rgba(0, 128, 128, 0.6); |
||||
`,
|
||||
LoadingIndicatorCentered: css` |
||||
label: LoadingIndicatorCentered; |
||||
display: block; |
||||
margin-left: auto; |
||||
margin-right: auto; |
||||
`,
|
||||
LoadingIndicatorSmall: css` |
||||
label: LoadingIndicatorSmall; |
||||
font-size: 0.7em; |
||||
`,
|
||||
}; |
||||
}); |
||||
|
||||
type LoadingIndicatorProps = { |
||||
centered?: boolean; |
||||
className?: string; |
||||
small?: boolean; |
||||
}; |
||||
|
||||
export default function LoadingIndicator(props: LoadingIndicatorProps) { |
||||
const { centered, className, small, ...rest } = props; |
||||
const styles = getStyles(); |
||||
const cls = cx(styles.LoadingIndicator, { |
||||
[styles.LoadingIndicatorCentered]: centered, |
||||
[styles.LoadingIndicatorSmall]: small, |
||||
className, |
||||
}); |
||||
return <UIIcon type="loading" className={cls} {...rest} />; |
||||
} |
||||
|
||||
LoadingIndicator.defaultProps = { |
||||
centered: false, |
||||
className: undefined, |
||||
small: false, |
||||
}; |
@ -0,0 +1,65 @@ |
||||
// Copyright (c) 2017 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react'; |
||||
import { css } from 'emotion'; |
||||
|
||||
import BreakableText from './BreakableText'; |
||||
import LoadingIndicator from './LoadingIndicator'; |
||||
import { fetchedState, FALLBACK_TRACE_NAME } from '../constants'; |
||||
|
||||
import { FetchedState, TNil } from '../types'; |
||||
import { ApiError } from '../types/api-error'; |
||||
import { createStyle } from '../Theme'; |
||||
|
||||
const getStyles = createStyle(() => { |
||||
return { |
||||
TraceNameError: css` |
||||
color: #c00; |
||||
`,
|
||||
}; |
||||
}); |
||||
|
||||
type Props = { |
||||
className?: string; |
||||
error?: ApiError | TNil; |
||||
state?: FetchedState | TNil; |
||||
traceName?: string | TNil; |
||||
}; |
||||
|
||||
export default function TraceName(props: Props) { |
||||
const { className, error, state, traceName } = props; |
||||
const isErred = state === fetchedState.ERROR; |
||||
let title: string | React.ReactNode = traceName || FALLBACK_TRACE_NAME; |
||||
const styles = getStyles(); |
||||
let errorCssClass = ''; |
||||
if (isErred) { |
||||
errorCssClass = styles.TraceNameError; |
||||
let titleStr = ''; |
||||
if (error) { |
||||
titleStr = typeof error === 'string' ? error : error.message || String(error); |
||||
} |
||||
if (!titleStr) { |
||||
titleStr = 'Error: Unknown error'; |
||||
} |
||||
title = titleStr; |
||||
title = <BreakableText text={titleStr} />; |
||||
} else if (state === fetchedState.LOADING) { |
||||
title = <LoadingIndicator small />; |
||||
} else { |
||||
const text = String(traceName || FALLBACK_TRACE_NAME); |
||||
title = <BreakableText text={text} />; |
||||
} |
||||
return <span className={`TraceName ${errorCssClass} ${className || ''}`}>{title}</span>; |
||||
} |
@ -0,0 +1,65 @@ |
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
import debounceMock from 'lodash/debounce'; |
||||
|
||||
import UiFindInput from './UiFindInput'; |
||||
import {UIInput} from "../uiElementsContext"; |
||||
|
||||
jest.mock('lodash/debounce'); |
||||
|
||||
describe('UiFindInput', () => { |
||||
const flushMock = jest.fn(); |
||||
|
||||
const uiFind = 'uiFind'; |
||||
const ownInputValue = 'ownInputValue'; |
||||
const props = { |
||||
uiFind: undefined, |
||||
history: { |
||||
replace: () => {}, |
||||
}, |
||||
location: { |
||||
search: null, |
||||
}, |
||||
}; |
||||
let wrapper; |
||||
|
||||
beforeAll(() => { |
||||
debounceMock.mockImplementation(fn => { |
||||
function debounceFunction(...args) { |
||||
fn(...args); |
||||
} |
||||
debounceFunction.flush = flushMock; |
||||
return debounceFunction; |
||||
}); |
||||
}); |
||||
|
||||
beforeEach(() => { |
||||
flushMock.mockReset(); |
||||
wrapper = shallow(<UiFindInput {...props} />); |
||||
}); |
||||
|
||||
describe('rendering', () => { |
||||
it('renders as expected', () => { |
||||
expect(wrapper).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('renders props.uiFind when state.ownInputValue is `undefined`', () => { |
||||
wrapper.setProps({value: uiFind}); |
||||
expect(wrapper.find(UIInput).prop('value')).toBe(uiFind); |
||||
}); |
||||
}) |
||||
}); |
@ -0,0 +1,66 @@ |
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as React from 'react'; |
||||
|
||||
import { TNil } from '../types/index'; |
||||
import { UIIcon, UIInput } from '../uiElementsContext'; |
||||
|
||||
type Props = { |
||||
allowClear?: boolean; |
||||
inputProps: Record<string, any>; |
||||
location: Location; |
||||
match: any; |
||||
trackFindFunction?: (str: string | TNil) => void; |
||||
value: string | undefined; |
||||
onChange: (value: string) => void; |
||||
}; |
||||
|
||||
export default class UiFindInput extends React.PureComponent<Props> { |
||||
static defaultProps: Partial<Props> = { |
||||
inputProps: {}, |
||||
trackFindFunction: undefined, |
||||
value: undefined, |
||||
}; |
||||
|
||||
clearUiFind = () => { |
||||
this.props.onChange(''); |
||||
}; |
||||
|
||||
componentWillUnmount(): void { |
||||
console.log('unomuet'); |
||||
} |
||||
|
||||
render() { |
||||
const { allowClear, inputProps, value } = this.props; |
||||
|
||||
const suffix = ( |
||||
<> |
||||
{allowClear && value && value.length && <UIIcon type="close" onClick={this.clearUiFind} />} |
||||
{inputProps.suffix} |
||||
</> |
||||
); |
||||
|
||||
return ( |
||||
<UIInput |
||||
autosize={null} |
||||
placeholder="Find..." |
||||
{...inputProps} |
||||
onChange={e => this.props.onChange(e.target.value)} |
||||
suffix={suffix} |
||||
value={value} |
||||
/> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,10 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`UiFindInput rendering renders as expected 1`] = ` |
||||
<UIInput |
||||
autosize={null} |
||||
onChange={[Function]} |
||||
placeholder="Find..." |
||||
suffix={<React.Fragment />} |
||||
/> |
||||
`; |
@ -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}` : ''; |
||||
} |
@ -0,0 +1,184 @@ |
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import filterSpans from './filter-spans'; |
||||
|
||||
describe('filterSpans', () => { |
||||
// span0 contains strings that end in 0 or 1
|
||||
const spanID0 = 'span-id-0'; |
||||
const span0 = { |
||||
spanID: spanID0, |
||||
operationName: 'operationName0', |
||||
process: { |
||||
serviceName: 'serviceName0', |
||||
tags: [ |
||||
{ |
||||
key: 'processTagKey0', |
||||
value: 'processTagValue0', |
||||
}, |
||||
{ |
||||
key: 'processTagKey1', |
||||
value: 'processTagValue1', |
||||
}, |
||||
], |
||||
}, |
||||
tags: [ |
||||
{ |
||||
key: 'tagKey0', |
||||
value: 'tagValue0', |
||||
}, |
||||
{ |
||||
key: 'tagKey1', |
||||
value: 'tagValue1', |
||||
}, |
||||
], |
||||
logs: [ |
||||
{ |
||||
fields: [ |
||||
{ |
||||
key: 'logFieldKey0', |
||||
value: 'logFieldValue0', |
||||
}, |
||||
{ |
||||
key: 'logFieldKey1', |
||||
value: 'logFieldValue1', |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}; |
||||
// span2 contains strings that end in 1 or 2, for overlap with span0
|
||||
// KVs in span2 have different numbers for key and value to facilitate excludesKey testing
|
||||
const spanID2 = 'span-id-2'; |
||||
const span2 = { |
||||
spanID: spanID2, |
||||
operationName: 'operationName2', |
||||
process: { |
||||
serviceName: 'serviceName2', |
||||
tags: [ |
||||
{ |
||||
key: 'processTagKey2', |
||||
value: 'processTagValue1', |
||||
}, |
||||
{ |
||||
key: 'processTagKey1', |
||||
value: 'processTagValue2', |
||||
}, |
||||
], |
||||
}, |
||||
tags: [ |
||||
{ |
||||
key: 'tagKey2', |
||||
value: 'tagValue1', |
||||
}, |
||||
{ |
||||
key: 'tagKey1', |
||||
value: 'tagValue2', |
||||
}, |
||||
], |
||||
logs: [ |
||||
{ |
||||
fields: [ |
||||
{ |
||||
key: 'logFieldKey2', |
||||
value: 'logFieldValue1', |
||||
}, |
||||
{ |
||||
key: 'logFieldKey1', |
||||
value: 'logFieldValue2', |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}; |
||||
const spans = [span0, span2]; |
||||
|
||||
it('should return `null` if spans is falsy', () => { |
||||
expect(filterSpans('operationName', null)).toBe(null); |
||||
}); |
||||
|
||||
it('should return spans whose spanID exactly match a filter', () => { |
||||
expect(filterSpans('spanID', spans)).toEqual(new Set([])); |
||||
expect(filterSpans(spanID0, spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans(spanID2, spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
it('should return spans whose operationName match a filter', () => { |
||||
expect(filterSpans('operationName', spans)).toEqual(new Set([spanID0, spanID2])); |
||||
expect(filterSpans('operationName0', spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans('operationName2', spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
it('should return spans whose serviceName match a filter', () => { |
||||
expect(filterSpans('serviceName', spans)).toEqual(new Set([spanID0, spanID2])); |
||||
expect(filterSpans('serviceName0', spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans('serviceName2', spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
it("should return spans whose tags' kv.key match a filter", () => { |
||||
expect(filterSpans('tagKey1', spans)).toEqual(new Set([spanID0, spanID2])); |
||||
expect(filterSpans('tagKey0', spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans('tagKey2', spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
it("should return spans whose tags' kv.value match a filter", () => { |
||||
expect(filterSpans('tagValue1', spans)).toEqual(new Set([spanID0, spanID2])); |
||||
expect(filterSpans('tagValue0', spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans('tagValue2', spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
it("should exclude span whose tags' kv.value or kv.key match a filter if the key matches an excludeKey", () => { |
||||
expect(filterSpans('tagValue1 -tagKey2', spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans('tagValue1 -tagKey1', spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
it('should return spans whose logs have a field whose kv.key match a filter', () => { |
||||
expect(filterSpans('logFieldKey1', spans)).toEqual(new Set([spanID0, spanID2])); |
||||
expect(filterSpans('logFieldKey0', spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans('logFieldKey2', spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
it('should return spans whose logs have a field whose kv.value match a filter', () => { |
||||
expect(filterSpans('logFieldValue1', spans)).toEqual(new Set([spanID0, spanID2])); |
||||
expect(filterSpans('logFieldValue0', spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans('logFieldValue2', spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
it('should exclude span whose logs have a field whose kv.value or kv.key match a filter if the key matches an excludeKey', () => { |
||||
expect(filterSpans('logFieldValue1 -logFieldKey2', spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans('logFieldValue1 -logFieldKey1', spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
it("should return spans whose process.tags' kv.key match a filter", () => { |
||||
expect(filterSpans('processTagKey1', spans)).toEqual(new Set([spanID0, spanID2])); |
||||
expect(filterSpans('processTagKey0', spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans('processTagKey2', spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
it("should return spans whose process.processTags' kv.value match a filter", () => { |
||||
expect(filterSpans('processTagValue1', spans)).toEqual(new Set([spanID0, spanID2])); |
||||
expect(filterSpans('processTagValue0', spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans('processTagValue2', spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
it("should exclude span whose process.processTags' kv.value or kv.key match a filter if the key matches an excludeKey", () => { |
||||
expect(filterSpans('processTagValue1 -processTagKey2', spans)).toEqual(new Set([spanID0])); |
||||
expect(filterSpans('processTagValue1 -processTagKey1', spans)).toEqual(new Set([spanID2])); |
||||
}); |
||||
|
||||
// This test may false positive if other tests are failing
|
||||
it('should return an empty set if no spans match the filter', () => { |
||||
expect(filterSpans('-processTagKey1', spans)).toEqual(new Set()); |
||||
}); |
||||
}); |
@ -0,0 +1,67 @@ |
||||
// Copyright (c) 2019 Uber Technologies, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { KeyValuePair, Span } from '../types/trace'; |
||||
import { TNil } from '../types'; |
||||
|
||||
export default function filterSpans(textFilter: string, spans: Span[] | TNil) { |
||||
if (!spans) { |
||||
return null; |
||||
} |
||||
|
||||
// if a span field includes at least one filter in includeFilters, the span is a match
|
||||
const includeFilters: string[] = []; |
||||
|
||||
// values with keys that include text in any one of the excludeKeys will be ignored
|
||||
const excludeKeys: string[] = []; |
||||
|
||||
// split textFilter by whitespace, remove empty strings, and extract includeFilters and excludeKeys
|
||||
textFilter |
||||
.split(/\s+/) |
||||
.filter(Boolean) |
||||
.forEach(w => { |
||||
if (w[0] === '-') { |
||||
excludeKeys.push(w.substr(1).toLowerCase()); |
||||
} else { |
||||
includeFilters.push(w.toLowerCase()); |
||||
} |
||||
}); |
||||
|
||||
const isTextInFilters = (filters: string[], text: string) => |
||||
filters.some(filter => text.toLowerCase().includes(filter)); |
||||
|
||||
const isTextInKeyValues = (kvs: KeyValuePair[]) => |
||||
kvs |
||||
? kvs.some(kv => { |
||||
// ignore checking key and value for a match if key is in excludeKeys
|
||||
if (isTextInFilters(excludeKeys, kv.key)) { |
||||
return false; |
||||
} |
||||
// match if key or value matches an item in includeFilters
|
||||
return isTextInFilters(includeFilters, kv.key) || isTextInFilters(includeFilters, kv.value.toString()); |
||||
}) |
||||
: false; |
||||
|
||||
const isSpanAMatch = (span: Span) => |
||||
isTextInFilters(includeFilters, span.operationName) || |
||||
isTextInFilters(includeFilters, span.process.serviceName) || |
||||
isTextInKeyValues(span.tags) || |
||||
span.logs.some(log => isTextInKeyValues(log.fields)) || |
||||
isTextInKeyValues(span.process.tags) || |
||||
includeFilters.some(filter => filter === span.spanID); |
||||
|
||||
// declare as const because need to disambiguate the type
|
||||
const rv: Set<string> = new Set(spans.filter(isSpanAMatch).map((span: Span) => span.spanID)); |
||||
return rv; |
||||
} |
@ -1,182 +0,0 @@ |
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
import { TraceView } from './TraceView'; |
||||
import { SpanData, TraceData, TraceTimelineViewer } from '@jaegertracing/jaeger-ui-components'; |
||||
|
||||
describe('TraceView', () => { |
||||
it('renders TraceTimelineViewer', () => { |
||||
const wrapper = shallow(<TraceView trace={response} />); |
||||
expect(wrapper.find(TraceTimelineViewer)).toHaveLength(1); |
||||
}); |
||||
|
||||
it('toggles detailState', () => { |
||||
const wrapper = shallow(<TraceView trace={response} />); |
||||
let viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.detailStates.size).toBe(0); |
||||
|
||||
viewer.props().detailToggle('1'); |
||||
viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.detailStates.size).toBe(1); |
||||
expect(viewer.props().traceTimeline.detailStates.get('1')).not.toBeUndefined(); |
||||
|
||||
viewer.props().detailToggle('1'); |
||||
viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.detailStates.size).toBe(0); |
||||
}); |
||||
|
||||
it('toggles children visibility', () => { |
||||
const wrapper = shallow(<TraceView trace={response} />); |
||||
let viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
|
||||
viewer.props().childrenToggle('1'); |
||||
viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(1); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.has('1')).toBeTruthy(); |
||||
|
||||
viewer.props().childrenToggle('1'); |
||||
viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
}); |
||||
|
||||
it('toggles adds and removes hover indent guides', () => { |
||||
const wrapper = shallow(<TraceView trace={response} />); |
||||
let viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.hoverIndentGuideIds.size).toBe(0); |
||||
|
||||
viewer.props().addHoverIndentGuideId('1'); |
||||
viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.hoverIndentGuideIds.size).toBe(1); |
||||
expect(viewer.props().traceTimeline.hoverIndentGuideIds.has('1')).toBeTruthy(); |
||||
|
||||
viewer.props().removeHoverIndentGuideId('1'); |
||||
viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.hoverIndentGuideIds.size).toBe(0); |
||||
}); |
||||
|
||||
it('toggles collapses and expands one level of spans', () => { |
||||
const wrapper = shallow(<TraceView trace={response} />); |
||||
let viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
const spans = viewer.props().trace.spans; |
||||
|
||||
viewer.props().collapseOne(spans); |
||||
viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(1); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.has('3fb050342773d333')).toBeTruthy(); |
||||
|
||||
viewer.props().expandOne(spans); |
||||
viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
}); |
||||
|
||||
it('toggles collapses and expands all levels', () => { |
||||
const wrapper = shallow(<TraceView trace={response} />); |
||||
let viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
const spans = viewer.props().trace.spans; |
||||
|
||||
viewer.props().collapseAll(spans); |
||||
viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(2); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.has('3fb050342773d333')).toBeTruthy(); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.has('1ed38015486087ca')).toBeTruthy(); |
||||
|
||||
viewer.props().expandAll(); |
||||
viewer = wrapper.find(TraceTimelineViewer); |
||||
expect(viewer.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
}); |
||||
}); |
||||
|
||||
const response: TraceData & { spans: SpanData[] } = { |
||||
traceID: '1ed38015486087ca', |
||||
spans: [ |
||||
{ |
||||
traceID: '1ed38015486087ca', |
||||
spanID: '1ed38015486087ca', |
||||
flags: 1, |
||||
operationName: 'HTTP POST - api_prom_push', |
||||
references: [] as any, |
||||
startTime: 1585244579835187, |
||||
duration: 1098, |
||||
tags: [ |
||||
{ key: 'sampler.type', type: 'string', value: 'const' }, |
||||
{ key: 'sampler.param', type: 'bool', value: true }, |
||||
{ key: 'span.kind', type: 'string', value: 'server' }, |
||||
{ key: 'http.method', type: 'string', value: 'POST' }, |
||||
{ key: 'http.url', type: 'string', value: '/api/prom/push' }, |
||||
{ key: 'component', type: 'string', value: 'net/http' }, |
||||
{ key: 'http.status_code', type: 'int64', value: 204 }, |
||||
{ key: 'internal.span.format', type: 'string', value: 'proto' }, |
||||
], |
||||
logs: [ |
||||
{ |
||||
timestamp: 1585244579835229, |
||||
fields: [{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[start reading]' }], |
||||
}, |
||||
{ |
||||
timestamp: 1585244579835241, |
||||
fields: [ |
||||
{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[decompress]' }, |
||||
{ key: 'size', type: 'int64', value: 315 }, |
||||
], |
||||
}, |
||||
{ |
||||
timestamp: 1585244579835245, |
||||
fields: [ |
||||
{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[unmarshal]' }, |
||||
{ key: 'size', type: 'int64', value: 446 }, |
||||
], |
||||
}, |
||||
], |
||||
processID: 'p1', |
||||
warnings: null as any, |
||||
}, |
||||
{ |
||||
traceID: '1ed38015486087ca', |
||||
spanID: '3fb050342773d333', |
||||
flags: 1, |
||||
operationName: '/logproto.Pusher/Push', |
||||
references: [{ refType: 'CHILD_OF', traceID: '1ed38015486087ca', spanID: '1ed38015486087ca' }], |
||||
startTime: 1585244579835341, |
||||
duration: 921, |
||||
tags: [ |
||||
{ key: 'span.kind', type: 'string', value: 'client' }, |
||||
{ key: 'component', type: 'string', value: 'gRPC' }, |
||||
{ key: 'internal.span.format', type: 'string', value: 'proto' }, |
||||
], |
||||
logs: [], |
||||
processID: 'p1', |
||||
warnings: null, |
||||
}, |
||||
{ |
||||
traceID: '1ed38015486087ca', |
||||
spanID: '35118c298fc91f68', |
||||
flags: 1, |
||||
operationName: '/logproto.Pusher/Push', |
||||
references: [{ refType: 'CHILD_OF', traceID: '1ed38015486087ca', spanID: '3fb050342773d333' }], |
||||
startTime: 1585244579836040, |
||||
duration: 36, |
||||
tags: [ |
||||
{ key: 'span.kind', type: 'string', value: 'server' }, |
||||
{ key: 'component', type: 'string', value: 'gRPC' }, |
||||
{ key: 'internal.span.format', type: 'string', value: 'proto' }, |
||||
], |
||||
logs: [] as any, |
||||
processID: 'p1', |
||||
warnings: null as any, |
||||
}, |
||||
], |
||||
processes: { |
||||
p1: { |
||||
serviceName: 'loki-all', |
||||
tags: [ |
||||
{ key: 'client-uuid', type: 'string', value: '2a59d08899ef6a8a' }, |
||||
{ key: 'hostname', type: 'string', value: '0080b530fae3' }, |
||||
{ key: 'ip', type: 'string', value: '172.18.0.6' }, |
||||
{ key: 'jaeger.version', type: 'string', value: 'Go-2.20.1' }, |
||||
], |
||||
}, |
||||
}, |
||||
warnings: null as any, |
||||
}; |
@ -1,247 +0,0 @@ |
||||
import { |
||||
DetailState, |
||||
KeyValuePair, |
||||
Link, |
||||
Log, |
||||
Span, |
||||
// SpanData,
|
||||
// SpanReference,
|
||||
Trace, |
||||
TraceTimelineViewer, |
||||
TTraceTimeline, |
||||
UIElementsContext, |
||||
ViewRangeTimeUpdate, |
||||
transformTraceData, |
||||
SpanData, |
||||
TraceData, |
||||
} from '@jaegertracing/jaeger-ui-components'; |
||||
import React, { useState } from 'react'; |
||||
|
||||
type Props = { |
||||
trace: TraceData & { spans: SpanData[] }; |
||||
}; |
||||
|
||||
export function TraceView(props: Props) { |
||||
/** |
||||
* Track whether details are open per span. |
||||
*/ |
||||
const [detailStates, setDetailStates] = useState(new Map<string, DetailState>()); |
||||
|
||||
/** |
||||
* Track whether span is collapsed, meaning its children spans are hidden. |
||||
*/ |
||||
const [childrenHiddenIDs, setChildrenHiddenIDs] = useState(new Set<string>()); |
||||
|
||||
/** |
||||
* For some reason this is used internally to handle hover state of indent guide. As indent guides are separate |
||||
* components per each row/span and you need to highlight all in multiple rows to make the effect of single line |
||||
* they need this kind of common imperative state changes. |
||||
* |
||||
* Ideally would be changed to trace view internal state. |
||||
*/ |
||||
const [hoverIndentGuideIds, setHoverIndentGuideIds] = useState(new Set<string>()); |
||||
|
||||
/** |
||||
* Keeps state of resizable name column |
||||
*/ |
||||
const [spanNameColumnWidth, setSpanNameColumnWidth] = useState(0.25); |
||||
|
||||
function toggleDetail(spanID: string) { |
||||
const newDetailStates = new Map(detailStates); |
||||
if (newDetailStates.has(spanID)) { |
||||
newDetailStates.delete(spanID); |
||||
} else { |
||||
newDetailStates.set(spanID, new DetailState()); |
||||
} |
||||
setDetailStates(newDetailStates); |
||||
} |
||||
|
||||
function expandOne(spans: Span[]) { |
||||
if (childrenHiddenIDs.size === 0) { |
||||
return; |
||||
} |
||||
let prevExpandedDepth = -1; |
||||
let expandNextHiddenSpan = true; |
||||
const newChildrenHiddenIDs = spans.reduce((res, s) => { |
||||
if (s.depth <= prevExpandedDepth) { |
||||
expandNextHiddenSpan = true; |
||||
} |
||||
if (expandNextHiddenSpan && res.has(s.spanID)) { |
||||
res.delete(s.spanID); |
||||
expandNextHiddenSpan = false; |
||||
prevExpandedDepth = s.depth; |
||||
} |
||||
return res; |
||||
}, new Set(childrenHiddenIDs)); |
||||
setChildrenHiddenIDs(newChildrenHiddenIDs); |
||||
} |
||||
|
||||
function collapseOne(spans: Span[]) { |
||||
if (shouldDisableCollapse(spans, childrenHiddenIDs)) { |
||||
return; |
||||
} |
||||
let nearestCollapsedAncestor: Span | undefined; |
||||
const newChildrenHiddenIDs = spans.reduce((res, curSpan) => { |
||||
if (nearestCollapsedAncestor && curSpan.depth <= nearestCollapsedAncestor.depth) { |
||||
res.add(nearestCollapsedAncestor.spanID); |
||||
if (curSpan.hasChildren) { |
||||
nearestCollapsedAncestor = curSpan; |
||||
} |
||||
} else if (curSpan.hasChildren && !res.has(curSpan.spanID)) { |
||||
nearestCollapsedAncestor = curSpan; |
||||
} |
||||
return res; |
||||
}, new Set(childrenHiddenIDs)); |
||||
// The last one
|
||||
if (nearestCollapsedAncestor) { |
||||
newChildrenHiddenIDs.add(nearestCollapsedAncestor.spanID); |
||||
} |
||||
setChildrenHiddenIDs(newChildrenHiddenIDs); |
||||
} |
||||
|
||||
function expandAll() { |
||||
setChildrenHiddenIDs(new Set<string>()); |
||||
} |
||||
|
||||
function collapseAll(spans: Span[]) { |
||||
if (shouldDisableCollapse(spans, childrenHiddenIDs)) { |
||||
return; |
||||
} |
||||
const newChildrenHiddenIDs = spans.reduce((res, s) => { |
||||
if (s.hasChildren) { |
||||
res.add(s.spanID); |
||||
} |
||||
return res; |
||||
}, new Set<string>()); |
||||
|
||||
setChildrenHiddenIDs(newChildrenHiddenIDs); |
||||
} |
||||
|
||||
function childrenToggle(spanID: string) { |
||||
const newChildrenHiddenIDs = new Set(childrenHiddenIDs); |
||||
if (childrenHiddenIDs.has(spanID)) { |
||||
newChildrenHiddenIDs.delete(spanID); |
||||
} else { |
||||
newChildrenHiddenIDs.add(spanID); |
||||
} |
||||
setChildrenHiddenIDs(newChildrenHiddenIDs); |
||||
} |
||||
|
||||
function detailLogItemToggle(spanID: string, log: Log) { |
||||
const old = detailStates.get(spanID); |
||||
if (!old) { |
||||
return; |
||||
} |
||||
const detailState = old.toggleLogItem(log); |
||||
const newDetailStates = new Map(detailStates); |
||||
newDetailStates.set(spanID, detailState); |
||||
return setDetailStates(newDetailStates); |
||||
} |
||||
|
||||
function addHoverIndentGuideId(spanID: string) { |
||||
setHoverIndentGuideIds(prevState => { |
||||
const newHoverIndentGuideIds = new Set(prevState); |
||||
newHoverIndentGuideIds.add(spanID); |
||||
return newHoverIndentGuideIds; |
||||
}); |
||||
} |
||||
|
||||
function removeHoverIndentGuideId(spanID: string) { |
||||
setHoverIndentGuideIds(prevState => { |
||||
const newHoverIndentGuideIds = new Set(prevState); |
||||
newHoverIndentGuideIds.delete(spanID); |
||||
return newHoverIndentGuideIds; |
||||
}); |
||||
} |
||||
|
||||
const traceProp = transformTraceData(props.trace); |
||||
|
||||
return ( |
||||
<UIElementsContext.Provider |
||||
value={{ |
||||
Popover: (() => null as any) as any, |
||||
Tooltip: (() => null as any) as any, |
||||
Icon: (() => null as any) as any, |
||||
Dropdown: (() => null as any) as any, |
||||
Menu: (() => null as any) as any, |
||||
MenuItem: (() => null as any) as any, |
||||
Button: (() => null as any) as any, |
||||
Divider: (() => null as any) as any, |
||||
}} |
||||
> |
||||
<TraceTimelineViewer |
||||
registerAccessors={() => {}} |
||||
scrollToFirstVisibleSpan={() => {}} |
||||
findMatchesIDs={null} |
||||
trace={traceProp} |
||||
traceTimeline={ |
||||
{ |
||||
childrenHiddenIDs, |
||||
detailStates, |
||||
hoverIndentGuideIds, |
||||
shouldScrollToFirstUiFindMatch: false, |
||||
spanNameColumnWidth, |
||||
traceID: '50b96206cf81dd64', |
||||
} as TTraceTimeline |
||||
} |
||||
updateNextViewRangeTime={(update: ViewRangeTimeUpdate) => {}} |
||||
updateViewRangeTime={() => {}} |
||||
viewRange={{ time: { current: [0, 1], cursor: null } }} |
||||
focusSpan={() => {}} |
||||
createLinkToExternalSpan={() => ''} |
||||
setSpanNameColumnWidth={setSpanNameColumnWidth} |
||||
collapseAll={collapseAll} |
||||
collapseOne={collapseOne} |
||||
expandAll={expandAll} |
||||
expandOne={expandOne} |
||||
childrenToggle={childrenToggle} |
||||
clearShouldScrollToFirstUiFindMatch={() => {}} |
||||
detailLogItemToggle={detailLogItemToggle} |
||||
detailLogsToggle={makeDetailSubsectionToggle('logs', detailStates, setDetailStates)} |
||||
detailWarningsToggle={makeDetailSubsectionToggle('warnings', detailStates, setDetailStates)} |
||||
detailReferencesToggle={makeDetailSubsectionToggle('references', detailStates, setDetailStates)} |
||||
detailProcessToggle={makeDetailSubsectionToggle('process', detailStates, setDetailStates)} |
||||
detailTagsToggle={makeDetailSubsectionToggle('tags', detailStates, setDetailStates)} |
||||
detailToggle={toggleDetail} |
||||
setTrace={(trace: Trace | null, uiFind: string | null) => {}} |
||||
addHoverIndentGuideId={addHoverIndentGuideId} |
||||
removeHoverIndentGuideId={removeHoverIndentGuideId} |
||||
linksGetter={(span: Span, items: KeyValuePair[], itemIndex: number) => [] as Link[]} |
||||
uiFind={undefined} |
||||
/> |
||||
</UIElementsContext.Provider> |
||||
); |
||||
} |
||||
|
||||
function shouldDisableCollapse(allSpans: Span[], hiddenSpansIds: Set<string>) { |
||||
const allParentSpans = allSpans.filter(s => s.hasChildren); |
||||
return allParentSpans.length === hiddenSpansIds.size; |
||||
} |
||||
|
||||
function makeDetailSubsectionToggle( |
||||
subSection: 'tags' | 'process' | 'logs' | 'warnings' | 'references', |
||||
detailStates: Map<string, DetailState>, |
||||
setDetailStates: (detailStates: Map<string, DetailState>) => void |
||||
) { |
||||
return (spanID: string) => { |
||||
const old = detailStates.get(spanID); |
||||
if (!old) { |
||||
return; |
||||
} |
||||
let detailState; |
||||
if (subSection === 'tags') { |
||||
detailState = old.toggleTags(); |
||||
} else if (subSection === 'process') { |
||||
detailState = old.toggleProcess(); |
||||
} else if (subSection === 'warnings') { |
||||
detailState = old.toggleWarnings(); |
||||
} else if (subSection === 'references') { |
||||
detailState = old.toggleReferences(); |
||||
} else { |
||||
detailState = old.toggleLogs(); |
||||
} |
||||
const newDetailStates = new Map(detailStates); |
||||
newDetailStates.set(spanID, detailState); |
||||
setDetailStates(newDetailStates); |
||||
}; |
||||
} |
@ -0,0 +1,216 @@ |
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
import { TraceView } from './TraceView'; |
||||
import { SpanData, TraceData, TracePageHeader, TraceTimelineViewer } from '@jaegertracing/jaeger-ui-components'; |
||||
|
||||
function renderTraceView() { |
||||
const wrapper = shallow(<TraceView trace={response} />); |
||||
return { |
||||
timeline: wrapper.find(TraceTimelineViewer), |
||||
header: wrapper.find(TracePageHeader), |
||||
wrapper, |
||||
}; |
||||
} |
||||
|
||||
describe('TraceView', () => { |
||||
it('renders TraceTimelineViewer', () => { |
||||
const { timeline, header } = renderTraceView(); |
||||
expect(timeline).toHaveLength(1); |
||||
expect(header).toHaveLength(1); |
||||
}); |
||||
|
||||
it('toggles detailState', () => { |
||||
let { timeline, wrapper } = renderTraceView(); |
||||
expect(timeline.props().traceTimeline.detailStates.size).toBe(0); |
||||
|
||||
timeline.props().detailToggle('1'); |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
expect(timeline.props().traceTimeline.detailStates.size).toBe(1); |
||||
expect(timeline.props().traceTimeline.detailStates.get('1')).not.toBeUndefined(); |
||||
|
||||
timeline.props().detailToggle('1'); |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
expect(timeline.props().traceTimeline.detailStates.size).toBe(0); |
||||
}); |
||||
|
||||
it('toggles children visibility', () => { |
||||
let { timeline, wrapper } = renderTraceView(); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
|
||||
timeline.props().childrenToggle('1'); |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(1); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('1')).toBeTruthy(); |
||||
|
||||
timeline.props().childrenToggle('1'); |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
}); |
||||
|
||||
it('toggles adds and removes hover indent guides', () => { |
||||
let { timeline, wrapper } = renderTraceView(); |
||||
expect(timeline.props().traceTimeline.hoverIndentGuideIds.size).toBe(0); |
||||
|
||||
timeline.props().addHoverIndentGuideId('1'); |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
expect(timeline.props().traceTimeline.hoverIndentGuideIds.size).toBe(1); |
||||
expect(timeline.props().traceTimeline.hoverIndentGuideIds.has('1')).toBeTruthy(); |
||||
|
||||
timeline.props().removeHoverIndentGuideId('1'); |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
expect(timeline.props().traceTimeline.hoverIndentGuideIds.size).toBe(0); |
||||
}); |
||||
|
||||
it('toggles collapses and expands one level of spans', () => { |
||||
let { timeline, wrapper } = renderTraceView(); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
const spans = timeline.props().trace.spans; |
||||
|
||||
timeline.props().collapseOne(spans); |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(1); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('3fb050342773d333')).toBeTruthy(); |
||||
|
||||
timeline.props().expandOne(spans); |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
}); |
||||
|
||||
it('toggles collapses and expands all levels', () => { |
||||
let { timeline, wrapper } = renderTraceView(); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
const spans = timeline.props().trace.spans; |
||||
|
||||
timeline.props().collapseAll(spans); |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(2); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('3fb050342773d333')).toBeTruthy(); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('1ed38015486087ca')).toBeTruthy(); |
||||
|
||||
timeline.props().expandAll(); |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0); |
||||
}); |
||||
|
||||
it('searches for spans', () => { |
||||
let { wrapper, header } = renderTraceView(); |
||||
header.props().onSearchValueChange('HTTP POST - api_prom_push'); |
||||
|
||||
const timeline = wrapper.find(TraceTimelineViewer); |
||||
expect(timeline.props().findMatchesIDs.has('1ed38015486087ca')).toBeTruthy(); |
||||
}); |
||||
|
||||
it('change viewRange', () => { |
||||
let { header, timeline, wrapper } = renderTraceView(); |
||||
const defaultRange = { time: { current: [0, 1] } }; |
||||
expect(timeline.props().viewRange).toEqual(defaultRange); |
||||
expect(header.props().viewRange).toEqual(defaultRange); |
||||
header.props().updateViewRangeTime(0.2, 0.8); |
||||
|
||||
let newRange = { time: { current: [0.2, 0.8] } }; |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
header = wrapper.find(TracePageHeader); |
||||
expect(timeline.props().viewRange).toEqual(newRange); |
||||
expect(header.props().viewRange).toEqual(newRange); |
||||
|
||||
newRange = { time: { current: [0.3, 0.7] } }; |
||||
timeline.props().updateViewRangeTime(0.3, 0.7); |
||||
timeline = wrapper.find(TraceTimelineViewer); |
||||
header = wrapper.find(TracePageHeader); |
||||
expect(timeline.props().viewRange).toEqual(newRange); |
||||
expect(header.props().viewRange).toEqual(newRange); |
||||
}); |
||||
}); |
||||
|
||||
const response: TraceData & { spans: SpanData[] } = { |
||||
traceID: '1ed38015486087ca', |
||||
spans: [ |
||||
{ |
||||
traceID: '1ed38015486087ca', |
||||
spanID: '1ed38015486087ca', |
||||
flags: 1, |
||||
operationName: 'HTTP POST - api_prom_push', |
||||
references: [] as any, |
||||
startTime: 1585244579835187, |
||||
duration: 1098, |
||||
tags: [ |
||||
{ key: 'sampler.type', type: 'string', value: 'const' }, |
||||
{ key: 'sampler.param', type: 'bool', value: true }, |
||||
{ key: 'span.kind', type: 'string', value: 'server' }, |
||||
{ key: 'http.method', type: 'string', value: 'POST' }, |
||||
{ key: 'http.url', type: 'string', value: '/api/prom/push' }, |
||||
{ key: 'component', type: 'string', value: 'net/http' }, |
||||
{ key: 'http.status_code', type: 'int64', value: 204 }, |
||||
{ key: 'internal.span.format', type: 'string', value: 'proto' }, |
||||
], |
||||
logs: [ |
||||
{ |
||||
timestamp: 1585244579835229, |
||||
fields: [{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[start reading]' }], |
||||
}, |
||||
{ |
||||
timestamp: 1585244579835241, |
||||
fields: [ |
||||
{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[decompress]' }, |
||||
{ key: 'size', type: 'int64', value: 315 }, |
||||
], |
||||
}, |
||||
{ |
||||
timestamp: 1585244579835245, |
||||
fields: [ |
||||
{ key: 'event', type: 'string', value: 'util.ParseProtoRequest[unmarshal]' }, |
||||
{ key: 'size', type: 'int64', value: 446 }, |
||||
], |
||||
}, |
||||
], |
||||
processID: 'p1', |
||||
warnings: null as any, |
||||
}, |
||||
{ |
||||
traceID: '1ed38015486087ca', |
||||
spanID: '3fb050342773d333', |
||||
flags: 1, |
||||
operationName: '/logproto.Pusher/Push', |
||||
references: [{ refType: 'CHILD_OF', traceID: '1ed38015486087ca', spanID: '1ed38015486087ca' }], |
||||
startTime: 1585244579835341, |
||||
duration: 921, |
||||
tags: [ |
||||
{ key: 'span.kind', type: 'string', value: 'client' }, |
||||
{ key: 'component', type: 'string', value: 'gRPC' }, |
||||
{ key: 'internal.span.format', type: 'string', value: 'proto' }, |
||||
], |
||||
logs: [], |
||||
processID: 'p1', |
||||
warnings: null, |
||||
}, |
||||
{ |
||||
traceID: '1ed38015486087ca', |
||||
spanID: '35118c298fc91f68', |
||||
flags: 1, |
||||
operationName: '/logproto.Pusher/Push', |
||||
references: [{ refType: 'CHILD_OF', traceID: '1ed38015486087ca', spanID: '3fb050342773d333' }], |
||||
startTime: 1585244579836040, |
||||
duration: 36, |
||||
tags: [ |
||||
{ key: 'span.kind', type: 'string', value: 'server' }, |
||||
{ key: 'component', type: 'string', value: 'gRPC' }, |
||||
{ key: 'internal.span.format', type: 'string', value: 'proto' }, |
||||
], |
||||
logs: [] as any, |
||||
processID: 'p1', |
||||
warnings: null as any, |
||||
}, |
||||
], |
||||
processes: { |
||||
p1: { |
||||
serviceName: 'loki-all', |
||||
tags: [ |
||||
{ key: 'client-uuid', type: 'string', value: '2a59d08899ef6a8a' }, |
||||
{ key: 'hostname', type: 'string', value: '0080b530fae3' }, |
||||
{ key: 'ip', type: 'string', value: '172.18.0.6' }, |
||||
{ key: 'jaeger.version', type: 'string', value: 'Go-2.20.1' }, |
||||
], |
||||
}, |
||||
}, |
||||
warnings: null as any, |
||||
}; |
@ -0,0 +1,119 @@ |
||||
import React, { useState } from 'react'; |
||||
import { |
||||
KeyValuePair, |
||||
Link, |
||||
Span, |
||||
Trace, |
||||
TraceTimelineViewer, |
||||
TTraceTimeline, |
||||
UIElementsContext, |
||||
transformTraceData, |
||||
SpanData, |
||||
TraceData, |
||||
TracePageHeader, |
||||
} from '@jaegertracing/jaeger-ui-components'; |
||||
import { UIElements } from './uiElements'; |
||||
import { useViewRange } from './useViewRange'; |
||||
import { useSearch } from './useSearch'; |
||||
import { useChildrenState } from './useChildrenState'; |
||||
import { useDetailState } from './useDetailState'; |
||||
import { useHoverIndentGuide } from './useHoverIndentGuide'; |
||||
|
||||
type Props = { |
||||
trace: TraceData & { spans: SpanData[] }; |
||||
}; |
||||
|
||||
export function TraceView(props: Props) { |
||||
const { expandOne, collapseOne, childrenToggle, collapseAll, childrenHiddenIDs, expandAll } = useChildrenState(); |
||||
const { |
||||
detailStates, |
||||
toggleDetail, |
||||
detailLogItemToggle, |
||||
detailLogsToggle, |
||||
detailProcessToggle, |
||||
detailReferencesToggle, |
||||
detailTagsToggle, |
||||
detailWarningsToggle, |
||||
} = useDetailState(); |
||||
const { removeHoverIndentGuideId, addHoverIndentGuideId, hoverIndentGuideIds } = useHoverIndentGuide(); |
||||
const { viewRange, updateViewRangeTime, updateNextViewRangeTime } = useViewRange(); |
||||
|
||||
/** |
||||
* Keeps state of resizable name column width |
||||
*/ |
||||
const [spanNameColumnWidth, setSpanNameColumnWidth] = useState(0.25); |
||||
/** |
||||
* State of the top minimap, slim means it is collapsed. |
||||
*/ |
||||
const [slim, setSlim] = useState(false); |
||||
|
||||
const traceProp = transformTraceData(props.trace); |
||||
const { search, setSearch, spanFindMatches } = useSearch(traceProp?.spans); |
||||
|
||||
return ( |
||||
<UIElementsContext.Provider value={UIElements}> |
||||
<TracePageHeader |
||||
canCollapse={true} |
||||
clearSearch={() => {}} |
||||
focusUiFindMatches={() => {}} |
||||
hideMap={false} |
||||
hideSummary={false} |
||||
nextResult={() => {}} |
||||
onSlimViewClicked={() => setSlim(!slim)} |
||||
onTraceGraphViewClicked={() => {}} |
||||
prevResult={() => {}} |
||||
resultCount={0} |
||||
slimView={slim} |
||||
textFilter={null} |
||||
trace={traceProp} |
||||
traceGraphView={false} |
||||
updateNextViewRangeTime={updateNextViewRangeTime} |
||||
updateViewRangeTime={updateViewRangeTime} |
||||
viewRange={viewRange} |
||||
searchValue={search} |
||||
onSearchValueChange={setSearch} |
||||
hideSearchButtons={true} |
||||
/> |
||||
<TraceTimelineViewer |
||||
registerAccessors={() => {}} |
||||
scrollToFirstVisibleSpan={() => {}} |
||||
findMatchesIDs={spanFindMatches} |
||||
trace={traceProp} |
||||
traceTimeline={ |
||||
{ |
||||
childrenHiddenIDs, |
||||
detailStates, |
||||
hoverIndentGuideIds, |
||||
shouldScrollToFirstUiFindMatch: false, |
||||
spanNameColumnWidth, |
||||
traceID: '50b96206cf81dd64', |
||||
} as TTraceTimeline |
||||
} |
||||
updateNextViewRangeTime={updateNextViewRangeTime} |
||||
updateViewRangeTime={updateViewRangeTime} |
||||
viewRange={viewRange} |
||||
focusSpan={() => {}} |
||||
createLinkToExternalSpan={() => ''} |
||||
setSpanNameColumnWidth={setSpanNameColumnWidth} |
||||
collapseAll={collapseAll} |
||||
collapseOne={collapseOne} |
||||
expandAll={expandAll} |
||||
expandOne={expandOne} |
||||
childrenToggle={childrenToggle} |
||||
clearShouldScrollToFirstUiFindMatch={() => {}} |
||||
detailLogItemToggle={detailLogItemToggle} |
||||
detailLogsToggle={detailLogsToggle} |
||||
detailWarningsToggle={detailWarningsToggle} |
||||
detailReferencesToggle={detailReferencesToggle} |
||||
detailProcessToggle={detailProcessToggle} |
||||
detailTagsToggle={detailTagsToggle} |
||||
detailToggle={toggleDetail} |
||||
setTrace={(trace: Trace | null, uiFind: string | null) => {}} |
||||
addHoverIndentGuideId={addHoverIndentGuideId} |
||||
removeHoverIndentGuideId={removeHoverIndentGuideId} |
||||
linksGetter={(span: Span, items: KeyValuePair[], itemIndex: number) => [] as Link[]} |
||||
uiFind={search} |
||||
/> |
||||
</UIElementsContext.Provider> |
||||
); |
||||
} |
@ -0,0 +1,45 @@ |
||||
import React from 'react'; |
||||
import { ButtonProps, Elements } from '@jaegertracing/jaeger-ui-components'; |
||||
import { Button, Input } from '@grafana/ui'; |
||||
|
||||
/** |
||||
* Right now Jaeger components need some UI elements to be injected. This is to get rid of AntD UI library that was |
||||
* used by default. |
||||
*/ |
||||
|
||||
// This needs to be static to prevent remounting on every render.
|
||||
export const UIElements: Elements = { |
||||
Popover: (() => null as any) as any, |
||||
Tooltip: (() => null as any) as any, |
||||
Icon: (() => null as any) as any, |
||||
Dropdown: (() => null as any) as any, |
||||
Menu: (() => null as any) as any, |
||||
MenuItem: (() => null as any) as any, |
||||
Button: ({ onClick, children, className }: ButtonProps) => ( |
||||
<Button variant={'secondary'} onClick={onClick} className={className}> |
||||
{children} |
||||
</Button> |
||||
), |
||||
Divider, |
||||
Input: props => <Input {...props} />, |
||||
InputGroup: ({ children, className, style }) => ( |
||||
<span className={className} style={style}> |
||||
{children} |
||||
</span> |
||||
), |
||||
}; |
||||
|
||||
function Divider({ className }: { className?: string }) { |
||||
return ( |
||||
<div |
||||
style={{ |
||||
display: 'inline-block', |
||||
background: '#e8e8e8', |
||||
width: '1px', |
||||
height: '0.9em', |
||||
margin: '0 8px', |
||||
}} |
||||
className={className} |
||||
/> |
||||
); |
||||
} |
@ -0,0 +1,65 @@ |
||||
import { renderHook, act } from '@testing-library/react-hooks'; |
||||
import { useChildrenState } from './useChildrenState'; |
||||
import { Span } from '@jaegertracing/jaeger-ui-components'; |
||||
|
||||
describe('useChildrenState', () => { |
||||
describe('childrenToggle', () => { |
||||
it('toggles children state', async () => { |
||||
const { result } = renderHook(() => useChildrenState()); |
||||
expect(result.current.childrenHiddenIDs.size).toBe(0); |
||||
act(() => result.current.childrenToggle('testId')); |
||||
|
||||
expect(result.current.childrenHiddenIDs.size).toBe(1); |
||||
expect(result.current.childrenHiddenIDs.has('testId')).toBe(true); |
||||
|
||||
act(() => result.current.childrenToggle('testId')); |
||||
|
||||
expect(result.current.childrenHiddenIDs.size).toBe(0); |
||||
}); |
||||
}); |
||||
|
||||
describe('expandAll', () => { |
||||
it('expands all', async () => { |
||||
const { result } = renderHook(() => useChildrenState()); |
||||
act(() => result.current.childrenToggle('testId1')); |
||||
act(() => result.current.childrenToggle('testId2')); |
||||
|
||||
expect(result.current.childrenHiddenIDs.size).toBe(2); |
||||
|
||||
act(() => result.current.expandAll()); |
||||
|
||||
expect(result.current.childrenHiddenIDs.size).toBe(0); |
||||
}); |
||||
}); |
||||
|
||||
describe('collapseAll', () => { |
||||
it('hides spans that have children', async () => { |
||||
const { result } = renderHook(() => useChildrenState()); |
||||
act(() => |
||||
result.current.collapseAll([ |
||||
{ spanID: 'span1', hasChildren: true } as Span, |
||||
{ spanID: 'span2', hasChildren: false } as Span, |
||||
]) |
||||
); |
||||
|
||||
expect(result.current.childrenHiddenIDs.size).toBe(1); |
||||
expect(result.current.childrenHiddenIDs.has('span1')).toBe(true); |
||||
}); |
||||
|
||||
it('does nothing if already collapsed', async () => { |
||||
const { result } = renderHook(() => useChildrenState()); |
||||
act(() => result.current.childrenToggle('span1')); |
||||
act(() => |
||||
result.current.collapseAll([ |
||||
{ spanID: 'span1', hasChildren: true } as Span, |
||||
{ spanID: 'span2', hasChildren: false } as Span, |
||||
]) |
||||
); |
||||
|
||||
expect(result.current.childrenHiddenIDs.size).toBe(1); |
||||
expect(result.current.childrenHiddenIDs.has('span1')).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
// Other function are not yet used.
|
||||
}); |
@ -0,0 +1,94 @@ |
||||
import { useState } from 'react'; |
||||
import { Span } from '@jaegertracing/jaeger-ui-components'; |
||||
|
||||
/** |
||||
* Children state means whether spans are collapsed or not. Also provides some functions to manipulate that state. |
||||
*/ |
||||
export function useChildrenState() { |
||||
const [childrenHiddenIDs, setChildrenHiddenIDs] = useState(new Set<string>()); |
||||
|
||||
function expandOne(spans: Span[]) { |
||||
if (childrenHiddenIDs.size === 0) { |
||||
return; |
||||
} |
||||
let prevExpandedDepth = -1; |
||||
let expandNextHiddenSpan = true; |
||||
const newChildrenHiddenIDs = spans.reduce((res, s) => { |
||||
if (s.depth <= prevExpandedDepth) { |
||||
expandNextHiddenSpan = true; |
||||
} |
||||
if (expandNextHiddenSpan && res.has(s.spanID)) { |
||||
res.delete(s.spanID); |
||||
expandNextHiddenSpan = false; |
||||
prevExpandedDepth = s.depth; |
||||
} |
||||
return res; |
||||
}, new Set(childrenHiddenIDs)); |
||||
setChildrenHiddenIDs(newChildrenHiddenIDs); |
||||
} |
||||
|
||||
function collapseOne(spans: Span[]) { |
||||
if (shouldDisableCollapse(spans, childrenHiddenIDs)) { |
||||
return; |
||||
} |
||||
let nearestCollapsedAncestor: Span | undefined; |
||||
const newChildrenHiddenIDs = spans.reduce((res, curSpan) => { |
||||
if (nearestCollapsedAncestor && curSpan.depth <= nearestCollapsedAncestor.depth) { |
||||
res.add(nearestCollapsedAncestor.spanID); |
||||
if (curSpan.hasChildren) { |
||||
nearestCollapsedAncestor = curSpan; |
||||
} |
||||
} else if (curSpan.hasChildren && !res.has(curSpan.spanID)) { |
||||
nearestCollapsedAncestor = curSpan; |
||||
} |
||||
return res; |
||||
}, new Set(childrenHiddenIDs)); |
||||
// The last one
|
||||
if (nearestCollapsedAncestor) { |
||||
newChildrenHiddenIDs.add(nearestCollapsedAncestor.spanID); |
||||
} |
||||
setChildrenHiddenIDs(newChildrenHiddenIDs); |
||||
} |
||||
|
||||
function expandAll() { |
||||
setChildrenHiddenIDs(new Set<string>()); |
||||
} |
||||
|
||||
function collapseAll(spans: Span[]) { |
||||
if (shouldDisableCollapse(spans, childrenHiddenIDs)) { |
||||
return; |
||||
} |
||||
const newChildrenHiddenIDs = spans.reduce((res, s) => { |
||||
if (s.hasChildren) { |
||||
res.add(s.spanID); |
||||
} |
||||
return res; |
||||
}, new Set<string>()); |
||||
|
||||
setChildrenHiddenIDs(newChildrenHiddenIDs); |
||||
} |
||||
|
||||
function childrenToggle(spanID: string) { |
||||
const newChildrenHiddenIDs = new Set(childrenHiddenIDs); |
||||
if (childrenHiddenIDs.has(spanID)) { |
||||
newChildrenHiddenIDs.delete(spanID); |
||||
} else { |
||||
newChildrenHiddenIDs.add(spanID); |
||||
} |
||||
setChildrenHiddenIDs(newChildrenHiddenIDs); |
||||
} |
||||
|
||||
return { |
||||
childrenHiddenIDs, |
||||
expandOne, |
||||
collapseOne, |
||||
expandAll, |
||||
collapseAll, |
||||
childrenToggle, |
||||
}; |
||||
} |
||||
|
||||
function shouldDisableCollapse(allSpans: Span[], hiddenSpansIds: Set<string>) { |
||||
const allParentSpans = allSpans.filter(s => s.hasChildren); |
||||
return allParentSpans.length === hiddenSpansIds.size; |
||||
} |
@ -0,0 +1,56 @@ |
||||
import { renderHook, act } from '@testing-library/react-hooks'; |
||||
import { Log } from '@jaegertracing/jaeger-ui-components'; |
||||
import { useDetailState } from './useDetailState'; |
||||
|
||||
describe('useDetailState', () => { |
||||
it('toggles detail', async () => { |
||||
const { result } = renderHook(() => useDetailState()); |
||||
expect(result.current.detailStates.size).toBe(0); |
||||
|
||||
act(() => result.current.toggleDetail('span1')); |
||||
expect(result.current.detailStates.size).toBe(1); |
||||
expect(result.current.detailStates.has('span1')).toBe(true); |
||||
|
||||
act(() => result.current.toggleDetail('span1')); |
||||
expect(result.current.detailStates.size).toBe(0); |
||||
}); |
||||
|
||||
it('toggles logs and logs items', async () => { |
||||
const { result } = renderHook(() => useDetailState()); |
||||
act(() => result.current.toggleDetail('span1')); |
||||
act(() => result.current.detailLogsToggle('span1')); |
||||
expect(result.current.detailStates.get('span1').logs.isOpen).toBe(true); |
||||
|
||||
const log = { timestamp: 1 } as Log; |
||||
act(() => result.current.detailLogItemToggle('span1', log)); |
||||
expect(result.current.detailStates.get('span1').logs.openedItems.has(log)).toBe(true); |
||||
}); |
||||
|
||||
it('toggles warnings', async () => { |
||||
const { result } = renderHook(() => useDetailState()); |
||||
act(() => result.current.toggleDetail('span1')); |
||||
act(() => result.current.detailWarningsToggle('span1')); |
||||
expect(result.current.detailStates.get('span1').isWarningsOpen).toBe(true); |
||||
}); |
||||
|
||||
it('toggles references', async () => { |
||||
const { result } = renderHook(() => useDetailState()); |
||||
act(() => result.current.toggleDetail('span1')); |
||||
act(() => result.current.detailReferencesToggle('span1')); |
||||
expect(result.current.detailStates.get('span1').isReferencesOpen).toBe(true); |
||||
}); |
||||
|
||||
it('toggles processes', async () => { |
||||
const { result } = renderHook(() => useDetailState()); |
||||
act(() => result.current.toggleDetail('span1')); |
||||
act(() => result.current.detailProcessToggle('span1')); |
||||
expect(result.current.detailStates.get('span1').isProcessOpen).toBe(true); |
||||
}); |
||||
|
||||
it('toggles tags', async () => { |
||||
const { result } = renderHook(() => useDetailState()); |
||||
act(() => result.current.toggleDetail('span1')); |
||||
act(() => result.current.detailTagsToggle('span1')); |
||||
expect(result.current.detailStates.get('span1').isTagsOpen).toBe(true); |
||||
}); |
||||
}); |
@ -0,0 +1,70 @@ |
||||
import { useState } from 'react'; |
||||
import { DetailState, Log } from '@jaegertracing/jaeger-ui-components'; |
||||
|
||||
/** |
||||
* Keeps state of the span detail. This means whether span details are open but also state of each detail subitem |
||||
* like logs or tags. |
||||
*/ |
||||
export function useDetailState() { |
||||
const [detailStates, setDetailStates] = useState(new Map<string, DetailState>()); |
||||
|
||||
function toggleDetail(spanID: string) { |
||||
const newDetailStates = new Map(detailStates); |
||||
if (newDetailStates.has(spanID)) { |
||||
newDetailStates.delete(spanID); |
||||
} else { |
||||
newDetailStates.set(spanID, new DetailState()); |
||||
} |
||||
setDetailStates(newDetailStates); |
||||
} |
||||
|
||||
function detailLogItemToggle(spanID: string, log: Log) { |
||||
const old = detailStates.get(spanID); |
||||
if (!old) { |
||||
return; |
||||
} |
||||
const detailState = old.toggleLogItem(log); |
||||
const newDetailStates = new Map(detailStates); |
||||
newDetailStates.set(spanID, detailState); |
||||
return setDetailStates(newDetailStates); |
||||
} |
||||
|
||||
return { |
||||
detailStates, |
||||
toggleDetail, |
||||
detailLogItemToggle, |
||||
detailLogsToggle: makeDetailSubsectionToggle('logs', detailStates, setDetailStates), |
||||
detailWarningsToggle: makeDetailSubsectionToggle('warnings', detailStates, setDetailStates), |
||||
detailReferencesToggle: makeDetailSubsectionToggle('references', detailStates, setDetailStates), |
||||
detailProcessToggle: makeDetailSubsectionToggle('process', detailStates, setDetailStates), |
||||
detailTagsToggle: makeDetailSubsectionToggle('tags', detailStates, setDetailStates), |
||||
}; |
||||
} |
||||
|
||||
function makeDetailSubsectionToggle( |
||||
subSection: 'tags' | 'process' | 'logs' | 'warnings' | 'references', |
||||
detailStates: Map<string, DetailState>, |
||||
setDetailStates: (detailStates: Map<string, DetailState>) => void |
||||
) { |
||||
return (spanID: string) => { |
||||
const old = detailStates.get(spanID); |
||||
if (!old) { |
||||
return; |
||||
} |
||||
let detailState; |
||||
if (subSection === 'tags') { |
||||
detailState = old.toggleTags(); |
||||
} else if (subSection === 'process') { |
||||
detailState = old.toggleProcess(); |
||||
} else if (subSection === 'warnings') { |
||||
detailState = old.toggleWarnings(); |
||||
} else if (subSection === 'references') { |
||||
detailState = old.toggleReferences(); |
||||
} else { |
||||
detailState = old.toggleLogs(); |
||||
} |
||||
const newDetailStates = new Map(detailStates); |
||||
newDetailStates.set(spanID, detailState); |
||||
setDetailStates(newDetailStates); |
||||
}; |
||||
} |
@ -0,0 +1,16 @@ |
||||
import { renderHook, act } from '@testing-library/react-hooks'; |
||||
import { useHoverIndentGuide } from './useHoverIndentGuide'; |
||||
|
||||
describe('useHoverIndentGuide', () => { |
||||
it('adds and removes indent guide ids', async () => { |
||||
const { result } = renderHook(() => useHoverIndentGuide()); |
||||
expect(result.current.hoverIndentGuideIds.size).toBe(0); |
||||
|
||||
act(() => result.current.addHoverIndentGuideId('span1')); |
||||
expect(result.current.hoverIndentGuideIds.size).toBe(1); |
||||
expect(result.current.hoverIndentGuideIds.has('span1')).toBe(true); |
||||
|
||||
act(() => result.current.removeHoverIndentGuideId('span1')); |
||||
expect(result.current.hoverIndentGuideIds.size).toBe(0); |
||||
}); |
||||
}); |
@ -0,0 +1,30 @@ |
||||
import { useState } from 'react'; |
||||
|
||||
/** |
||||
* This is used internally to handle hover state of indent guide. As indent guides are separate |
||||
* components per each row/span and you need to highlight all in multiple rows to make the effect of single line |
||||
* they need this kind of common imperative state changes. |
||||
* |
||||
* Ideally would be changed to trace view internal state. |
||||
*/ |
||||
export function useHoverIndentGuide() { |
||||
const [hoverIndentGuideIds, setHoverIndentGuideIds] = useState(new Set<string>()); |
||||
|
||||
function addHoverIndentGuideId(spanID: string) { |
||||
setHoverIndentGuideIds(prevState => { |
||||
const newHoverIndentGuideIds = new Set(prevState); |
||||
newHoverIndentGuideIds.add(spanID); |
||||
return newHoverIndentGuideIds; |
||||
}); |
||||
} |
||||
|
||||
function removeHoverIndentGuideId(spanID: string) { |
||||
setHoverIndentGuideIds(prevState => { |
||||
const newHoverIndentGuideIds = new Set(prevState); |
||||
newHoverIndentGuideIds.delete(spanID); |
||||
return newHoverIndentGuideIds; |
||||
}); |
||||
} |
||||
|
||||
return { hoverIndentGuideIds, addHoverIndentGuideId, removeHoverIndentGuideId }; |
||||
} |
@ -0,0 +1,44 @@ |
||||
import { renderHook, act } from '@testing-library/react-hooks'; |
||||
import { useSearch } from './useSearch'; |
||||
import { Span } from '@jaegertracing/jaeger-ui-components'; |
||||
|
||||
describe('useSearch', () => { |
||||
it('returns matching span IDs', async () => { |
||||
const { result } = renderHook(() => |
||||
useSearch([ |
||||
{ |
||||
spanID: 'span1', |
||||
operationName: 'operation1', |
||||
process: { |
||||
serviceName: 'service1', |
||||
tags: [], |
||||
}, |
||||
tags: [], |
||||
logs: [], |
||||
} as Span, |
||||
|
||||
{ |
||||
spanID: 'span2', |
||||
operationName: 'operation2', |
||||
process: { |
||||
serviceName: 'service2', |
||||
tags: [], |
||||
}, |
||||
tags: [], |
||||
logs: [], |
||||
} as Span, |
||||
]) |
||||
); |
||||
|
||||
act(() => result.current.setSearch('service1')); |
||||
expect(result.current.spanFindMatches.size).toBe(1); |
||||
expect(result.current.spanFindMatches.has('span1')).toBe(true); |
||||
}); |
||||
|
||||
it('works without spans', async () => { |
||||
const { result } = renderHook(() => useSearch()); |
||||
|
||||
act(() => result.current.setSearch('service1')); |
||||
expect(result.current.spanFindMatches).toBe(undefined); |
||||
}); |
||||
}); |
@ -0,0 +1,15 @@ |
||||
import { useState } from 'react'; |
||||
import { Span, filterSpans } from '@jaegertracing/jaeger-ui-components'; |
||||
|
||||
/** |
||||
* Controls the state of search input that highlights spans if they match the search string. |
||||
* @param spans |
||||
*/ |
||||
export function useSearch(spans?: Span[]) { |
||||
const [search, setSearch] = useState(''); |
||||
let spanFindMatches: Set<string> | undefined; |
||||
if (search && spans) { |
||||
spanFindMatches = filterSpans(search, spans); |
||||
} |
||||
return { search, setSearch, spanFindMatches }; |
||||
} |
@ -0,0 +1,25 @@ |
||||
import { renderHook, act } from '@testing-library/react-hooks'; |
||||
import { useViewRange } from './useViewRange'; |
||||
|
||||
describe('useViewRange', () => { |
||||
it('defaults to full range', async () => { |
||||
const { result } = renderHook(() => useViewRange()); |
||||
expect(result.current.viewRange).toEqual({ time: { current: [0, 1] } }); |
||||
}); |
||||
|
||||
describe('updateNextViewRangeTime', () => { |
||||
it('updates time', async () => { |
||||
const { result } = renderHook(() => useViewRange()); |
||||
act(() => result.current.updateNextViewRangeTime({ cursor: 0.5 })); |
||||
expect(result.current.viewRange).toEqual({ time: { current: [0, 1], cursor: 0.5 } }); |
||||
}); |
||||
}); |
||||
|
||||
describe('updateViewRangeTime', () => { |
||||
it('updates time', async () => { |
||||
const { result } = renderHook(() => useViewRange()); |
||||
act(() => result.current.updateViewRangeTime(0.1, 0.2)); |
||||
expect(result.current.viewRange).toEqual({ time: { current: [0.1, 0.2] } }); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,35 @@ |
||||
import { useState } from 'react'; |
||||
import { ViewRangeTimeUpdate, ViewRange } from '@jaegertracing/jaeger-ui-components'; |
||||
|
||||
/** |
||||
* Controls state of the zoom function that can be used through minimap in header or on the timeline. ViewRange contains |
||||
* state not only for current range that is showing but range that is currently being selected by the user. |
||||
*/ |
||||
export function useViewRange() { |
||||
const [viewRange, setViewRange] = useState<ViewRange>({ |
||||
time: { |
||||
current: [0, 1], |
||||
}, |
||||
}); |
||||
|
||||
function updateNextViewRangeTime(update: ViewRangeTimeUpdate) { |
||||
setViewRange( |
||||
(prevRange): ViewRange => { |
||||
const time = { ...prevRange.time, ...update }; |
||||
return { ...prevRange, time }; |
||||
} |
||||
); |
||||
} |
||||
|
||||
function updateViewRangeTime(start: number, end: number) { |
||||
const current: [number, number] = [start, end]; |
||||
const time = { current }; |
||||
setViewRange( |
||||
(prevRange): ViewRange => { |
||||
return { ...prevRange, time }; |
||||
} |
||||
); |
||||
} |
||||
|
||||
return { viewRange, updateViewRangeTime, updateNextViewRangeTime }; |
||||
} |
Loading…
Reference in new issue