Graph: introduce Tooltip to React graph (#20046)

This commit is contained in:
Dominik Prokop 2019-11-07 12:37:46 +01:00 committed by GitHub
parent 08ada20270
commit 96dbed5efc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1697 additions and 3437 deletions

View File

@ -21,7 +21,8 @@ module.exports = {
],
"setupFiles": [
"./public/test/jest-shim.ts",
"./public/test/jest-setup.ts"
"./public/test/jest-setup.ts",
"jest-canvas-mock"
],
"snapshotSerializers": ["enzyme-to-json/serializer"],
"globals": { "ts-jest": { "isolatedModules": true } },

View File

@ -95,6 +95,7 @@
"html-webpack-plugin": "3.2.0",
"husky": "1.3.1",
"jest": "24.8.0",
"jest-canvas-mock": "2.1.2",
"jest-date-mock": "1.0.7",
"lerna": "^3.15.0",
"lint-staged": "8.1.5",
@ -241,7 +242,7 @@
"react-sizeme": "2.5.2",
"react-table": "6.9.2",
"react-transition-group": "2.6.1",
"react-use": "9.0.0",
"react-use": "12.8.0",
"react-virtualized": "9.21.0",
"react-window": "1.7.1",
"redux": "4.0.4",

View File

@ -0,0 +1,39 @@
import { Field } from '../types/dataFrame';
import { KeyValue } from '../types/data';
export interface Dimension<T = any> {
// Name of the dimension
name: string;
// Colection of fields representing dimension
// I.e. in 2d graph we have two dimension- X and Y axes. Both dimensions can represent
// multiple fields being drawn on the graph.
// For instance y-axis dimension is a collection of series value fields,
// and x-axis dimension is a collection of corresponding time fields
columns: Array<Field<T>>;
}
export type Dimensions = KeyValue<Dimension>;
export const createDimension = (name: string, columns: Field[]): Dimension => {
return {
name,
columns,
};
};
export const getColumnsFromDimension = (dimension: Dimension) => {
return dimension.columns;
};
export const getColumnFromDimension = (dimension: Dimension, column: number) => {
return dimension.columns[column];
};
export const getValueFromDimension = (dimension: Dimension, column: number, row: number) => {
return dimension.columns[column].values.get(row);
};
export const getAllValuesFromDimension = (dimension: Dimension, column: number, row: number) => {
return dimension.columns.map(c => c.values.get(row));
};
export const getDimensionByName = (dimensions: Dimensions, name: string) => dimensions[name];

View File

@ -3,3 +3,4 @@ export * from './FieldCache';
export * from './CircularDataFrame';
export * from './MutableDataFrame';
export * from './processDataFrame';
export * from './dimensions';

View File

@ -63,9 +63,10 @@ function convertTimeSeriesToDataFrame(timeSeries: TimeSeries): DataFrame {
type: FieldType.number,
config: {
unit: timeSeries.unit,
color: timeSeries.color,
},
values: new ArrayVector<TimeSeriesValue>(),
},
} as Field<TimeSeriesValue, ArrayVector<TimeSeriesValue>>,
{
name: 'Time',
type: FieldType.time,
@ -73,12 +74,12 @@ function convertTimeSeriesToDataFrame(timeSeries: TimeSeries): DataFrame {
unit: 'dateTimeAsIso',
},
values: new ArrayVector<number>(),
},
} as Field<number, ArrayVector<number>>,
];
for (const point of timeSeries.datapoints) {
fields[0].values.buffer.push(point[0]);
fields[1].values.buffer.push(point[1]);
fields[1].values.buffer.push(point[1] as number);
}
return {

View File

@ -58,6 +58,7 @@ export interface TimeSeries extends QueryResultBase {
target: string;
datapoints: TimeSeriesPoints;
unit?: string;
color?: string;
tags?: Labels;
}

View File

@ -44,6 +44,9 @@ export interface FieldConfig {
// Alternative to empty string
noValue?: string;
// Visual options
color?: string;
// Used for time field formatting
dateDisplayFormat?: string;
}
@ -53,7 +56,6 @@ export interface Field<T = any, V = Vector<T>> {
type: FieldType;
config: FieldConfig;
values: V; // The raw field values
/**
* Cache of reduced values
*/

View File

@ -1,5 +1,5 @@
import { DisplayValue } from './displayValue';
import { Field } from './dataFrame';
export interface YAxis {
index: number;
min?: number;
@ -16,6 +16,12 @@ export interface GraphSeriesXY {
info?: DisplayValue[]; // Legend info
isVisible: boolean;
yAxis: YAxis;
// Field with series' time values
timeField: Field;
// Field with series' values
valueField: Field;
seriesIndex: number;
timeStep: number;
}
export interface CreatePlotOverlay {

View File

@ -7,6 +7,7 @@ export * from './labels';
export * from './object';
export * from './thresholds';
export * from './namedColorsPalette';
export * from './series';
export { getMappedValue } from './valueMappings';
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';

View File

@ -0,0 +1,47 @@
import { getSeriesTimeStep, hasMsResolution } from './series';
import { Field, FieldType } from '../types';
import { ArrayVector } from '../vector';
const uniformTimeField: Field = {
name: 'time',
type: FieldType.time,
values: new ArrayVector([0, 100, 200, 300]),
config: {},
};
const nonUniformTimeField: Field = {
name: 'time',
type: FieldType.time,
values: new ArrayVector([0, 100, 300, 350]),
config: {},
};
const msResolutionTimeField: Field = {
name: 'time',
type: FieldType.time,
values: new ArrayVector([0, 1572951685007, 300, 350]),
config: {},
};
describe('getSeriesTimeStep', () => {
test('uniform series', () => {
const result = getSeriesTimeStep(uniformTimeField);
expect(result).toBe(100);
});
test('non-uniform series', () => {
const result = getSeriesTimeStep(nonUniformTimeField);
expect(result).toBe(50);
});
});
describe('hasMsResolution', () => {
test('return false if none of the timestamps is in ms', () => {
const result = hasMsResolution(uniformTimeField);
expect(result).toBeFalsy();
});
test('return true if any of the timestamps is in ms', () => {
const result = hasMsResolution(msResolutionTimeField);
expect(result).toBeTruthy();
});
});

View File

@ -0,0 +1,45 @@
import { Field } from '../types/dataFrame';
/**
* Returns minimal time step from series time field
* @param timeField
*/
export const getSeriesTimeStep = (timeField: Field) => {
let previousTime;
let minTimeStep;
for (let i = 0; i < timeField.values.length; i++) {
const currentTime = timeField.values.get(i);
if (previousTime !== undefined) {
const timeStep = currentTime - previousTime;
if (minTimeStep === undefined) {
minTimeStep = timeStep;
}
if (timeStep < minTimeStep) {
minTimeStep = timeStep;
}
}
previousTime = currentTime;
}
return minTimeStep;
};
/**
* Checks if series time field has ms resolution
* @param timeField
*/
export const hasMsResolution = (timeField: Field) => {
for (let i = 0; i < timeField.values.length; i++) {
const value = timeField.values.get(i);
if (value !== null && value !== undefined) {
const timestamp = value.toString();
if (timestamp.length === 13 && timestamp % 1000 !== 0) {
return true;
}
}
}
return false;
};

View File

@ -0,0 +1,100 @@
import React from 'react';
import { mount } from 'enzyme';
import { Tooltip } from './Tooltip';
// Tooltip container has padding of 8px, let's assume target tooltip has measured width & height of 100px
const content = <div style={{ width: '84px', height: '84' }} />;
describe('Chart Tooltip', () => {
describe('is positioned correctly', () => {
beforeEach(() => {
// jsdom does not perform actual DOM rendering
// We need to mock getBoundingClientRect to return what DOM would actually return
// when measuring tooltip container (wrapper with padding and content inside)
Element.prototype.getBoundingClientRect = jest.fn(() => {
return { width: 100, height: 100, top: 0, left: 0, bottom: 0, right: 0 };
});
});
// Jest's default viewport size is 1024x768px
test('when fits into the viewport', () => {
const tooltip = mount(<Tooltip content={content} position={{ x: 0, y: 0 }} />);
const container = tooltip.find('TooltipContainer > div');
const styleAttribute = container.getDOMNode().getAttribute('style');
// +------+
// |origin|
// +------+--------------+
// | Tooltip |
// | |
// +--------------+
expect(styleAttribute).toContain('translate3d(0px, 0px, 0)');
});
test("when overflows viewport's x axis", () => {
const tooltip = mount(<Tooltip content={content} position={{ x: 1000, y: 0 }} />);
const container = tooltip.find('TooltipContainer > div');
const styleAttribute = container.getDOMNode().getAttribute('style');
// We expect tooltip to flip over left side of the origin position
// +------+
// |origin|
// +--------------+------+
// | Tooltip |
// | |
// +--------------+
expect(styleAttribute).toContain('translate3d(900px, 0px, 0)');
});
test("when overflows viewport's y axis", () => {
const tooltip = mount(<Tooltip content={content} position={{ x: 0, y: 700 }} />);
const container = tooltip.find('TooltipContainer > div');
const styleAttribute = container.getDOMNode().getAttribute('style');
// We expect tooltip to flip over top side of the origin position
// +--------------+
// | Tooltip |
// | |
// +------+--------------+
// |origin|
// +------+
expect(styleAttribute).toContain('translate3d(0px, 600px, 0)');
});
test("when overflows viewport's x and y axes", () => {
const tooltip = mount(<Tooltip content={content} position={{ x: 1000, y: 700 }} />);
const container = tooltip.find('TooltipContainer > div');
const styleAttribute = container.getDOMNode().getAttribute('style');
// We expect tooltip to flip over the left top corner of the origin position
// +--------------+
// | Tooltip |
// | |
// +--------------+------+
// |origin|
// +------+
expect(styleAttribute).toContain('translate3d(900px, 600px, 0)');
});
describe('when offset provided', () => {
test("when overflows viewport's x and y axes", () => {
const tooltip = mount(<Tooltip content={content} position={{ x: 1000, y: 700 }} offset={{ x: 10, y: 10 }} />);
const container = tooltip.find('TooltipContainer > div');
const styleAttribute = container.getDOMNode().getAttribute('style');
// We expect tooltip to flip over the left top corner of the origin position with offset applied
// +--------------------+
// | |
// | +--------------+ |
// | | Tooltip | |
// | | | |
// | +--------------+ |
// | offset|
// +--------------------++------+
// |origin|
// +------+
expect(styleAttribute).toContain('translate3d(890px, 590px, 0)');
});
});
});
});

View File

@ -0,0 +1,70 @@
import React from 'react';
import { css } from 'emotion';
import { Portal } from '../Portal/Portal';
import { Dimensions } from '@grafana/data';
import { FlotPosition } from '../Graph/types';
import { TooltipContainer } from './TooltipContainer';
export type TooltipMode = 'single' | 'multi';
// Describes active dimensions user interacts with
// It's a key-value pair where:
// - key is the name of the dimension
// - value is a tuple addresing which column and row from given dimension is active.
// If row is undefined, it means that we are not hovering over a datapoint
export type ActiveDimensions<T extends Dimensions = any> = { [key in keyof T]: [number, number | undefined] | null };
export interface TooltipContentProps<T extends Dimensions = any> {
// Each dimension is described by array of fields representing it
// I.e. for graph there are two dimensions: x and y axis:
// { xAxis: [<array of time fields>], yAxis: [<array of value fields>]}
// TODO: type this better, no good idea how yet
dimensions: T; // Dimension[]
activeDimensions?: ActiveDimensions<T>;
// timeZone: TimeZone;
pos: FlotPosition;
mode: TooltipMode;
}
export interface TooltipProps {
/** Element used as tooltips content */
content?: React.ReactElement<any>;
/** Optional component to be used as a tooltip content */
tooltipComponent?: React.ComponentType<TooltipContentProps>;
/** x/y position relative to the window */
position?: { x: number; y: number };
/** x/y offset relative to tooltip origin element, i.e. graph's datapoint */
offset?: { x: number; y: number };
// Mode in which tooltip works
// - single - display single series info
// - multi - display all series info
mode?: TooltipMode;
}
export const Tooltip: React.FC<TooltipProps> = ({ content, position, offset }) => {
if (position) {
return (
<Portal
className={css`
position: absolute;
top: 0;
left: 0;
pointer-events: none;
width: 100%;
height: 100%;
`}
>
<TooltipContainer position={position} offset={offset || { x: 0, y: 0 }}>
{content}
</TooltipContainer>
</Portal>
);
}
return null;
};
Tooltip.displayName = 'ChartTooltip';

View File

@ -0,0 +1,79 @@
import React, { useState, useLayoutEffect, useRef } from 'react';
import { stylesFactory } from '../../themes/stylesFactory';
import { selectThemeVariant } from '../../themes/selectThemeVariant';
import { css } from 'emotion';
import { useTheme } from '../../themes/ThemeContext';
import useWindowSize from 'react-use/lib/useWindowSize';
import { GrafanaTheme } from '@grafana/data';
interface TooltipContainerProps {
position: { x: number; y: number };
offset: { x: number; y: number };
children?: JSX.Element;
}
const getTooltipContainerStyles = stylesFactory((theme: GrafanaTheme) => {
const bgColor = selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark1 }, theme.type);
return {
wrapper: css`
overflow: hidden;
background: ${bgColor};
/* 30% is an arbitrary choice. We can be more clever about calculating tooltip\'s width */
max-width: 30%;
padding: ${theme.spacing.sm};
border-radius: ${theme.border.radius.sm};
`,
};
});
export const TooltipContainer: React.FC<TooltipContainerProps> = ({ position, offset, children }) => {
const theme = useTheme();
const tooltipRef = useRef<HTMLDivElement>(null);
const { width, height } = useWindowSize();
const [placement, setPlacement] = useState({
x: position.x + offset.x,
y: position.y + offset.y,
});
// Make sure tooltip does not overflow window
useLayoutEffect(() => {
let xO = 0,
yO = 0;
if (tooltipRef && tooltipRef.current) {
const measurement = tooltipRef.current.getBoundingClientRect();
const xOverflow = width - (position.x + measurement.width);
const yOverflow = height - (position.y + measurement.height);
if (xOverflow < 0) {
xO = measurement.width + offset.x;
}
if (yOverflow < 0) {
yO = measurement.height + offset.y;
}
}
setPlacement({
x: position.x - xO,
y: position.y - yO,
});
}, [tooltipRef, position]);
const styles = getTooltipContainerStyles(theme);
return (
<div
ref={tooltipRef}
style={{
position: 'fixed',
left: 0,
top: 0,
transform: `translate3d(${placement.x}px, ${placement.y}px, 0)`,
}}
className={styles.wrapper}
>
{children}
</div>
);
};
TooltipContainer.displayName = 'TooltipContainer';

View File

@ -0,0 +1,7 @@
import { Tooltip } from './Tooltip';
const Chart = {
Tooltip,
};
export default Chart;

View File

@ -0,0 +1,131 @@
import React from 'react';
import { Graph } from './Graph';
import Chart from '../Chart';
import { dateTime, ArrayVector, FieldType, GraphSeriesXY } from '@grafana/data';
import { select } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { TooltipContentProps } from '../Chart/Tooltip';
import { JSONFormatter } from '../JSONFormatter/JSONFormatter';
export default {
title: 'Visualizations/Graph/Graph',
component: Graph,
decorators: [withCenteredStory],
};
const getKnobs = () => {
return {
tooltipMode: select(
'Tooltip mode',
{
multi: 'multi',
single: 'single',
},
'single'
),
};
};
const series: GraphSeriesXY[] = [
{
data: [[1546372800000, 10], [1546376400000, 20], [1546380000000, 10]],
color: 'red',
isVisible: true,
label: 'A-series',
seriesIndex: 0,
timeField: {
type: FieldType.time,
name: 'time',
values: new ArrayVector([1546372800000, 1546376400000, 1546380000000]),
config: {},
},
valueField: {
type: FieldType.number,
name: 'a-series',
values: new ArrayVector([10, 20, 10]),
config: { color: 'red' },
},
timeStep: 3600000,
yAxis: {
index: 0,
},
},
{
data: [[1546372800000, 20], [1546376400000, 30], [1546380000000, 40]],
color: 'blue',
isVisible: true,
label:
"B-series with an ultra wide label that probably gonna make the tooltip to overflow window. This situation happens, so let's better make sure it behaves nicely :)",
seriesIndex: 1,
timeField: {
type: FieldType.time,
name: 'time',
values: new ArrayVector([1546372800000, 1546376400000, 1546380000000]),
config: {},
},
valueField: {
type: FieldType.number,
name:
"B-series with an ultra wide label that is probably going go make the tooltip overflow window. This situation happens, so let's better make sure it behaves nicely :)",
values: new ArrayVector([20, 30, 40]),
config: { color: 'blue' },
},
timeStep: 3600000,
yAxis: {
index: 0,
},
},
];
export const withTooltip = () => {
const { tooltipMode } = getKnobs();
return (
<Graph
height={300}
width={600}
series={series}
timeRange={{
from: dateTime(1546372800000),
to: dateTime(1546380000000),
raw: {
from: dateTime(1546372800000),
to: dateTime(1546380000000),
},
}}
timeZone="browser"
>
<Chart.Tooltip mode={tooltipMode} />
</Graph>
);
};
const CustomGraphTooltip: React.FC<TooltipContentProps> = ({ activeDimensions }) => {
return (
<div style={{ height: '200px' }}>
<div>Showing currently active active dimensions:</div>
<JSONFormatter json={activeDimensions || {}} />
</div>
);
};
export const withCustomTooltip = () => {
const { tooltipMode } = getKnobs();
return (
<Graph
height={300}
width={600}
series={series}
timeRange={{
from: dateTime(1546372800000),
to: dateTime(1546380000000),
raw: {
from: dateTime(1546372800000),
to: dateTime(1546380000000),
},
}}
timeZone="browser"
>
<Chart.Tooltip mode={tooltipMode} tooltipComponent={CustomGraphTooltip} />
</Graph>
);
};

View File

@ -0,0 +1,160 @@
import React from 'react';
import { mount } from 'enzyme';
import Graph from './Graph';
import Chart from '../Chart';
import { GraphSeriesXY, FieldType, ArrayVector, dateTime } from '@grafana/data';
const series: GraphSeriesXY[] = [
{
data: [[1546372800000, 10], [1546376400000, 20], [1546380000000, 10]],
color: 'red',
isVisible: true,
label: 'A-series',
seriesIndex: 0,
timeField: {
type: FieldType.time,
name: 'time',
values: new ArrayVector([1546372800000, 1546376400000, 1546380000000]),
config: {},
},
valueField: {
type: FieldType.number,
name: 'a-series',
values: new ArrayVector([10, 20, 10]),
config: { color: 'red' },
},
timeStep: 3600000,
yAxis: {
index: 0,
},
},
{
data: [[1546372800000, 20], [1546376400000, 30], [1546380000000, 40]],
color: 'blue',
isVisible: true,
label: 'B-series',
seriesIndex: 0,
timeField: {
type: FieldType.time,
name: 'time',
values: new ArrayVector([1546372800000, 1546376400000, 1546380000000]),
config: {},
},
valueField: {
type: FieldType.number,
name: 'b-series',
values: new ArrayVector([20, 30, 40]),
config: { color: 'blue' },
},
timeStep: 3600000,
yAxis: {
index: 0,
},
},
];
const mockTimeRange = {
from: dateTime(1546372800000),
to: dateTime(1546380000000),
raw: {
from: dateTime(1546372800000),
to: dateTime(1546380000000),
},
};
const mockGraphProps = (multiSeries = false) => {
return {
width: 200,
height: 100,
series,
timeRange: mockTimeRange,
timeZone: 'browser',
};
};
describe('Graph', () => {
describe('with tooltip', () => {
describe('in single mode', () => {
it("doesn't render tooltip when not hovering over a datapoint", () => {
const graphWithTooltip = (
<Graph {...mockGraphProps()}>
<Chart.Tooltip mode="single" />
</Graph>
);
const container = mount(graphWithTooltip);
const tooltip = container.find('GraphTooltip');
expect(tooltip).toHaveLength(0);
});
it('renders tooltip when hovering over a datapoint', () => {
// Given
const graphWithTooltip = (
<Graph {...mockGraphProps()}>
<Chart.Tooltip mode="single" />
</Graph>
);
const container = mount(graphWithTooltip);
// When
// Simulating state set by $.flot plothover event when interacting with the canvas with Graph
// Unfortunately I haven't found a way to perfom the actual mouse hover interaction in JSDOM, hence I'm simulating the state
container.setState({
isTooltipVisible: true,
// This "is" close by middle point, Flot would have pick the middle point at this position
pos: {
x: 120,
y: 50,
},
activeItem: {
seriesIndex: 0,
dataIndex: 1,
series: { seriesIndex: 0 },
},
});
// Then
const tooltip = container.find('GraphTooltip');
const time = tooltip.find("[aria-label='Timestamp']");
// Each series should have icon rendered by default GraphTooltip component
// We are using this to make sure correct amount of series were rendered
const seriesIcons = tooltip.find('SeriesIcon');
expect(time).toHaveLength(1);
expect(tooltip).toHaveLength(1);
expect(seriesIcons).toHaveLength(1);
});
});
describe('in All Series mode', () => {
it('it renders all series summary regardles of mouse position', () => {
// Given
const graphWithTooltip = (
<Graph {...mockGraphProps(true)}>
<Chart.Tooltip mode="multi" />
</Graph>
);
const container = mount(graphWithTooltip);
// When
container.setState({
isTooltipVisible: true,
// This "is" more or less between first and middle point. Flot would not have picked any point as active one at this position
pos: {
x: 80,
y: 50,
},
activeItem: null,
});
// Then
const tooltip = container.find('GraphTooltip');
const time = tooltip.find("[aria-label='Timestamp']");
const seriesIcons = tooltip.find('SeriesIcon');
expect(time).toHaveLength(1);
expect(tooltip).toHaveLength(1);
expect(seriesIcons).toHaveLength(2);
});
});
});
});

View File

@ -3,9 +3,15 @@ import $ from 'jquery';
import React, { PureComponent } from 'react';
import uniqBy from 'lodash/uniqBy';
// Types
import { TimeRange, GraphSeriesXY, TimeZone, DefaultTimeZone } from '@grafana/data';
import { TimeRange, GraphSeriesXY, TimeZone, DefaultTimeZone, createDimension } from '@grafana/data';
import _ from 'lodash';
import { FlotPosition, FlotItem } from './types';
import { TooltipProps, TooltipContentProps, ActiveDimensions, Tooltip } from '../Chart/Tooltip';
import { GraphTooltip } from './GraphTooltip/GraphTooltip';
import { GraphDimensions } from './GraphTooltip/types';
export interface GraphProps {
children?: JSX.Element | JSX.Element[];
series: GraphSeriesXY[];
timeRange: TimeRange; // NOTE: we should aim to make `time` a property of the axis, not force it for all graphs
timeZone: TimeZone; // NOTE: we should aim to make `time` a property of the axis, not force it for all graphs
@ -19,7 +25,13 @@ export interface GraphProps {
onHorizontalRegionSelected?: (from: number, to: number) => void;
}
export class Graph extends PureComponent<GraphProps> {
interface GraphState {
pos?: FlotPosition;
isTooltipVisible: boolean;
activeItem?: FlotItem<GraphSeriesXY>;
}
export class Graph extends PureComponent<GraphProps, GraphState> {
static defaultProps = {
showLines: true,
showPoints: false,
@ -28,11 +40,17 @@ export class Graph extends PureComponent<GraphProps> {
lineWidth: 1,
};
state: GraphState = {
isTooltipVisible: false,
};
element: HTMLElement | null = null;
$element: any;
componentDidUpdate() {
this.draw();
componentDidUpdate(prevProps: GraphProps, prevState: GraphState) {
if (prevProps !== this.props) {
this.draw();
}
}
componentDidMount() {
@ -40,6 +58,7 @@ export class Graph extends PureComponent<GraphProps> {
if (this.element) {
this.$element = $(this.element);
this.$element.bind('plotselected', this.onPlotSelected);
this.$element.bind('plothover', this.onPlotHover);
}
}
@ -54,6 +73,14 @@ export class Graph extends PureComponent<GraphProps> {
}
};
onPlotHover = (event: JQueryEventObject, pos: FlotPosition, item?: FlotItem<GraphSeriesXY>) => {
this.setState({
isTooltipVisible: true,
activeItem: item,
pos,
});
};
getYAxes(series: GraphSeriesXY[]) {
if (series.length === 0) {
return [{ show: true, min: -1, max: 1 }];
@ -75,6 +102,83 @@ export class Graph extends PureComponent<GraphProps> {
);
}
renderTooltip = () => {
const { children, series } = this.props;
const { pos, activeItem, isTooltipVisible } = this.state;
let tooltipElement: React.ReactElement<TooltipProps> | null = null;
if (!isTooltipVisible || !pos || series.length === 0) {
return null;
}
// Find children that indicate tooltip to be rendered
React.Children.forEach(children, c => {
// We have already found tooltip
if (tooltipElement) {
return;
}
// @ts-ignore
const childType = c && c.type && (c.type.displayName || c.type.name);
if (childType === Tooltip.displayName) {
tooltipElement = c as React.ReactElement<TooltipProps>;
}
});
// If no tooltip provided, skip rendering
if (!tooltipElement) {
return null;
}
const tooltipElementProps = (tooltipElement as React.ReactElement<TooltipProps>).props;
const tooltipMode = tooltipElementProps.mode || 'single';
// If mode is single series and user is not hovering over item, skip rendering
if (!activeItem && tooltipMode === 'single') {
return null;
}
// Check if tooltip needs to be rendered with custom tooltip component, otherwise default to GraphTooltip
const tooltipContentRenderer = tooltipElementProps.tooltipComponent || GraphTooltip;
// Indicates column(field) index in y-axis dimension
const seriesIndex = activeItem ? activeItem.series.seriesIndex : 0;
// Indicates row index in active field values
const rowIndex = activeItem ? activeItem.dataIndex : undefined;
const activeDimensions: ActiveDimensions<GraphDimensions> = {
// Described x-axis active item
// When hovering over an item - let's take it's dataIndex, otherwise undefined
// Tooltip itself needs to figure out correct datapoint display information based on pos passed to it
xAxis: [seriesIndex, rowIndex],
// Describes y-axis active item
yAxis: activeItem ? [activeItem.series.seriesIndex, activeItem.dataIndex] : null,
};
const tooltipContentProps: TooltipContentProps<GraphDimensions> = {
dimensions: {
// time/value dimension columns are index-aligned - see getGraphSeriesModel
xAxis: createDimension('xAxis', series.map(s => s.timeField)),
yAxis: createDimension('yAxis', series.map(s => s.valueField)),
},
activeDimensions,
pos,
mode: tooltipElementProps.mode || 'single',
};
const tooltipContent = React.createElement(tooltipContentRenderer, { ...tooltipContentProps });
return React.cloneElement<TooltipProps>(tooltipElement as React.ReactElement<TooltipProps>, {
content: tooltipContent,
position: { x: pos.pageX, y: pos.pageY },
offset: { x: 10, y: 10 },
});
};
getBarWidth = () => {
const { series } = this.props;
return Math.min(...series.map(s => s.timeStep));
};
draw() {
if (this.element === null) {
return;
@ -122,7 +226,8 @@ export class Graph extends PureComponent<GraphProps> {
bars: {
show: showBars,
fill: 1,
barWidth: 1,
// Dividig the width by 1.5 to make the bars not touch each other
barWidth: showBars ? this.getBarWidth() / 1.5 : 1,
zero: false,
lineWidth: lineWidth,
},
@ -144,16 +249,20 @@ export class Graph extends PureComponent<GraphProps> {
markings: [],
backgroundColor: null,
borderWidth: 0,
// hoverable: true,
hoverable: true,
clickable: true,
color: '#a1a1a1',
margin: { left: 0, right: 0 },
labelMarginX: 0,
mouseActiveRadius: 30,
},
selection: {
mode: onHorizontalRegionSelected ? 'x' : null,
color: '#666',
},
crosshair: {
mode: 'x',
},
};
try {
@ -165,12 +274,21 @@ export class Graph extends PureComponent<GraphProps> {
}
render() {
const { height, series } = this.props;
const { height, width, series } = this.props;
const noDataToBeDisplayed = series.length === 0;
return (
<div className="graph-panel">
<div className="graph-panel__chart" ref={e => (this.element = e)} style={{ height }} />
<div
className="graph-panel__chart"
ref={e => (this.element = e)}
style={{ height, width }}
onMouseLeave={() => {
this.setState({ isTooltipVisible: false });
}}
/>
{noDataToBeDisplayed && <div className="datapoints-warning">No data</div>}
{this.renderTooltip()}
</div>
);
}

View File

@ -0,0 +1,28 @@
import React from 'react';
import { TooltipContentProps } from '../../Chart/Tooltip';
import { SingleModeGraphTooltip } from './SingleModeGraphTooltip';
import { MultiModeGraphTooltip } from './MultiModeGraphTooltip';
import { GraphDimensions } from './types';
export const GraphTooltip: React.FC<TooltipContentProps<GraphDimensions>> = ({
mode = 'single',
dimensions,
activeDimensions,
pos,
}) => {
// When
// [1] no active dimension or
// [2] no xAxis position
// we assume no tooltip should be rendered
if (!activeDimensions || !activeDimensions.xAxis) {
return null;
}
if (mode === 'single') {
return <SingleModeGraphTooltip dimensions={dimensions} activeDimensions={activeDimensions} />;
} else {
return <MultiModeGraphTooltip dimensions={dimensions} activeDimensions={activeDimensions} pos={pos} />;
}
};
GraphTooltip.displayName = 'GraphTooltip';

View File

@ -0,0 +1,94 @@
import React from 'react';
import { mount } from 'enzyme';
import { MultiModeGraphTooltip } from './MultiModeGraphTooltip';
import { createDimension, ArrayVector, FieldType } from '@grafana/data';
import { GraphDimensions } from './types';
import { ActiveDimensions } from '../../Chart/Tooltip';
let dimensions: GraphDimensions;
describe('MultiModeGraphTooltip', () => {
describe('when shown when hovering over a datapoint', () => {
beforeEach(() => {
dimensions = {
xAxis: createDimension('xAxis', [
{
config: {},
values: new ArrayVector([0, 100, 200]),
name: 'A-series time',
type: FieldType.time,
},
{
config: {},
values: new ArrayVector([0, 100, 200]),
name: 'B-series time',
type: FieldType.time,
},
]),
yAxis: createDimension('yAxis', [
{
config: {},
values: new ArrayVector([10, 20, 10]),
name: 'A-series values',
type: FieldType.number,
},
{
config: {},
values: new ArrayVector([20, 30, 40]),
name: 'B-series values',
type: FieldType.number,
},
]),
};
});
it('highlights series of the datapoint', () => {
// We are simulating hover over A-series, middle point
const activeDimensions: ActiveDimensions<GraphDimensions> = {
xAxis: [0, 1], // column, row
yAxis: [0, 1], // column, row
};
const container = mount(
<MultiModeGraphTooltip
dimensions={dimensions}
activeDimensions={activeDimensions}
// pos is not relevant in this test
pos={{ x: 0, y: 0, pageX: 0, pageY: 0, x1: 0, y1: 0 }}
/>
);
// We rendered two series rows
const rows = container.find('SeriesTableRow');
// We expect A-series(1st row) to be higlighted
expect(rows.get(0).props.isActive).toBeTruthy();
// We expect B-series(2nd row) not to be higlighted
expect(rows.get(1).props.isActive).toBeFalsy();
});
it("doesn't highlight series when not hovering over datapoint", () => {
// We are simulating hover over graph, but not datapoint
const activeDimensions: ActiveDimensions<GraphDimensions> = {
xAxis: [0, undefined], // no active point in time
yAxis: null, // no active series
};
const container = mount(
<MultiModeGraphTooltip
dimensions={dimensions}
activeDimensions={activeDimensions}
// pos is not relevant in this test
pos={{ x: 0, y: 0, pageX: 0, pageY: 0, x1: 0, y1: 0 }}
/>
);
// We rendered two series rows
const rows = container.find('SeriesTableRow');
// We expect A-series(1st row) not to be higlighted
expect(rows.get(0).props.isActive).toBeFalsy();
// We expect B-series(2nd row) not to be higlighted
expect(rows.get(1).props.isActive).toBeFalsy();
});
});
});

View File

@ -0,0 +1,44 @@
import React from 'react';
import { SeriesTable } from './SeriesTable';
import { GraphTooltipContentProps } from './types';
import { getMultiSeriesGraphHoverInfo } from '../utils';
import { FlotPosition } from '../types';
import { getValueFromDimension } from '@grafana/data';
export const MultiModeGraphTooltip: React.FC<
GraphTooltipContentProps & {
// We expect position to figure out correct values when not hovering over a datapoint
pos: FlotPosition;
}
> = ({ dimensions, activeDimensions, pos }) => {
let activeSeriesIndex: number | null = null;
// when no x-axis provided, skip rendering
if (activeDimensions.xAxis === null) {
return null;
}
if (activeDimensions.yAxis) {
activeSeriesIndex = activeDimensions.yAxis[0];
}
// when not hovering over a point, time is undefined, and we use pos.x as time
const time = activeDimensions.xAxis[1]
? getValueFromDimension(dimensions.xAxis, activeDimensions.xAxis[0], activeDimensions.xAxis[1])
: pos.x;
const hoverInfo = getMultiSeriesGraphHoverInfo(dimensions.yAxis.columns, dimensions.xAxis.columns, time);
const timestamp = hoverInfo.time;
const series = hoverInfo.results.map((s, i) => {
return {
color: s.color,
label: s.label,
value: s.value,
isActive: activeSeriesIndex === i,
};
});
return <SeriesTable series={series} timestamp={timestamp} />;
};
MultiModeGraphTooltip.displayName = 'MultiModeGraphTooltip';

View File

@ -0,0 +1,68 @@
import React from 'react';
import { stylesFactory } from '../../../themes/stylesFactory';
import { GrafanaTheme, GraphSeriesValue } from '@grafana/data';
import { css, cx } from 'emotion';
import { SeriesIcon } from '../../Legend/SeriesIcon';
import { useTheme } from '../../../themes';
interface SeriesTableRowProps {
color?: string;
label?: string;
value: string | GraphSeriesValue;
isActive?: boolean;
}
const getSeriesTableRowStyles = stylesFactory((theme: GrafanaTheme) => {
return {
icon: css`
margin-right: ${theme.spacing.xs};
`,
seriesTable: css`
display: table;
`,
seriesTableRow: css`
display: table-row;
`,
seriesTableCell: css`
display: table-cell;
`,
value: css`
padding-left: ${theme.spacing.md};
`,
activeSeries: css`
font-weight: ${theme.typography.weight.bold};
`,
};
});
const SeriesTableRow: React.FC<SeriesTableRowProps> = ({ color, label, value, isActive }) => {
const theme = useTheme();
const styles = getSeriesTableRowStyles(theme);
return (
<div className={cx(styles.seriesTableRow, isActive && styles.activeSeries)}>
{color && (
<div className={styles.seriesTableCell}>
<SeriesIcon color={color} className={styles.icon} />
</div>
)}
<div className={styles.seriesTableCell}>{label}</div>
<div className={cx(styles.seriesTableCell, styles.value)}>{value}</div>
</div>
);
};
interface SeriesTableProps {
timestamp?: string | GraphSeriesValue;
series: SeriesTableRowProps[];
}
export const SeriesTable: React.FC<SeriesTableProps> = ({ timestamp, series }) => {
return (
<>
{timestamp && <div aria-label="Timestamp">{timestamp}</div>}
{series.map(s => {
return <SeriesTableRow isActive={s.isActive} label={s.label} color={s.color} value={s.value} key={s.label} />;
})}
</>
);
};

View File

@ -0,0 +1,32 @@
import React from 'react';
import { getValueFromDimension, getColumnFromDimension } from '@grafana/data';
import { SeriesTable } from './SeriesTable';
import { GraphTooltipContentProps } from './types';
export const SingleModeGraphTooltip: React.FC<GraphTooltipContentProps> = ({ dimensions, activeDimensions }) => {
// not hovering over a point, skip rendering
if (
activeDimensions.yAxis === null ||
activeDimensions.yAxis[1] === undefined ||
activeDimensions.xAxis === null ||
activeDimensions.xAxis[1] === undefined
) {
return null;
}
const time = getValueFromDimension(dimensions.xAxis, activeDimensions.xAxis[0], activeDimensions.xAxis[1]);
const timeField = getColumnFromDimension(dimensions.xAxis, activeDimensions.xAxis[0]);
const processedTime = timeField.display ? timeField.display(time).text : time;
const valueField = getColumnFromDimension(dimensions.yAxis, activeDimensions.yAxis[0]);
const value = getValueFromDimension(dimensions.yAxis, activeDimensions.yAxis[0], activeDimensions.yAxis[1]);
const processedValue = valueField.display ? valueField.display(value).text : value;
return (
<SeriesTable
series={[{ color: valueField.config.color, label: valueField.name, value: processedValue }]}
timestamp={processedTime}
/>
);
};
SingleModeGraphTooltip.displayName = 'SingleModeGraphTooltip';

View File

@ -0,0 +1,16 @@
import { ActiveDimensions, TooltipMode } from '../../Chart/Tooltip';
import { Dimension, Dimensions } from '@grafana/data';
export interface GraphTooltipOptions {
mode: TooltipMode;
}
export interface GraphDimensions extends Dimensions {
xAxis: Dimension<number>;
yAxis: Dimension<number>;
}
export interface GraphTooltipContentProps {
dimensions: GraphDimensions; // Dimension[]
activeDimensions: ActiveDimensions<GraphDimensions>;
}

View File

@ -3,34 +3,63 @@ import { storiesOf } from '@storybook/react';
import { select, text } from '@storybook/addon-knobs';
import { withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
import { GraphWithLegend } from './GraphWithLegend';
import { GraphWithLegend, GraphWithLegendProps } from './GraphWithLegend';
import { mockGraphWithLegendData } from './mockGraphWithLegendData';
import { action } from '@storybook/addon-actions';
import { LegendPlacement, LegendDisplayMode } from '../Legend/Legend';
import { GraphSeriesXY, FieldType, ArrayVector, dateTime } from '@grafana/data';
const GraphWithLegendStories = storiesOf('Visualizations/Graph/GraphWithLegend', module);
GraphWithLegendStories.addDecorator(withHorizontallyCenteredStory);
const getStoriesKnobs = () => {
const containerWidth = select(
'Container width',
{
Small: '200px',
Medium: '500px',
'Full width': '100%',
const series: GraphSeriesXY[] = [
{
data: [[1546372800000, 10], [1546376400000, 20], [1546380000000, 10]],
color: 'red',
isVisible: true,
label: 'A-series',
seriesIndex: 0,
timeField: {
type: FieldType.time,
name: 'time',
values: new ArrayVector([1546372800000, 1546376400000, 1546380000000]),
config: {},
},
'100%'
);
const containerHeight = select(
'Container height',
{
Small: '200px',
Medium: '400px',
'Full height': '100%',
valueField: {
type: FieldType.number,
name: 'a-series',
values: new ArrayVector([10, 20, 10]),
config: { color: 'red' },
},
'400px'
);
timeStep: 3600000,
yAxis: {
index: 1,
},
},
{
data: [[1546372800000, 20], [1546376400000, 30], [1546380000000, 40]],
color: 'blue',
isVisible: true,
label: 'B-series',
seriesIndex: 1,
timeField: {
type: FieldType.time,
name: 'time',
values: new ArrayVector([1546372800000, 1546376400000, 1546380000000]),
config: {},
},
valueField: {
type: FieldType.number,
name: 'b-series',
values: new ArrayVector([20, 30, 40]),
config: { color: 'blue' },
},
timeStep: 3600000,
yAxis: {
index: 1,
},
},
];
const getStoriesKnobs = () => {
const rightAxisSeries = text('Right y-axis series, i.e. A,C', '');
const legendPlacement = select<LegendPlacement>(
@ -51,8 +80,6 @@ const getStoriesKnobs = () => {
);
return {
containerWidth,
containerHeight,
rightAxisSeries,
legendPlacement,
renderLegendAsTable,
@ -60,28 +87,37 @@ const getStoriesKnobs = () => {
};
GraphWithLegendStories.add('default', () => {
const { containerWidth, containerHeight, rightAxisSeries, legendPlacement, renderLegendAsTable } = getStoriesKnobs();
const props = mockGraphWithLegendData({
onSeriesColorChange: action('Series color changed'),
onSeriesAxisToggle: action('Series y-axis changed'),
const { legendPlacement, rightAxisSeries, renderLegendAsTable } = getStoriesKnobs();
const props: GraphWithLegendProps = {
series: series.map(s => {
if (
rightAxisSeries
.split(',')
.map(s => s.trim())
.indexOf(s.label.split('-')[0]) > -1
) {
s.yAxis = { index: 2 };
} else {
s.yAxis = { index: 1 };
}
return s;
}),
displayMode: renderLegendAsTable ? LegendDisplayMode.Table : LegendDisplayMode.List,
});
const series = props.series.map(s => {
if (
rightAxisSeries
.split(',')
.map(s => s.trim())
.indexOf(s.label.split('-')[0]) > -1
) {
s.yAxis = { index: 2 };
}
isLegendVisible: true,
onToggleSort: () => {},
timeRange: {
from: dateTime(1546372800000),
to: dateTime(1546380000000),
raw: {
from: dateTime(1546372800000),
to: dateTime(1546380000000),
},
},
timeZone: 'browser',
width: 600,
height: 300,
placement: legendPlacement,
};
return s;
});
return (
<div style={{ width: containerWidth, height: containerHeight }}>
<GraphWithLegend {...props} placement={legendPlacement} series={series} />,
</div>
);
return <GraphWithLegend {...props} />;
});

View File

@ -72,6 +72,7 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
lineWidth,
onHorizontalRegionSelected,
timeZone,
children,
} = props;
const { graphContainer, wrapper, legendContainer } = getGraphWithLegendStyles(props);
@ -105,7 +106,9 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
isStacked={isStacked}
lineWidth={lineWidth}
onHorizontalRegionSelected={onHorizontalRegionSelected}
/>
>
{children}
</Graph>
</div>
{isLegendVisible && (

View File

@ -0,0 +1,17 @@
export interface FlotPosition {
pageX: number;
pageY: number;
x: number;
x1: number;
y: number;
y1: number;
}
export interface FlotItem<T> {
datapoint: [number, number];
dataIndex: number;
series: T;
seriesIndex: number;
pageX: number;
pageY: number;
}

View File

@ -0,0 +1,151 @@
import { GraphSeriesValue, toDataFrame, FieldType, FieldCache } from '@grafana/data';
import { getMultiSeriesGraphHoverInfo, findHoverIndexFromData } from './utils';
const mockResult = (
value: GraphSeriesValue,
datapointIndex: number,
seriesIndex: number,
color?: string,
label?: string,
time?: GraphSeriesValue
) => ({
value,
datapointIndex,
seriesIndex,
color,
label,
time,
});
// A and B series have the same x-axis range and the datapoints are x-axis aligned
const aSeries = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'value', type: FieldType.number, values: [10, 20, 10], config: { color: 'red' } },
],
});
const bSeries = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'value', type: FieldType.number, values: [30, 60, 30], config: { color: 'blue' } },
],
});
// C-series has the same x-axis range as A and B but is missing the middle point
const cSeries = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [100, 300] },
{ name: 'value', type: FieldType.number, values: [30, 30], config: { color: 'yellow' } },
],
});
describe('Graph utils', () => {
describe('getMultiSeriesGraphHoverInfo', () => {
describe('when series datapoints are x-axis aligned', () => {
it('returns a datapoints that user hovers over', () => {
const aCache = new FieldCache(aSeries);
const aValueField = aCache.getFieldByName('value');
const aTimeField = aCache.getFieldByName('time');
const bCache = new FieldCache(bSeries);
const bValueField = bCache.getFieldByName('value');
const bTimeField = bCache.getFieldByName('time');
const result = getMultiSeriesGraphHoverInfo([aValueField!, bValueField!], [aTimeField!, bTimeField!], 0);
expect(result.time).toBe(100);
expect(result.results[0]).toEqual(mockResult(10, 0, 0, aValueField!.config.color, aValueField!.name, 100));
expect(result.results[1]).toEqual(mockResult(30, 0, 1, bValueField!.config.color, bValueField!.name, 100));
});
describe('returns the closest datapoints before the hover position', () => {
it('when hovering right before a datapoint', () => {
const aCache = new FieldCache(aSeries);
const aValueField = aCache.getFieldByName('value');
const aTimeField = aCache.getFieldByName('time');
const bCache = new FieldCache(bSeries);
const bValueField = bCache.getFieldByName('value');
const bTimeField = bCache.getFieldByName('time');
// hovering right before middle point
const result = getMultiSeriesGraphHoverInfo([aValueField!, bValueField!], [aTimeField!, bTimeField!], 199);
expect(result.time).toBe(100);
expect(result.results[0]).toEqual(mockResult(10, 0, 0, aValueField!.config.color, aValueField!.name, 100));
expect(result.results[1]).toEqual(mockResult(30, 0, 1, bValueField!.config.color, bValueField!.name, 100));
});
it('when hovering right after a datapoint', () => {
const aCache = new FieldCache(aSeries);
const aValueField = aCache.getFieldByName('value');
const aTimeField = aCache.getFieldByName('time');
const bCache = new FieldCache(bSeries);
const bValueField = bCache.getFieldByName('value');
const bTimeField = bCache.getFieldByName('time');
// hovering right after middle point
const result = getMultiSeriesGraphHoverInfo([aValueField!, bValueField!], [aTimeField!, bTimeField!], 201);
expect(result.time).toBe(200);
expect(result.results[0]).toEqual(mockResult(20, 1, 0, aValueField!.config.color, aValueField!.name, 200));
expect(result.results[1]).toEqual(mockResult(60, 1, 1, bValueField!.config.color, bValueField!.name, 200));
});
});
});
describe('when series x-axes are not aligned', () => {
// aSeries and cSeries are not aligned
// cSeries is missing a middle point
it('hovering over a middle point', () => {
const aCache = new FieldCache(aSeries);
const aValueField = aCache.getFieldByName('value');
const aTimeField = aCache.getFieldByName('time');
const cCache = new FieldCache(cSeries);
const cValueField = cCache.getFieldByName('value');
const cTimeField = cCache.getFieldByName('time');
// hovering on a middle point
// aSeries has point at that time, cSeries doesn't
const result = getMultiSeriesGraphHoverInfo([aValueField!, cValueField!], [aTimeField!, cTimeField!], 200);
// we expect a time of the hovered point
expect(result.time).toBe(200);
// we expect middle point from aSeries (the one we are hovering over)
expect(result.results[0]).toEqual(mockResult(20, 1, 0, aValueField!.config.color, aValueField!.name, 200));
// we expect closest point before hovered point from cSeries (1st point)
expect(result.results[1]).toEqual(mockResult(30, 0, 1, cValueField!.config.color, cValueField!.name, 100));
});
it('hovering right after over the middle point', () => {
const aCache = new FieldCache(aSeries);
const aValueField = aCache.getFieldByName('value');
const aTimeField = aCache.getFieldByName('time');
const cCache = new FieldCache(cSeries);
const cValueField = cCache.getFieldByName('value');
const cTimeField = cCache.getFieldByName('time');
// aSeries has point at that time, cSeries doesn't
const result = getMultiSeriesGraphHoverInfo([aValueField!, cValueField!], [aTimeField!, cTimeField!], 201);
// we expect the time of the closest point before hover
expect(result.time).toBe(200);
// we expect the closest datapoint before hover from aSeries
expect(result.results[0]).toEqual(mockResult(20, 1, 0, aValueField!.config.color, aValueField!.name, 200));
// we expect the closest datapoint before hover from cSeries (1st point)
expect(result.results[1]).toEqual(mockResult(30, 0, 1, cValueField!.config.color, cValueField!.name, 100));
});
});
});
describe('findHoverIndexFromData', () => {
it('returns index of the closest datapoint before hover position', () => {
const cache = new FieldCache(aSeries);
const timeField = cache.getFieldByName('time');
// hovering over 1st datapoint
expect(findHoverIndexFromData(timeField!, 0)).toBe(0);
// hovering over right before 2nd datapoint
expect(findHoverIndexFromData(timeField!, 199)).toBe(0);
// hovering over 2nd datapoint
expect(findHoverIndexFromData(timeField!, 200)).toBe(1);
// hovering over right before 3rd datapoint
expect(findHoverIndexFromData(timeField!, 299)).toBe(1);
// hovering over 3rd datapoint
expect(findHoverIndexFromData(timeField!, 300)).toBe(2);
});
});
});

View File

@ -0,0 +1,94 @@
import { GraphSeriesValue, Field } from '@grafana/data';
/**
* Returns index of the closest datapoint BEFORE hover position
*
* @param posX
* @param series
*/
export const findHoverIndexFromData = (xAxisDimension: Field, xPos: number) => {
let lower = 0;
let upper = xAxisDimension.values.length - 1;
let middle;
while (true) {
if (lower > upper) {
return Math.max(upper, 0);
}
middle = Math.floor((lower + upper) / 2);
const xPosition = xAxisDimension.values.get(middle);
if (xPosition === xPos) {
return middle;
} else if (xPosition && xPosition < xPos) {
lower = middle + 1;
} else {
upper = middle - 1;
}
}
};
interface MultiSeriesHoverInfo {
value: string;
time: string;
datapointIndex: number;
seriesIndex: number;
label?: string;
color?: string;
}
/**
* Returns information about closest datapoints when hovering over a Graph
*
* @param seriesList list of series visible on the Graph
* @param pos mouse cursor position, based on jQuery.flot position
*/
export const getMultiSeriesGraphHoverInfo = (
// x and y axis dimensions order is aligned
yAxisDimensions: Field[],
xAxisDimensions: Field[],
/** Well, time basically */
xAxisPosition: number
): {
results: MultiSeriesHoverInfo[];
time?: GraphSeriesValue;
} => {
let value, i, series, hoverIndex, hoverDistance, pointTime;
const results: MultiSeriesHoverInfo[] = [];
let minDistance, minTime;
for (i = 0; i < yAxisDimensions.length; i++) {
series = yAxisDimensions[i];
const time = xAxisDimensions[i];
hoverIndex = findHoverIndexFromData(time, xAxisPosition);
hoverDistance = xAxisPosition - time.values.get(hoverIndex);
pointTime = time.values.get(hoverIndex);
// Take the closest point before the cursor, or if it does not exist, the closest after
if (
minDistance === undefined ||
(hoverDistance >= 0 && (hoverDistance < minDistance || minDistance < 0)) ||
(hoverDistance < 0 && hoverDistance > minDistance)
) {
minDistance = hoverDistance;
minTime = time.display ? time.display(pointTime).text : pointTime;
}
value = series.values.get(hoverIndex);
results.push({
value: series.display ? series.display(value).text : value,
datapointIndex: hoverIndex,
seriesIndex: i,
color: series.config.color,
label: series.name,
time: time.display ? time.display(pointTime).text : pointTime,
});
}
return {
results,
time: minTime,
};
};

View File

@ -5,6 +5,9 @@ export interface SeriesIconProps {
color: string;
className?: string;
}
export const SeriesIcon: React.FunctionComponent<SeriesIconProps> = ({ color, className }) => {
return <i className={cx('fa', 'fa-minus', className)} style={{ color }} />;
};
SeriesIcon.displayName = 'SeriesIcon';

View File

@ -22,12 +22,13 @@ import {
const labelWidth = 6;
export interface Props {
showMinMax: boolean;
showMinMax?: boolean;
showTitle?: boolean;
value: FieldConfig;
onChange: (value: FieldConfig, event?: React.SyntheticEvent<HTMLElement>) => void;
}
export const FieldPropertiesEditor: React.FC<Props> = ({ value, onChange, showMinMax }) => {
export const FieldPropertiesEditor: React.FC<Props> = ({ value, onChange, showMinMax, showTitle }) => {
const { unit, title } = value;
const [decimals, setDecimals] = useState(
@ -88,14 +89,16 @@ export const FieldPropertiesEditor: React.FC<Props> = ({ value, onChange, showMi
return (
<>
<FormField
label="Title"
labelWidth={labelWidth}
onChange={onTitleChange}
value={title}
tooltip={titleTooltip}
placeholder="Auto"
/>
{showTitle && (
<FormField
label="Title"
labelWidth={labelWidth}
onChange={onTitleChange}
value={title}
tooltip={titleTooltip}
placeholder="Auto"
/>
)}
<div className="gf-form">
<FormLabel width={labelWidth}>Unit</FormLabel>

View File

@ -52,6 +52,7 @@ export { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph';
export { GraphLegend } from './Graph/GraphLegend';
export { GraphWithLegend } from './Graph/GraphWithLegend';
export { GraphTooltipOptions } from './Graph/GraphTooltip/types';
export { BarGauge } from './BarGauge/BarGauge';
export { VizRepeater } from './VizRepeater/VizRepeater';
export {
@ -95,6 +96,7 @@ export { Spinner } from './Spinner/Spinner';
export { FadeTransition } from './transitions/FadeTransition';
export { SlideOutTransition } from './transitions/SlideOutTransition';
export { Segment, SegmentAsync, SegmentSelect } from './Segment/';
export { default as Chart } from './Chart';
// Next-gen forms
export { default as Forms } from './Forms';

View File

@ -144,7 +144,7 @@ const emptyLogsModel: any = {
describe('dataFrameToLogsModel', () => {
it('given empty series should return empty logs model', () => {
expect(dataFrameToLogsModel([] as DataFrame[], 0)).toMatchObject(emptyLogsModel);
expect(dataFrameToLogsModel([] as DataFrame[], 0, 'utc')).toMatchObject(emptyLogsModel);
});
it('given series without correct series name should return empty logs model', () => {
@ -153,7 +153,7 @@ describe('dataFrameToLogsModel', () => {
fields: [],
}),
];
expect(dataFrameToLogsModel(series, 0)).toMatchObject(emptyLogsModel);
expect(dataFrameToLogsModel(series, 0, 'utc')).toMatchObject(emptyLogsModel);
});
it('given series without a time field should return empty logs model', () => {
@ -168,7 +168,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
expect(dataFrameToLogsModel(series, 0)).toMatchObject(emptyLogsModel);
expect(dataFrameToLogsModel(series, 0, 'utc')).toMatchObject(emptyLogsModel);
});
it('given series without a string field should return empty logs model', () => {
@ -183,7 +183,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
expect(dataFrameToLogsModel(series, 0)).toMatchObject(emptyLogsModel);
expect(dataFrameToLogsModel(series, 0, 'utc')).toMatchObject(emptyLogsModel);
});
it('given one series should return expected logs model', () => {
@ -218,7 +218,7 @@ describe('dataFrameToLogsModel', () => {
},
}),
];
const logsModel = dataFrameToLogsModel(series, 0);
const logsModel = dataFrameToLogsModel(series, 0, 'utc');
expect(logsModel.hasUniqueLabels).toBeFalsy();
expect(logsModel.rows).toHaveLength(2);
expect(logsModel.rows).toMatchObject([
@ -276,7 +276,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
const logsModel = dataFrameToLogsModel(series, 0);
const logsModel = dataFrameToLogsModel(series, 0, 'utc');
expect(logsModel.rows).toHaveLength(1);
expect(logsModel.rows).toMatchObject([
{
@ -330,7 +330,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
const logsModel = dataFrameToLogsModel(series, 0);
const logsModel = dataFrameToLogsModel(series, 0, 'utc');
expect(logsModel.hasUniqueLabels).toBeTruthy();
expect(logsModel.rows).toHaveLength(3);
expect(logsModel.rows).toMatchObject([
@ -425,7 +425,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
const logsModel = dataFrameToLogsModel(series, 0);
const logsModel = dataFrameToLogsModel(series, 0, 'utc');
expect(logsModel.hasUniqueLabels).toBeTruthy();
expect(logsModel.rows).toHaveLength(4);
expect(logsModel.rows).toMatchObject([
@ -474,7 +474,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
const logsModel = dataFrameToLogsModel(series, 0);
const logsModel = dataFrameToLogsModel(series, 0, 'utc');
expect(logsModel.rows[0].uid).toBe('0');
});
});

View File

@ -23,6 +23,8 @@ import {
FieldCache,
FieldWithIndex,
getFlotPairs,
TimeZone,
getDisplayProcessor,
} from '@grafana/data';
import { getThemeColor } from 'app/core/utils/colors';
import { hasAnsiCodes } from 'app/core/utils/text';
@ -85,7 +87,7 @@ export function filterLogLevels(logRows: LogRowModel[], hiddenLogLevels: Set<Log
});
}
export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): GraphSeriesXY[] {
export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number, timeZone: TimeZone): GraphSeriesXY[] {
// currently interval is rangeMs / resolution, which is too low for showing series as bars.
// need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain
// when executing queries & interval calculated and not here but this is a temporary fix.
@ -105,6 +107,7 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Grap
lastTs: null,
datapoints: [],
alias: row.logLevel,
target: row.logLevel,
color: LogLevelColor[row.logLevel],
};
@ -132,7 +135,7 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Grap
}
}
return seriesList.map(series => {
return seriesList.map((series, i) => {
series.datapoints.sort((a: number[], b: number[]) => {
return a[1] - b[1];
});
@ -145,6 +148,14 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Grap
nullValueMode: NullValueMode.Null,
});
const timeField = data.fields[1];
timeField.display = getDisplayProcessor({
config: timeField.config,
type: timeField.type,
isUtc: timeZone === 'utc',
});
const graphSeries: GraphSeriesXY = {
color: series.color,
label: series.alias,
@ -155,6 +166,12 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Grap
min: 0,
tickDecimals: 0,
},
seriesIndex: i,
timeField,
valueField: data.fields[0],
// for now setting the time step to be 0,
// and handle the bar width by setting lineWidth instead of barWidth in flot options
timeStep: 0,
};
return graphSeries;
@ -171,18 +188,19 @@ function isLogsData(series: DataFrame) {
* @param dataFrame
* @param intervalMs In case there are no metrics series, we use this for computing it from log rows.
*/
export function dataFrameToLogsModel(dataFrame: DataFrame[], intervalMs: number): LogsModel {
export function dataFrameToLogsModel(dataFrame: DataFrame[], intervalMs: number, timeZone: TimeZone): LogsModel {
const { logSeries, metricSeries } = separateLogsAndMetrics(dataFrame);
const logsModel = logSeriesToLogsModel(logSeries);
if (logsModel) {
if (metricSeries.length === 0) {
// Create metrics from logs
logsModel.series = makeSeriesForLogs(logsModel.rows, intervalMs);
logsModel.series = makeSeriesForLogs(logsModel.rows, intervalMs, timeZone);
} else {
// We got metrics in the dataFrame so process those
logsModel.series = getGraphSeriesModel(
metricSeries,
timeZone,
{},
{ showBars: true, showLines: false, showPoints: false },
{

View File

@ -11,6 +11,7 @@ import {
Collapse,
GraphSeriesToggler,
GraphSeriesTogglerAPI,
Chart,
} from '@grafana/ui';
const MAX_NUMBER_OF_TIME_SERIES = 20;
@ -136,7 +137,10 @@ class UnThemedExploreGraphPanel extends PureComponent<Props, State> {
lineWidth={lineWidth}
onSeriesToggle={onSeriesToggle}
onHorizontalRegionSelected={this.onChangeTime}
/>
>
{/* For logs we are using mulit mode until we refactor logs histogram to use barWidth instead of lineWidth to render bars */}
<Chart.Tooltip mode={showBars ? 'multi' : 'single'} />
</GraphWithLegend>
);
}}
</GraphSeriesToggler>

View File

@ -19,6 +19,7 @@ import {
PanelData,
DataQueryRequest,
PanelEvents,
TimeZone,
} from '@grafana/data';
import { RefreshPicker } from '@grafana/ui';
import {
@ -589,7 +590,7 @@ export const processQueryResponse = (
}
const latency = request.endTime ? request.endTime - request.startTime : 0;
const processor = new ResultProcessor(state, series, request.intervalMs);
const processor = new ResultProcessor(state, series, request.intervalMs, request.timezone as TimeZone);
const graphResult = processor.getGraphResult();
const tableResult = processor.getTableResult();
const logsResult = processor.getLogsResult();

View File

@ -58,7 +58,7 @@ const testContext = (options: any = {}) => {
queryIntervals: { intervalMs: 10 },
} as any) as ExploreItemState;
const resultProcessor = new ResultProcessor(state, combinedOptions.dataFrames, 60000);
const resultProcessor = new ResultProcessor(state, combinedOptions.dataFrames, 60000, 'utc');
return {
dataFrames: combinedOptions.dataFrames,
@ -99,7 +99,9 @@ describe('ResultProcessor', () => {
describe('constructed with a result that is a DataQueryResponse', () => {
describe('when calling getGraphResult', () => {
it('then it should return correct graph result', () => {
const { resultProcessor } = testContext();
const { resultProcessor, dataFrames } = testContext();
const timeField = dataFrames[0].fields[1];
const valueField = dataFrames[0].fields[0];
const theResult = resultProcessor.getGraphResult();
expect(theResult).toEqual([
@ -112,6 +114,10 @@ describe('ResultProcessor', () => {
yAxis: {
index: 1,
},
seriesIndex: 0,
timeField,
valueField,
timeStep: 100,
},
]);
});
@ -138,6 +144,8 @@ describe('ResultProcessor', () => {
describe('when calling getLogsResult', () => {
it('then it should return correct logs result', () => {
const { resultProcessor, dataFrames } = testContext({ mode: ExploreMode.Logs });
const timeField = dataFrames[0].fields[1];
const valueField = dataFrames[0].fields[0];
const logsDataFrame = dataFrames[1];
const theResult = resultProcessor.getLogsResult();
@ -210,6 +218,10 @@ describe('ResultProcessor', () => {
yAxis: {
index: 1,
},
seriesIndex: 0,
timeField,
valueField,
timeStep: 100,
},
],
});

View File

@ -1,4 +1,4 @@
import { LogsModel, GraphSeriesXY, DataFrame, FieldType } from '@grafana/data';
import { LogsModel, GraphSeriesXY, DataFrame, FieldType, TimeZone } from '@grafana/data';
import { ExploreItemState, ExploreMode } from 'app/types/explore';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
@ -7,7 +7,12 @@ import { dataFrameToLogsModel } from 'app/core/logs_model';
import { getGraphSeriesModel } from 'app/plugins/panel/graph2/getGraphSeriesModel';
export class ResultProcessor {
constructor(private state: ExploreItemState, private dataFrames: DataFrame[], private intervalMs: number) {}
constructor(
private state: ExploreItemState,
private dataFrames: DataFrame[],
private intervalMs: number,
private timeZone: TimeZone
) {}
getGraphResult(): GraphSeriesXY[] {
if (this.state.mode !== ExploreMode.Metrics) {
@ -22,6 +27,7 @@ export class ResultProcessor {
return getGraphSeriesModel(
onlyTimeSeries,
this.timeZone,
{},
{ showBars: false, showLines: true, showPoints: false },
{ asTable: false, isVisible: true, placement: 'under' }
@ -77,7 +83,7 @@ export class ResultProcessor {
return null;
}
const newResults = dataFrameToLogsModel(this.dataFrames, this.intervalMs);
const newResults = dataFrameToLogsModel(this.dataFrames, this.intervalMs, this.timeZone);
const sortOrder = refreshIntervalToSortOrder(this.state.refreshInterval);
const sortedNewResults = sortLogsResult(newResults, sortOrder);

View File

@ -1,5 +1,5 @@
import React from 'react';
import { GraphWithLegend } from '@grafana/ui';
import { GraphWithLegend, Chart } from '@grafana/ui';
import { PanelProps } from '@grafana/data';
import { Options } from './types';
import { GraphPanelController } from './GraphPanelController';
@ -28,17 +28,20 @@ export const GraphPanel: React.FunctionComponent<GraphPanelProps> = ({
const {
graph: { showLines, showBars, showPoints },
legend: legendOptions,
tooltipOptions,
} = options;
const graphProps = {
showBars,
showLines,
showPoints,
tooltipOptions,
};
const { asTable, isVisible, ...legendProps } = legendOptions;
return (
<GraphPanelController
data={data}
timeZone={timeZone}
options={options}
onOptionsChange={onOptionsChange}
onChangeTimeRange={onChangeTimeRange}
@ -59,7 +62,9 @@ export const GraphPanel: React.FunctionComponent<GraphPanelProps> = ({
{...graphProps}
{...legendProps}
{...controllerApi}
/>
>
<Chart.Tooltip mode={tooltipOptions.mode} />
</GraphWithLegend>
);
}}
</GraphPanelController>

View File

@ -1,6 +1,6 @@
import React from 'react';
import { GraphSeriesToggler } from '@grafana/ui';
import { PanelData, GraphSeriesXY, AbsoluteTimeRange } from '@grafana/data';
import { PanelData, GraphSeriesXY, AbsoluteTimeRange, TimeZone } from '@grafana/data';
import { getGraphSeriesModel } from './getGraphSeriesModel';
import { Options, SeriesOptions } from './types';
@ -19,6 +19,7 @@ interface GraphPanelControllerProps {
children: (api: GraphPanelControllerAPI) => JSX.Element;
options: Options;
data: PanelData;
timeZone: TimeZone;
onOptionsChange: (options: Options) => void;
onChangeTimeRange: (timeRange: AbsoluteTimeRange) => void;
}
@ -39,9 +40,11 @@ export class GraphPanelController extends React.Component<GraphPanelControllerPr
this.state = {
graphSeriesModel: getGraphSeriesModel(
props.data.series,
props.timeZone,
props.options.series,
props.options.graph,
props.options.legend
props.options.legend,
props.options.fieldOptions
),
};
}
@ -51,9 +54,11 @@ export class GraphPanelController extends React.Component<GraphPanelControllerPr
...state,
graphSeriesModel: getGraphSeriesModel(
props.data.series,
props.timeZone,
props.options.series,
props.options.graph,
props.options.legend
props.options.legend,
props.options.fieldOptions
),
};
}

View File

@ -3,8 +3,16 @@ import _ from 'lodash';
import React, { PureComponent } from 'react';
// Types
import { PanelEditorProps } from '@grafana/data';
import { Switch, LegendOptions } from '@grafana/ui';
import { PanelEditorProps, FieldConfig } from '@grafana/data';
import {
Switch,
LegendOptions,
GraphTooltipOptions,
PanelOptionsGrid,
PanelOptionsGroup,
FieldPropertiesEditor,
Select,
} from '@grafana/ui';
import { Options, GraphOptions } from './types';
import { GraphLegendEditor } from './GraphLegendEditor';
@ -23,6 +31,10 @@ export class GraphPanelEditor extends PureComponent<PanelEditorProps<Options>> {
this.props.onOptionsChange({ ...this.props.options, legend: options });
};
onTooltipOptionsChange = (options: GraphTooltipOptions) => {
this.props.onOptionsChange({ ...this.props.options, tooltipOptions: options });
};
onToggleLines = () => {
this.onGraphOptionsChange({ showLines: !this.props.options.graph.showLines });
};
@ -35,9 +47,20 @@ export class GraphPanelEditor extends PureComponent<PanelEditorProps<Options>> {
this.onGraphOptionsChange({ showPoints: !this.props.options.graph.showPoints });
};
onDefaultsChange = (field: FieldConfig) => {
this.props.onOptionsChange({
...this.props.options,
fieldOptions: {
...this.props.options.fieldOptions,
defaults: field,
},
});
};
render() {
const {
graph: { showBars, showPoints, showLines },
tooltipOptions: { mode },
} = this.props.options;
return (
@ -48,7 +71,25 @@ export class GraphPanelEditor extends PureComponent<PanelEditorProps<Options>> {
<Switch label="Bars" labelClass="width-5" checked={showBars} onChange={this.onToggleBars} />
<Switch label="Points" labelClass="width-5" checked={showPoints} onChange={this.onTogglePoints} />
</div>
<GraphLegendEditor options={this.props.options.legend} onChange={this.onLegendOptionsChange} />
<PanelOptionsGrid>
<PanelOptionsGroup title="Field">
<FieldPropertiesEditor
showMinMax={false}
onChange={this.onDefaultsChange}
value={this.props.options.fieldOptions.defaults}
/>
</PanelOptionsGroup>
<PanelOptionsGroup title="Tooltip">
<Select
value={{ value: mode, label: mode === 'single' ? 'Single' : 'All series' }}
onChange={value => {
this.onTooltipOptionsChange({ mode: value.value as any });
}}
options={[{ label: 'All series', value: 'multi' }, { label: 'Single', value: 'single' }]}
/>
</PanelOptionsGroup>
<GraphLegendEditor options={this.props.options.legend} onChange={this.onLegendOptionsChange} />
</PanelOptionsGrid>
</>
);
}

View File

@ -10,6 +10,12 @@ import {
GraphSeriesXY,
getTimeField,
DataFrame,
FieldDisplayOptions,
getSeriesTimeStep,
TimeZone,
hasMsResolution,
MS_DATE_TIME_FORMAT,
DEFAULT_DATE_TIME_FORMAT,
} from '@grafana/data';
import { SeriesOptions, GraphOptions } from './types';
@ -17,9 +23,11 @@ import { GraphLegendEditorLegendOptions } from './GraphLegendEditor';
export const getGraphSeriesModel = (
dataFrames: DataFrame[],
timeZone: TimeZone,
seriesOptions: SeriesOptions,
graphOptions: GraphOptions,
legendOptions: GraphLegendEditorLegendOptions
legendOptions: GraphLegendEditorLegendOptions,
fieldOptions?: FieldDisplayOptions
) => {
const graphs: GraphSeriesXY[] = [];
@ -29,6 +37,7 @@ export const getGraphSeriesModel = (
},
});
let fieldColumnIndex = -1;
for (const series of dataFrames) {
const { timeField } = getTimeField(series);
if (!timeField) {
@ -39,6 +48,8 @@ export const getGraphSeriesModel = (
if (field.type !== FieldType.number) {
continue;
}
// Storing index of series field for future inspection
fieldColumnIndex++;
// Use external calculator just to make sure it works :)
const points = getFlotPairs({
@ -68,6 +79,29 @@ export const getGraphSeriesModel = (
? getColorFromHexRgbOrName(seriesOptions[field.name].color)
: colors[graphs.length % colors.length];
field.config = fieldOptions
? {
...field.config,
unit: fieldOptions.defaults.unit,
decimals: fieldOptions.defaults.decimals,
color: seriesColor,
}
: { ...field.config };
field.display = getDisplayProcessor({ config: { ...field.config }, type: field.type });
// Time step is used to determine bars width when graph is rendered as bar chart
const timeStep = getSeriesTimeStep(timeField);
const useMsDateFormat = hasMsResolution(timeField);
timeField.display = getDisplayProcessor({
type: timeField.type,
isUtc: timeZone === 'utc',
config: {
dateDisplayFormat: useMsDateFormat ? MS_DATE_TIME_FORMAT : DEFAULT_DATE_TIME_FORMAT,
},
});
graphs.push({
label: field.name,
data: points,
@ -77,6 +111,11 @@ export const getGraphSeriesModel = (
yAxis: {
index: (seriesOptions[field.name] && seriesOptions[field.name].yAxis) || 1,
},
// This index is used later on to retrieve appropriate series/time for X and Y axes
seriesIndex: fieldColumnIndex,
timeField: { ...timeField },
valueField: { ...field },
timeStep,
});
}
}

View File

@ -1,7 +1,9 @@
import { LegendOptions } from '@grafana/ui';
import { YAxis } from '@grafana/data';
import { LegendOptions, GraphTooltipOptions } from '@grafana/ui';
import { YAxis, FieldDisplayOptions } from '@grafana/data';
import { GraphLegendEditorLegendOptions } from './GraphLegendEditor';
// TODO move out from single stat
import { standardFieldDisplayOptions } from '../singlestat2/types';
export interface SeriesOptions {
color?: string;
@ -20,6 +22,8 @@ export interface Options {
series: {
[alias: string]: SeriesOptions;
};
fieldOptions: FieldDisplayOptions;
tooltipOptions: GraphTooltipOptions;
}
export const defaults: Options = {
@ -34,4 +38,6 @@ export const defaults: Options = {
placement: 'under',
},
series: {},
fieldOptions: { ...standardFieldDisplayOptions },
tooltipOptions: { mode: 'single' },
};

View File

@ -21,7 +21,7 @@ export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({
);
}
const newResults = data ? dataFrameToLogsModel(data.series, data.request.intervalMs) : null;
const newResults = data ? dataFrameToLogsModel(data.series, data.request.intervalMs, timeZone) : null;
const sortedNewResults = sortLogsResult(newResults, sortOrder);
return (

View File

@ -2,6 +2,12 @@ import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import 'jquery';
import $ from 'jquery';
const global = window as any;
global.$ = global.jQuery = $;
import '../vendor/flot/jquery.flot';
import '../vendor/flot/jquery.flot.time';
import 'angular';
import angular from 'angular';
@ -18,9 +24,6 @@ jest.mock('app/features/plugins/plugin_loader', () => ({}));
configure({ adapter: new Adapter() });
const global = window as any;
global.$ = global.jQuery = $;
const localStorageMock = (() => {
let store: any = {};
return {

View File

@ -3664,6 +3664,13 @@
"@types/prop-types" "*"
"@types/react" "*"
"@types/react-wait@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@types/react-wait/-/react-wait-0.3.0.tgz#6f7ef17571a17e72c7864ede8cf7d3aa525a005e"
integrity sha512-5jIfDcHRjqeE7QfZG7kCqOpfrPSvOM1E3/nlKuJ/NZrG/WrhLo/AFr0i72jhTWzyNRo4ex0pshBaiCHksZXH3A==
dependencies:
"@types/react" "*"
"@types/react-window@1.7.0":
version "1.7.0"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.7.0.tgz#8dd99822c54380c9c05df213b7b4400c24c9877e"
@ -6295,6 +6302,11 @@ color-convert@^1.9.0, color-convert@^1.9.1:
dependencies:
color-name "1.1.3"
color-convert@~0.5.0:
version "0.5.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd"
integrity sha1-vbbGnOZg+t/+CwAHzER+G59ygr0=
color-name@1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
@ -7111,6 +7123,11 @@ cssfilter@0.0.10:
resolved "https://registry.yarnpkg.com/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae"
integrity sha1-xtJnJjKi5cg+AT5oZKQs6N79IK4=
cssfontparser@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3"
integrity sha1-9AIvyPlwDGgCnVQghK+69CWj8+M=
cssnano-preset-default@^4.0.7:
version "4.0.7"
resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76"
@ -11869,6 +11886,14 @@ istanbul-reports@^2.2.6:
dependencies:
handlebars "^4.1.2"
jest-canvas-mock@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.1.2.tgz#0d16c9f91534f773fd132fc289f2e6b6db8faa28"
integrity sha512-1VI4PK4/X70yrSjYScYVkYJYbXYlZLKJkUrAlyHjQsfolv64aoFyIrmMDtqCjpYrpVvWYEcAGUaYv5DVJj00oQ==
dependencies:
cssfontparser "^1.2.1"
parse-color "^1.0.0"
jest-changed-files@^24.9.0:
version "24.9.0"
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.9.0.tgz#08d8c15eb79a7fa3fc98269bc14b451ee82f8039"
@ -15203,6 +15228,13 @@ parse-asn1@^5.0.0:
pbkdf2 "^3.0.3"
safe-buffer "^5.1.1"
parse-color@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/parse-color/-/parse-color-1.0.0.tgz#7b748b95a83f03f16a94f535e52d7f3d94658619"
integrity sha1-e3SLlag/A/FqlPU15S1/PZRlhhk=
dependencies:
color-convert "~0.5.0"
parse-entities@^1.1.0, parse-entities@^1.1.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50"
@ -17352,18 +17384,22 @@ react-transition-group@^2.2.1:
prop-types "^15.6.2"
react-lifecycles-compat "^3.0.4"
react-use@9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/react-use/-/react-use-9.0.0.tgz#142bec53fa465db2a6e43c68a8c9ef2acc000592"
integrity sha512-jlXJneB96yl4VvAXDKyE6cmdIeWk0cO7Gomh870Qu0vXZ9YM2JjjR09E9vIPPPI2M27RWo2dZKXspv44Wxtoog==
react-use@12.8.0:
version "12.8.0"
resolved "https://registry.yarnpkg.com/react-use/-/react-use-12.8.0.tgz#72f03d9f3c82d8e86b0d0c5d0c5d7e7b1b4bb822"
integrity sha512-uRnLUO1wLtjaVEqrtBndZe5x1SGF5NWfK5qTXbMmvowmTdVsZ757BQ/U1oJJMzY2h3iBC2khCK29XsXMb7hYYw==
dependencies:
"@types/react-wait" "^0.3.0"
copy-to-clipboard "^3.1.0"
nano-css "^5.1.0"
react-fast-compare "^2.0.4"
react-wait "^0.3.0"
screenfull "^4.1.0"
resize-observer-polyfill "^1.5.1"
screenfull "^5.0.0"
set-harmonic-interval "^1.0.1"
throttle-debounce "^2.0.1"
ts-easing "^0.2.0"
tslib "^1.10.0"
react-virtualized@9.21.0:
version "9.21.0"
@ -18504,10 +18540,10 @@ schema-utils@^2.0.0, schema-utils@^2.4.1:
ajv "^6.10.2"
ajv-keywords "^3.4.1"
screenfull@^4.1.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-4.2.1.tgz#3245b7bc73d2b7c9a15bd8caaf6965db7cbc7f04"
integrity sha512-PLSp6f5XdhvjCCCO8OjavRfzkSGL3Qmdm7P82bxyU8HDDDBhDV3UckRaYcRa/NDNTYt8YBpzjoLWHUAejmOjLg==
screenfull@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.0.0.tgz#5c2010c0e84fd4157bf852877698f90b8cbe96f6"
integrity sha512-yShzhaIoE9OtOhWVyBBffA6V98CDCoyHTsp8228blmqYy1Z5bddzE/4FPiJKlr8DVR4VBiiUyfPzIQPIYDkeMA==
scss-tokenizer@^0.2.3:
version "0.2.3"
@ -18642,6 +18678,11 @@ set-blocking@^2.0.0, set-blocking@~2.0.0:
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
set-harmonic-interval@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz#e1773705539cdfb80ce1c3d99e7f298bb3995249"
integrity sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==
set-value@^2.0.0, set-value@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
@ -20294,7 +20335,7 @@ ts-pnp@^1.1.2:
resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.1.4.tgz#ae27126960ebaefb874c6d7fa4729729ab200d90"
integrity sha512-1J/vefLC+BWSo+qe8OnJQfWTYRS6ingxjwqmHMqaMxXMj7kFtKLgAaYW3JeX3mktjgUL+etlU8/B4VUAUI9QGw==
tslib@1.10.0, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
tslib@1.10.0, tslib@^1.10.0, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
version "1.10.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==