mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Graph: introduce Tooltip to React graph (#20046)
This commit is contained in:
parent
08ada20270
commit
96dbed5efc
@ -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 } },
|
||||
|
@ -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",
|
||||
|
39
packages/grafana-data/src/dataframe/dimensions.ts
Normal file
39
packages/grafana-data/src/dataframe/dimensions.ts
Normal 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];
|
@ -3,3 +3,4 @@ export * from './FieldCache';
|
||||
export * from './CircularDataFrame';
|
||||
export * from './MutableDataFrame';
|
||||
export * from './processDataFrame';
|
||||
export * from './dimensions';
|
||||
|
@ -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 {
|
||||
|
@ -58,6 +58,7 @@ export interface TimeSeries extends QueryResultBase {
|
||||
target: string;
|
||||
datapoints: TimeSeriesPoints;
|
||||
unit?: string;
|
||||
color?: string;
|
||||
tags?: Labels;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
47
packages/grafana-data/src/utils/series.test.ts
Normal file
47
packages/grafana-data/src/utils/series.test.ts
Normal 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();
|
||||
});
|
||||
});
|
45
packages/grafana-data/src/utils/series.ts
Normal file
45
packages/grafana-data/src/utils/series.ts
Normal 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;
|
||||
};
|
100
packages/grafana-ui/src/components/Chart/Tooltip.test.tsx
Normal file
100
packages/grafana-ui/src/components/Chart/Tooltip.test.tsx
Normal 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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
70
packages/grafana-ui/src/components/Chart/Tooltip.tsx
Normal file
70
packages/grafana-ui/src/components/Chart/Tooltip.tsx
Normal 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';
|
@ -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';
|
7
packages/grafana-ui/src/components/Chart/index.tsx
Normal file
7
packages/grafana-ui/src/components/Chart/index.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { Tooltip } from './Tooltip';
|
||||
|
||||
const Chart = {
|
||||
Tooltip,
|
||||
};
|
||||
|
||||
export default Chart;
|
131
packages/grafana-ui/src/components/Graph/Graph.story.tsx
Normal file
131
packages/grafana-ui/src/components/Graph/Graph.story.tsx
Normal 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>
|
||||
);
|
||||
};
|
160
packages/grafana-ui/src/components/Graph/Graph.test.tsx
Normal file
160
packages/grafana-ui/src/components/Graph/Graph.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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';
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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';
|
@ -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} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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';
|
@ -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>;
|
||||
}
|
@ -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} />;
|
||||
});
|
||||
|
@ -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 && (
|
||||
|
File diff suppressed because it is too large
Load Diff
17
packages/grafana-ui/src/components/Graph/types.ts
Normal file
17
packages/grafana-ui/src/components/Graph/types.ts
Normal 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;
|
||||
}
|
151
packages/grafana-ui/src/components/Graph/utils.test.ts
Normal file
151
packages/grafana-ui/src/components/Graph/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
94
packages/grafana-ui/src/components/Graph/utils.ts
Normal file
94
packages/grafana-ui/src/components/Graph/utils.ts
Normal 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,
|
||||
};
|
||||
};
|
@ -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';
|
||||
|
@ -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>
|
||||
|
@ -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';
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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 },
|
||||
{
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
),
|
||||
};
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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' },
|
||||
};
|
||||
|
@ -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 (
|
||||
|
@ -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 {
|
||||
|
61
yarn.lock
61
yarn.lock
@ -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==
|
||||
|
Loading…
Reference in New Issue
Block a user