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