mirror of
https://github.com/grafana/grafana.git
synced 2025-09-18 19:52:52 +08:00
Graph: introduce Tooltip to React graph (#20046)
This commit is contained in:
@ -21,7 +21,8 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
"setupFiles": [
|
"setupFiles": [
|
||||||
"./public/test/jest-shim.ts",
|
"./public/test/jest-shim.ts",
|
||||||
"./public/test/jest-setup.ts"
|
"./public/test/jest-setup.ts",
|
||||||
|
"jest-canvas-mock"
|
||||||
],
|
],
|
||||||
"snapshotSerializers": ["enzyme-to-json/serializer"],
|
"snapshotSerializers": ["enzyme-to-json/serializer"],
|
||||||
"globals": { "ts-jest": { "isolatedModules": true } },
|
"globals": { "ts-jest": { "isolatedModules": true } },
|
||||||
|
@ -95,6 +95,7 @@
|
|||||||
"html-webpack-plugin": "3.2.0",
|
"html-webpack-plugin": "3.2.0",
|
||||||
"husky": "1.3.1",
|
"husky": "1.3.1",
|
||||||
"jest": "24.8.0",
|
"jest": "24.8.0",
|
||||||
|
"jest-canvas-mock": "2.1.2",
|
||||||
"jest-date-mock": "1.0.7",
|
"jest-date-mock": "1.0.7",
|
||||||
"lerna": "^3.15.0",
|
"lerna": "^3.15.0",
|
||||||
"lint-staged": "8.1.5",
|
"lint-staged": "8.1.5",
|
||||||
@ -241,7 +242,7 @@
|
|||||||
"react-sizeme": "2.5.2",
|
"react-sizeme": "2.5.2",
|
||||||
"react-table": "6.9.2",
|
"react-table": "6.9.2",
|
||||||
"react-transition-group": "2.6.1",
|
"react-transition-group": "2.6.1",
|
||||||
"react-use": "9.0.0",
|
"react-use": "12.8.0",
|
||||||
"react-virtualized": "9.21.0",
|
"react-virtualized": "9.21.0",
|
||||||
"react-window": "1.7.1",
|
"react-window": "1.7.1",
|
||||||
"redux": "4.0.4",
|
"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 './CircularDataFrame';
|
||||||
export * from './MutableDataFrame';
|
export * from './MutableDataFrame';
|
||||||
export * from './processDataFrame';
|
export * from './processDataFrame';
|
||||||
|
export * from './dimensions';
|
||||||
|
@ -63,9 +63,10 @@ function convertTimeSeriesToDataFrame(timeSeries: TimeSeries): DataFrame {
|
|||||||
type: FieldType.number,
|
type: FieldType.number,
|
||||||
config: {
|
config: {
|
||||||
unit: timeSeries.unit,
|
unit: timeSeries.unit,
|
||||||
|
color: timeSeries.color,
|
||||||
},
|
},
|
||||||
values: new ArrayVector<TimeSeriesValue>(),
|
values: new ArrayVector<TimeSeriesValue>(),
|
||||||
},
|
} as Field<TimeSeriesValue, ArrayVector<TimeSeriesValue>>,
|
||||||
{
|
{
|
||||||
name: 'Time',
|
name: 'Time',
|
||||||
type: FieldType.time,
|
type: FieldType.time,
|
||||||
@ -73,12 +74,12 @@ function convertTimeSeriesToDataFrame(timeSeries: TimeSeries): DataFrame {
|
|||||||
unit: 'dateTimeAsIso',
|
unit: 'dateTimeAsIso',
|
||||||
},
|
},
|
||||||
values: new ArrayVector<number>(),
|
values: new ArrayVector<number>(),
|
||||||
},
|
} as Field<number, ArrayVector<number>>,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const point of timeSeries.datapoints) {
|
for (const point of timeSeries.datapoints) {
|
||||||
fields[0].values.buffer.push(point[0]);
|
fields[0].values.buffer.push(point[0]);
|
||||||
fields[1].values.buffer.push(point[1]);
|
fields[1].values.buffer.push(point[1] as number);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -58,6 +58,7 @@ export interface TimeSeries extends QueryResultBase {
|
|||||||
target: string;
|
target: string;
|
||||||
datapoints: TimeSeriesPoints;
|
datapoints: TimeSeriesPoints;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
color?: string;
|
||||||
tags?: Labels;
|
tags?: Labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,6 +44,9 @@ export interface FieldConfig {
|
|||||||
// Alternative to empty string
|
// Alternative to empty string
|
||||||
noValue?: string;
|
noValue?: string;
|
||||||
|
|
||||||
|
// Visual options
|
||||||
|
color?: string;
|
||||||
|
|
||||||
// Used for time field formatting
|
// Used for time field formatting
|
||||||
dateDisplayFormat?: string;
|
dateDisplayFormat?: string;
|
||||||
}
|
}
|
||||||
@ -53,7 +56,6 @@ export interface Field<T = any, V = Vector<T>> {
|
|||||||
type: FieldType;
|
type: FieldType;
|
||||||
config: FieldConfig;
|
config: FieldConfig;
|
||||||
values: V; // The raw field values
|
values: V; // The raw field values
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache of reduced values
|
* Cache of reduced values
|
||||||
*/
|
*/
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { DisplayValue } from './displayValue';
|
import { DisplayValue } from './displayValue';
|
||||||
|
import { Field } from './dataFrame';
|
||||||
export interface YAxis {
|
export interface YAxis {
|
||||||
index: number;
|
index: number;
|
||||||
min?: number;
|
min?: number;
|
||||||
@ -16,6 +16,12 @@ export interface GraphSeriesXY {
|
|||||||
info?: DisplayValue[]; // Legend info
|
info?: DisplayValue[]; // Legend info
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
yAxis: YAxis;
|
yAxis: YAxis;
|
||||||
|
// Field with series' time values
|
||||||
|
timeField: Field;
|
||||||
|
// Field with series' values
|
||||||
|
valueField: Field;
|
||||||
|
seriesIndex: number;
|
||||||
|
timeStep: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePlotOverlay {
|
export interface CreatePlotOverlay {
|
||||||
|
@ -7,6 +7,7 @@ export * from './labels';
|
|||||||
export * from './object';
|
export * from './object';
|
||||||
export * from './thresholds';
|
export * from './thresholds';
|
||||||
export * from './namedColorsPalette';
|
export * from './namedColorsPalette';
|
||||||
|
export * from './series';
|
||||||
|
|
||||||
export { getMappedValue } from './valueMappings';
|
export { getMappedValue } from './valueMappings';
|
||||||
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';
|
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 React, { PureComponent } from 'react';
|
||||||
import uniqBy from 'lodash/uniqBy';
|
import uniqBy from 'lodash/uniqBy';
|
||||||
// Types
|
// 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 {
|
export interface GraphProps {
|
||||||
|
children?: JSX.Element | JSX.Element[];
|
||||||
series: GraphSeriesXY[];
|
series: GraphSeriesXY[];
|
||||||
timeRange: TimeRange; // NOTE: we should aim to make `time` a property of the axis, not force it for all graphs
|
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
|
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;
|
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 = {
|
static defaultProps = {
|
||||||
showLines: true,
|
showLines: true,
|
||||||
showPoints: false,
|
showPoints: false,
|
||||||
@ -28,11 +40,17 @@ export class Graph extends PureComponent<GraphProps> {
|
|||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
state: GraphState = {
|
||||||
|
isTooltipVisible: false,
|
||||||
|
};
|
||||||
|
|
||||||
element: HTMLElement | null = null;
|
element: HTMLElement | null = null;
|
||||||
$element: any;
|
$element: any;
|
||||||
|
|
||||||
componentDidUpdate() {
|
componentDidUpdate(prevProps: GraphProps, prevState: GraphState) {
|
||||||
this.draw();
|
if (prevProps !== this.props) {
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@ -40,6 +58,7 @@ export class Graph extends PureComponent<GraphProps> {
|
|||||||
if (this.element) {
|
if (this.element) {
|
||||||
this.$element = $(this.element);
|
this.$element = $(this.element);
|
||||||
this.$element.bind('plotselected', this.onPlotSelected);
|
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[]) {
|
getYAxes(series: GraphSeriesXY[]) {
|
||||||
if (series.length === 0) {
|
if (series.length === 0) {
|
||||||
return [{ show: true, min: -1, max: 1 }];
|
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() {
|
draw() {
|
||||||
if (this.element === null) {
|
if (this.element === null) {
|
||||||
return;
|
return;
|
||||||
@ -122,7 +226,8 @@ export class Graph extends PureComponent<GraphProps> {
|
|||||||
bars: {
|
bars: {
|
||||||
show: showBars,
|
show: showBars,
|
||||||
fill: 1,
|
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,
|
zero: false,
|
||||||
lineWidth: lineWidth,
|
lineWidth: lineWidth,
|
||||||
},
|
},
|
||||||
@ -144,16 +249,20 @@ export class Graph extends PureComponent<GraphProps> {
|
|||||||
markings: [],
|
markings: [],
|
||||||
backgroundColor: null,
|
backgroundColor: null,
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
// hoverable: true,
|
hoverable: true,
|
||||||
clickable: true,
|
clickable: true,
|
||||||
color: '#a1a1a1',
|
color: '#a1a1a1',
|
||||||
margin: { left: 0, right: 0 },
|
margin: { left: 0, right: 0 },
|
||||||
labelMarginX: 0,
|
labelMarginX: 0,
|
||||||
|
mouseActiveRadius: 30,
|
||||||
},
|
},
|
||||||
selection: {
|
selection: {
|
||||||
mode: onHorizontalRegionSelected ? 'x' : null,
|
mode: onHorizontalRegionSelected ? 'x' : null,
|
||||||
color: '#666',
|
color: '#666',
|
||||||
},
|
},
|
||||||
|
crosshair: {
|
||||||
|
mode: 'x',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -165,12 +274,21 @@ export class Graph extends PureComponent<GraphProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { height, series } = this.props;
|
const { height, width, series } = this.props;
|
||||||
const noDataToBeDisplayed = series.length === 0;
|
const noDataToBeDisplayed = series.length === 0;
|
||||||
return (
|
return (
|
||||||
<div className="graph-panel">
|
<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>}
|
{noDataToBeDisplayed && <div className="datapoints-warning">No data</div>}
|
||||||
|
{this.renderTooltip()}
|
||||||
</div>
|
</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 { select, text } from '@storybook/addon-knobs';
|
||||||
import { withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
|
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 { LegendPlacement, LegendDisplayMode } from '../Legend/Legend';
|
||||||
|
import { GraphSeriesXY, FieldType, ArrayVector, dateTime } from '@grafana/data';
|
||||||
const GraphWithLegendStories = storiesOf('Visualizations/Graph/GraphWithLegend', module);
|
const GraphWithLegendStories = storiesOf('Visualizations/Graph/GraphWithLegend', module);
|
||||||
GraphWithLegendStories.addDecorator(withHorizontallyCenteredStory);
|
GraphWithLegendStories.addDecorator(withHorizontallyCenteredStory);
|
||||||
|
|
||||||
const getStoriesKnobs = () => {
|
const series: GraphSeriesXY[] = [
|
||||||
const containerWidth = select(
|
{
|
||||||
'Container width',
|
data: [[1546372800000, 10], [1546376400000, 20], [1546380000000, 10]],
|
||||||
{
|
color: 'red',
|
||||||
Small: '200px',
|
isVisible: true,
|
||||||
Medium: '500px',
|
label: 'A-series',
|
||||||
'Full width': '100%',
|
seriesIndex: 0,
|
||||||
|
timeField: {
|
||||||
|
type: FieldType.time,
|
||||||
|
name: 'time',
|
||||||
|
values: new ArrayVector([1546372800000, 1546376400000, 1546380000000]),
|
||||||
|
config: {},
|
||||||
},
|
},
|
||||||
'100%'
|
valueField: {
|
||||||
);
|
type: FieldType.number,
|
||||||
const containerHeight = select(
|
name: 'a-series',
|
||||||
'Container height',
|
values: new ArrayVector([10, 20, 10]),
|
||||||
{
|
config: { color: 'red' },
|
||||||
Small: '200px',
|
|
||||||
Medium: '400px',
|
|
||||||
'Full height': '100%',
|
|
||||||
},
|
},
|
||||||
'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 rightAxisSeries = text('Right y-axis series, i.e. A,C', '');
|
||||||
|
|
||||||
const legendPlacement = select<LegendPlacement>(
|
const legendPlacement = select<LegendPlacement>(
|
||||||
@ -51,8 +80,6 @@ const getStoriesKnobs = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
containerWidth,
|
|
||||||
containerHeight,
|
|
||||||
rightAxisSeries,
|
rightAxisSeries,
|
||||||
legendPlacement,
|
legendPlacement,
|
||||||
renderLegendAsTable,
|
renderLegendAsTable,
|
||||||
@ -60,28 +87,37 @@ const getStoriesKnobs = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
GraphWithLegendStories.add('default', () => {
|
GraphWithLegendStories.add('default', () => {
|
||||||
const { containerWidth, containerHeight, rightAxisSeries, legendPlacement, renderLegendAsTable } = getStoriesKnobs();
|
const { legendPlacement, rightAxisSeries, renderLegendAsTable } = getStoriesKnobs();
|
||||||
|
const props: GraphWithLegendProps = {
|
||||||
const props = mockGraphWithLegendData({
|
series: series.map(s => {
|
||||||
onSeriesColorChange: action('Series color changed'),
|
if (
|
||||||
onSeriesAxisToggle: action('Series y-axis changed'),
|
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,
|
displayMode: renderLegendAsTable ? LegendDisplayMode.Table : LegendDisplayMode.List,
|
||||||
});
|
isLegendVisible: true,
|
||||||
const series = props.series.map(s => {
|
onToggleSort: () => {},
|
||||||
if (
|
timeRange: {
|
||||||
rightAxisSeries
|
from: dateTime(1546372800000),
|
||||||
.split(',')
|
to: dateTime(1546380000000),
|
||||||
.map(s => s.trim())
|
raw: {
|
||||||
.indexOf(s.label.split('-')[0]) > -1
|
from: dateTime(1546372800000),
|
||||||
) {
|
to: dateTime(1546380000000),
|
||||||
s.yAxis = { index: 2 };
|
},
|
||||||
}
|
},
|
||||||
|
timeZone: 'browser',
|
||||||
|
width: 600,
|
||||||
|
height: 300,
|
||||||
|
placement: legendPlacement,
|
||||||
|
};
|
||||||
|
|
||||||
return s;
|
return <GraphWithLegend {...props} />;
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div style={{ width: containerWidth, height: containerHeight }}>
|
|
||||||
<GraphWithLegend {...props} placement={legendPlacement} series={series} />,
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
@ -72,6 +72,7 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
|
|||||||
lineWidth,
|
lineWidth,
|
||||||
onHorizontalRegionSelected,
|
onHorizontalRegionSelected,
|
||||||
timeZone,
|
timeZone,
|
||||||
|
children,
|
||||||
} = props;
|
} = props;
|
||||||
const { graphContainer, wrapper, legendContainer } = getGraphWithLegendStyles(props);
|
const { graphContainer, wrapper, legendContainer } = getGraphWithLegendStyles(props);
|
||||||
|
|
||||||
@ -105,7 +106,9 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
|
|||||||
isStacked={isStacked}
|
isStacked={isStacked}
|
||||||
lineWidth={lineWidth}
|
lineWidth={lineWidth}
|
||||||
onHorizontalRegionSelected={onHorizontalRegionSelected}
|
onHorizontalRegionSelected={onHorizontalRegionSelected}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</Graph>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLegendVisible && (
|
{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;
|
color: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SeriesIcon: React.FunctionComponent<SeriesIconProps> = ({ color, className }) => {
|
export const SeriesIcon: React.FunctionComponent<SeriesIconProps> = ({ color, className }) => {
|
||||||
return <i className={cx('fa', 'fa-minus', className)} style={{ color }} />;
|
return <i className={cx('fa', 'fa-minus', className)} style={{ color }} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
SeriesIcon.displayName = 'SeriesIcon';
|
||||||
|
@ -22,12 +22,13 @@ import {
|
|||||||
const labelWidth = 6;
|
const labelWidth = 6;
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
showMinMax: boolean;
|
showMinMax?: boolean;
|
||||||
|
showTitle?: boolean;
|
||||||
value: FieldConfig;
|
value: FieldConfig;
|
||||||
onChange: (value: FieldConfig, event?: React.SyntheticEvent<HTMLElement>) => void;
|
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 { unit, title } = value;
|
||||||
|
|
||||||
const [decimals, setDecimals] = useState(
|
const [decimals, setDecimals] = useState(
|
||||||
@ -88,14 +89,16 @@ export const FieldPropertiesEditor: React.FC<Props> = ({ value, onChange, showMi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField
|
{showTitle && (
|
||||||
label="Title"
|
<FormField
|
||||||
labelWidth={labelWidth}
|
label="Title"
|
||||||
onChange={onTitleChange}
|
labelWidth={labelWidth}
|
||||||
value={title}
|
onChange={onTitleChange}
|
||||||
tooltip={titleTooltip}
|
value={title}
|
||||||
placeholder="Auto"
|
tooltip={titleTooltip}
|
||||||
/>
|
placeholder="Auto"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="gf-form">
|
<div className="gf-form">
|
||||||
<FormLabel width={labelWidth}>Unit</FormLabel>
|
<FormLabel width={labelWidth}>Unit</FormLabel>
|
||||||
|
@ -52,6 +52,7 @@ export { Gauge } from './Gauge/Gauge';
|
|||||||
export { Graph } from './Graph/Graph';
|
export { Graph } from './Graph/Graph';
|
||||||
export { GraphLegend } from './Graph/GraphLegend';
|
export { GraphLegend } from './Graph/GraphLegend';
|
||||||
export { GraphWithLegend } from './Graph/GraphWithLegend';
|
export { GraphWithLegend } from './Graph/GraphWithLegend';
|
||||||
|
export { GraphTooltipOptions } from './Graph/GraphTooltip/types';
|
||||||
export { BarGauge } from './BarGauge/BarGauge';
|
export { BarGauge } from './BarGauge/BarGauge';
|
||||||
export { VizRepeater } from './VizRepeater/VizRepeater';
|
export { VizRepeater } from './VizRepeater/VizRepeater';
|
||||||
export {
|
export {
|
||||||
@ -95,6 +96,7 @@ export { Spinner } from './Spinner/Spinner';
|
|||||||
export { FadeTransition } from './transitions/FadeTransition';
|
export { FadeTransition } from './transitions/FadeTransition';
|
||||||
export { SlideOutTransition } from './transitions/SlideOutTransition';
|
export { SlideOutTransition } from './transitions/SlideOutTransition';
|
||||||
export { Segment, SegmentAsync, SegmentSelect } from './Segment/';
|
export { Segment, SegmentAsync, SegmentSelect } from './Segment/';
|
||||||
|
export { default as Chart } from './Chart';
|
||||||
|
|
||||||
// Next-gen forms
|
// Next-gen forms
|
||||||
export { default as Forms } from './Forms';
|
export { default as Forms } from './Forms';
|
||||||
|
@ -144,7 +144,7 @@ const emptyLogsModel: any = {
|
|||||||
|
|
||||||
describe('dataFrameToLogsModel', () => {
|
describe('dataFrameToLogsModel', () => {
|
||||||
it('given empty series should return empty logs model', () => {
|
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', () => {
|
it('given series without correct series name should return empty logs model', () => {
|
||||||
@ -153,7 +153,7 @@ describe('dataFrameToLogsModel', () => {
|
|||||||
fields: [],
|
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', () => {
|
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', () => {
|
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', () => {
|
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.hasUniqueLabels).toBeFalsy();
|
||||||
expect(logsModel.rows).toHaveLength(2);
|
expect(logsModel.rows).toHaveLength(2);
|
||||||
expect(logsModel.rows).toMatchObject([
|
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).toHaveLength(1);
|
||||||
expect(logsModel.rows).toMatchObject([
|
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.hasUniqueLabels).toBeTruthy();
|
||||||
expect(logsModel.rows).toHaveLength(3);
|
expect(logsModel.rows).toHaveLength(3);
|
||||||
expect(logsModel.rows).toMatchObject([
|
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.hasUniqueLabels).toBeTruthy();
|
||||||
expect(logsModel.rows).toHaveLength(4);
|
expect(logsModel.rows).toHaveLength(4);
|
||||||
expect(logsModel.rows).toMatchObject([
|
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');
|
expect(logsModel.rows[0].uid).toBe('0');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -23,6 +23,8 @@ import {
|
|||||||
FieldCache,
|
FieldCache,
|
||||||
FieldWithIndex,
|
FieldWithIndex,
|
||||||
getFlotPairs,
|
getFlotPairs,
|
||||||
|
TimeZone,
|
||||||
|
getDisplayProcessor,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { getThemeColor } from 'app/core/utils/colors';
|
import { getThemeColor } from 'app/core/utils/colors';
|
||||||
import { hasAnsiCodes } from 'app/core/utils/text';
|
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.
|
// 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
|
// 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.
|
// 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,
|
lastTs: null,
|
||||||
datapoints: [],
|
datapoints: [],
|
||||||
alias: row.logLevel,
|
alias: row.logLevel,
|
||||||
|
target: row.logLevel,
|
||||||
color: LogLevelColor[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[]) => {
|
series.datapoints.sort((a: number[], b: number[]) => {
|
||||||
return a[1] - b[1];
|
return a[1] - b[1];
|
||||||
});
|
});
|
||||||
@ -145,6 +148,14 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Grap
|
|||||||
nullValueMode: NullValueMode.Null,
|
nullValueMode: NullValueMode.Null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const timeField = data.fields[1];
|
||||||
|
|
||||||
|
timeField.display = getDisplayProcessor({
|
||||||
|
config: timeField.config,
|
||||||
|
type: timeField.type,
|
||||||
|
isUtc: timeZone === 'utc',
|
||||||
|
});
|
||||||
|
|
||||||
const graphSeries: GraphSeriesXY = {
|
const graphSeries: GraphSeriesXY = {
|
||||||
color: series.color,
|
color: series.color,
|
||||||
label: series.alias,
|
label: series.alias,
|
||||||
@ -155,6 +166,12 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Grap
|
|||||||
min: 0,
|
min: 0,
|
||||||
tickDecimals: 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;
|
return graphSeries;
|
||||||
@ -171,18 +188,19 @@ function isLogsData(series: DataFrame) {
|
|||||||
* @param dataFrame
|
* @param dataFrame
|
||||||
* @param intervalMs In case there are no metrics series, we use this for computing it from log rows.
|
* @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 { logSeries, metricSeries } = separateLogsAndMetrics(dataFrame);
|
||||||
const logsModel = logSeriesToLogsModel(logSeries);
|
const logsModel = logSeriesToLogsModel(logSeries);
|
||||||
|
|
||||||
if (logsModel) {
|
if (logsModel) {
|
||||||
if (metricSeries.length === 0) {
|
if (metricSeries.length === 0) {
|
||||||
// Create metrics from logs
|
// Create metrics from logs
|
||||||
logsModel.series = makeSeriesForLogs(logsModel.rows, intervalMs);
|
logsModel.series = makeSeriesForLogs(logsModel.rows, intervalMs, timeZone);
|
||||||
} else {
|
} else {
|
||||||
// We got metrics in the dataFrame so process those
|
// We got metrics in the dataFrame so process those
|
||||||
logsModel.series = getGraphSeriesModel(
|
logsModel.series = getGraphSeriesModel(
|
||||||
metricSeries,
|
metricSeries,
|
||||||
|
timeZone,
|
||||||
{},
|
{},
|
||||||
{ showBars: true, showLines: false, showPoints: false },
|
{ showBars: true, showLines: false, showPoints: false },
|
||||||
{
|
{
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
Collapse,
|
Collapse,
|
||||||
GraphSeriesToggler,
|
GraphSeriesToggler,
|
||||||
GraphSeriesTogglerAPI,
|
GraphSeriesTogglerAPI,
|
||||||
|
Chart,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
|
|
||||||
const MAX_NUMBER_OF_TIME_SERIES = 20;
|
const MAX_NUMBER_OF_TIME_SERIES = 20;
|
||||||
@ -136,7 +137,10 @@ class UnThemedExploreGraphPanel extends PureComponent<Props, State> {
|
|||||||
lineWidth={lineWidth}
|
lineWidth={lineWidth}
|
||||||
onSeriesToggle={onSeriesToggle}
|
onSeriesToggle={onSeriesToggle}
|
||||||
onHorizontalRegionSelected={this.onChangeTime}
|
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>
|
</GraphSeriesToggler>
|
||||||
|
@ -19,6 +19,7 @@ import {
|
|||||||
PanelData,
|
PanelData,
|
||||||
DataQueryRequest,
|
DataQueryRequest,
|
||||||
PanelEvents,
|
PanelEvents,
|
||||||
|
TimeZone,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { RefreshPicker } from '@grafana/ui';
|
import { RefreshPicker } from '@grafana/ui';
|
||||||
import {
|
import {
|
||||||
@ -589,7 +590,7 @@ export const processQueryResponse = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latency = request.endTime ? request.endTime - request.startTime : 0;
|
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 graphResult = processor.getGraphResult();
|
||||||
const tableResult = processor.getTableResult();
|
const tableResult = processor.getTableResult();
|
||||||
const logsResult = processor.getLogsResult();
|
const logsResult = processor.getLogsResult();
|
||||||
|
@ -58,7 +58,7 @@ const testContext = (options: any = {}) => {
|
|||||||
queryIntervals: { intervalMs: 10 },
|
queryIntervals: { intervalMs: 10 },
|
||||||
} as any) as ExploreItemState;
|
} as any) as ExploreItemState;
|
||||||
|
|
||||||
const resultProcessor = new ResultProcessor(state, combinedOptions.dataFrames, 60000);
|
const resultProcessor = new ResultProcessor(state, combinedOptions.dataFrames, 60000, 'utc');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dataFrames: combinedOptions.dataFrames,
|
dataFrames: combinedOptions.dataFrames,
|
||||||
@ -99,7 +99,9 @@ describe('ResultProcessor', () => {
|
|||||||
describe('constructed with a result that is a DataQueryResponse', () => {
|
describe('constructed with a result that is a DataQueryResponse', () => {
|
||||||
describe('when calling getGraphResult', () => {
|
describe('when calling getGraphResult', () => {
|
||||||
it('then it should return correct graph result', () => {
|
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();
|
const theResult = resultProcessor.getGraphResult();
|
||||||
|
|
||||||
expect(theResult).toEqual([
|
expect(theResult).toEqual([
|
||||||
@ -112,6 +114,10 @@ describe('ResultProcessor', () => {
|
|||||||
yAxis: {
|
yAxis: {
|
||||||
index: 1,
|
index: 1,
|
||||||
},
|
},
|
||||||
|
seriesIndex: 0,
|
||||||
|
timeField,
|
||||||
|
valueField,
|
||||||
|
timeStep: 100,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@ -138,6 +144,8 @@ describe('ResultProcessor', () => {
|
|||||||
describe('when calling getLogsResult', () => {
|
describe('when calling getLogsResult', () => {
|
||||||
it('then it should return correct logs result', () => {
|
it('then it should return correct logs result', () => {
|
||||||
const { resultProcessor, dataFrames } = testContext({ mode: ExploreMode.Logs });
|
const { resultProcessor, dataFrames } = testContext({ mode: ExploreMode.Logs });
|
||||||
|
const timeField = dataFrames[0].fields[1];
|
||||||
|
const valueField = dataFrames[0].fields[0];
|
||||||
const logsDataFrame = dataFrames[1];
|
const logsDataFrame = dataFrames[1];
|
||||||
const theResult = resultProcessor.getLogsResult();
|
const theResult = resultProcessor.getLogsResult();
|
||||||
|
|
||||||
@ -210,6 +218,10 @@ describe('ResultProcessor', () => {
|
|||||||
yAxis: {
|
yAxis: {
|
||||||
index: 1,
|
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 { ExploreItemState, ExploreMode } from 'app/types/explore';
|
||||||
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
|
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';
|
import { getGraphSeriesModel } from 'app/plugins/panel/graph2/getGraphSeriesModel';
|
||||||
|
|
||||||
export class ResultProcessor {
|
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[] {
|
getGraphResult(): GraphSeriesXY[] {
|
||||||
if (this.state.mode !== ExploreMode.Metrics) {
|
if (this.state.mode !== ExploreMode.Metrics) {
|
||||||
@ -22,6 +27,7 @@ export class ResultProcessor {
|
|||||||
|
|
||||||
return getGraphSeriesModel(
|
return getGraphSeriesModel(
|
||||||
onlyTimeSeries,
|
onlyTimeSeries,
|
||||||
|
this.timeZone,
|
||||||
{},
|
{},
|
||||||
{ showBars: false, showLines: true, showPoints: false },
|
{ showBars: false, showLines: true, showPoints: false },
|
||||||
{ asTable: false, isVisible: true, placement: 'under' }
|
{ asTable: false, isVisible: true, placement: 'under' }
|
||||||
@ -77,7 +83,7 @@ export class ResultProcessor {
|
|||||||
return null;
|
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 sortOrder = refreshIntervalToSortOrder(this.state.refreshInterval);
|
||||||
const sortedNewResults = sortLogsResult(newResults, sortOrder);
|
const sortedNewResults = sortLogsResult(newResults, sortOrder);
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { GraphWithLegend } from '@grafana/ui';
|
import { GraphWithLegend, Chart } from '@grafana/ui';
|
||||||
import { PanelProps } from '@grafana/data';
|
import { PanelProps } from '@grafana/data';
|
||||||
import { Options } from './types';
|
import { Options } from './types';
|
||||||
import { GraphPanelController } from './GraphPanelController';
|
import { GraphPanelController } from './GraphPanelController';
|
||||||
@ -28,17 +28,20 @@ export const GraphPanel: React.FunctionComponent<GraphPanelProps> = ({
|
|||||||
const {
|
const {
|
||||||
graph: { showLines, showBars, showPoints },
|
graph: { showLines, showBars, showPoints },
|
||||||
legend: legendOptions,
|
legend: legendOptions,
|
||||||
|
tooltipOptions,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const graphProps = {
|
const graphProps = {
|
||||||
showBars,
|
showBars,
|
||||||
showLines,
|
showLines,
|
||||||
showPoints,
|
showPoints,
|
||||||
|
tooltipOptions,
|
||||||
};
|
};
|
||||||
const { asTable, isVisible, ...legendProps } = legendOptions;
|
const { asTable, isVisible, ...legendProps } = legendOptions;
|
||||||
return (
|
return (
|
||||||
<GraphPanelController
|
<GraphPanelController
|
||||||
data={data}
|
data={data}
|
||||||
|
timeZone={timeZone}
|
||||||
options={options}
|
options={options}
|
||||||
onOptionsChange={onOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
onChangeTimeRange={onChangeTimeRange}
|
onChangeTimeRange={onChangeTimeRange}
|
||||||
@ -59,7 +62,9 @@ export const GraphPanel: React.FunctionComponent<GraphPanelProps> = ({
|
|||||||
{...graphProps}
|
{...graphProps}
|
||||||
{...legendProps}
|
{...legendProps}
|
||||||
{...controllerApi}
|
{...controllerApi}
|
||||||
/>
|
>
|
||||||
|
<Chart.Tooltip mode={tooltipOptions.mode} />
|
||||||
|
</GraphWithLegend>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</GraphPanelController>
|
</GraphPanelController>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { GraphSeriesToggler } from '@grafana/ui';
|
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 { getGraphSeriesModel } from './getGraphSeriesModel';
|
||||||
import { Options, SeriesOptions } from './types';
|
import { Options, SeriesOptions } from './types';
|
||||||
@ -19,6 +19,7 @@ interface GraphPanelControllerProps {
|
|||||||
children: (api: GraphPanelControllerAPI) => JSX.Element;
|
children: (api: GraphPanelControllerAPI) => JSX.Element;
|
||||||
options: Options;
|
options: Options;
|
||||||
data: PanelData;
|
data: PanelData;
|
||||||
|
timeZone: TimeZone;
|
||||||
onOptionsChange: (options: Options) => void;
|
onOptionsChange: (options: Options) => void;
|
||||||
onChangeTimeRange: (timeRange: AbsoluteTimeRange) => void;
|
onChangeTimeRange: (timeRange: AbsoluteTimeRange) => void;
|
||||||
}
|
}
|
||||||
@ -39,9 +40,11 @@ export class GraphPanelController extends React.Component<GraphPanelControllerPr
|
|||||||
this.state = {
|
this.state = {
|
||||||
graphSeriesModel: getGraphSeriesModel(
|
graphSeriesModel: getGraphSeriesModel(
|
||||||
props.data.series,
|
props.data.series,
|
||||||
|
props.timeZone,
|
||||||
props.options.series,
|
props.options.series,
|
||||||
props.options.graph,
|
props.options.graph,
|
||||||
props.options.legend
|
props.options.legend,
|
||||||
|
props.options.fieldOptions
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -51,9 +54,11 @@ export class GraphPanelController extends React.Component<GraphPanelControllerPr
|
|||||||
...state,
|
...state,
|
||||||
graphSeriesModel: getGraphSeriesModel(
|
graphSeriesModel: getGraphSeriesModel(
|
||||||
props.data.series,
|
props.data.series,
|
||||||
|
props.timeZone,
|
||||||
props.options.series,
|
props.options.series,
|
||||||
props.options.graph,
|
props.options.graph,
|
||||||
props.options.legend
|
props.options.legend,
|
||||||
|
props.options.fieldOptions
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,16 @@ import _ from 'lodash';
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import { PanelEditorProps } from '@grafana/data';
|
import { PanelEditorProps, FieldConfig } from '@grafana/data';
|
||||||
import { Switch, LegendOptions } from '@grafana/ui';
|
import {
|
||||||
|
Switch,
|
||||||
|
LegendOptions,
|
||||||
|
GraphTooltipOptions,
|
||||||
|
PanelOptionsGrid,
|
||||||
|
PanelOptionsGroup,
|
||||||
|
FieldPropertiesEditor,
|
||||||
|
Select,
|
||||||
|
} from '@grafana/ui';
|
||||||
import { Options, GraphOptions } from './types';
|
import { Options, GraphOptions } from './types';
|
||||||
import { GraphLegendEditor } from './GraphLegendEditor';
|
import { GraphLegendEditor } from './GraphLegendEditor';
|
||||||
|
|
||||||
@ -23,6 +31,10 @@ export class GraphPanelEditor extends PureComponent<PanelEditorProps<Options>> {
|
|||||||
this.props.onOptionsChange({ ...this.props.options, legend: options });
|
this.props.onOptionsChange({ ...this.props.options, legend: options });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onTooltipOptionsChange = (options: GraphTooltipOptions) => {
|
||||||
|
this.props.onOptionsChange({ ...this.props.options, tooltipOptions: options });
|
||||||
|
};
|
||||||
|
|
||||||
onToggleLines = () => {
|
onToggleLines = () => {
|
||||||
this.onGraphOptionsChange({ showLines: !this.props.options.graph.showLines });
|
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 });
|
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() {
|
render() {
|
||||||
const {
|
const {
|
||||||
graph: { showBars, showPoints, showLines },
|
graph: { showBars, showPoints, showLines },
|
||||||
|
tooltipOptions: { mode },
|
||||||
} = this.props.options;
|
} = this.props.options;
|
||||||
|
|
||||||
return (
|
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="Bars" labelClass="width-5" checked={showBars} onChange={this.onToggleBars} />
|
||||||
<Switch label="Points" labelClass="width-5" checked={showPoints} onChange={this.onTogglePoints} />
|
<Switch label="Points" labelClass="width-5" checked={showPoints} onChange={this.onTogglePoints} />
|
||||||
</div>
|
</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,
|
GraphSeriesXY,
|
||||||
getTimeField,
|
getTimeField,
|
||||||
DataFrame,
|
DataFrame,
|
||||||
|
FieldDisplayOptions,
|
||||||
|
getSeriesTimeStep,
|
||||||
|
TimeZone,
|
||||||
|
hasMsResolution,
|
||||||
|
MS_DATE_TIME_FORMAT,
|
||||||
|
DEFAULT_DATE_TIME_FORMAT,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
|
||||||
import { SeriesOptions, GraphOptions } from './types';
|
import { SeriesOptions, GraphOptions } from './types';
|
||||||
@ -17,9 +23,11 @@ import { GraphLegendEditorLegendOptions } from './GraphLegendEditor';
|
|||||||
|
|
||||||
export const getGraphSeriesModel = (
|
export const getGraphSeriesModel = (
|
||||||
dataFrames: DataFrame[],
|
dataFrames: DataFrame[],
|
||||||
|
timeZone: TimeZone,
|
||||||
seriesOptions: SeriesOptions,
|
seriesOptions: SeriesOptions,
|
||||||
graphOptions: GraphOptions,
|
graphOptions: GraphOptions,
|
||||||
legendOptions: GraphLegendEditorLegendOptions
|
legendOptions: GraphLegendEditorLegendOptions,
|
||||||
|
fieldOptions?: FieldDisplayOptions
|
||||||
) => {
|
) => {
|
||||||
const graphs: GraphSeriesXY[] = [];
|
const graphs: GraphSeriesXY[] = [];
|
||||||
|
|
||||||
@ -29,6 +37,7 @@ export const getGraphSeriesModel = (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let fieldColumnIndex = -1;
|
||||||
for (const series of dataFrames) {
|
for (const series of dataFrames) {
|
||||||
const { timeField } = getTimeField(series);
|
const { timeField } = getTimeField(series);
|
||||||
if (!timeField) {
|
if (!timeField) {
|
||||||
@ -39,6 +48,8 @@ export const getGraphSeriesModel = (
|
|||||||
if (field.type !== FieldType.number) {
|
if (field.type !== FieldType.number) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Storing index of series field for future inspection
|
||||||
|
fieldColumnIndex++;
|
||||||
|
|
||||||
// Use external calculator just to make sure it works :)
|
// Use external calculator just to make sure it works :)
|
||||||
const points = getFlotPairs({
|
const points = getFlotPairs({
|
||||||
@ -68,6 +79,29 @@ export const getGraphSeriesModel = (
|
|||||||
? getColorFromHexRgbOrName(seriesOptions[field.name].color)
|
? getColorFromHexRgbOrName(seriesOptions[field.name].color)
|
||||||
: colors[graphs.length % colors.length];
|
: 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({
|
graphs.push({
|
||||||
label: field.name,
|
label: field.name,
|
||||||
data: points,
|
data: points,
|
||||||
@ -77,6 +111,11 @@ export const getGraphSeriesModel = (
|
|||||||
yAxis: {
|
yAxis: {
|
||||||
index: (seriesOptions[field.name] && seriesOptions[field.name].yAxis) || 1,
|
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 { LegendOptions, GraphTooltipOptions } from '@grafana/ui';
|
||||||
import { YAxis } from '@grafana/data';
|
import { YAxis, FieldDisplayOptions } from '@grafana/data';
|
||||||
|
|
||||||
import { GraphLegendEditorLegendOptions } from './GraphLegendEditor';
|
import { GraphLegendEditorLegendOptions } from './GraphLegendEditor';
|
||||||
|
// TODO move out from single stat
|
||||||
|
import { standardFieldDisplayOptions } from '../singlestat2/types';
|
||||||
|
|
||||||
export interface SeriesOptions {
|
export interface SeriesOptions {
|
||||||
color?: string;
|
color?: string;
|
||||||
@ -20,6 +22,8 @@ export interface Options {
|
|||||||
series: {
|
series: {
|
||||||
[alias: string]: SeriesOptions;
|
[alias: string]: SeriesOptions;
|
||||||
};
|
};
|
||||||
|
fieldOptions: FieldDisplayOptions;
|
||||||
|
tooltipOptions: GraphTooltipOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaults: Options = {
|
export const defaults: Options = {
|
||||||
@ -34,4 +38,6 @@ export const defaults: Options = {
|
|||||||
placement: 'under',
|
placement: 'under',
|
||||||
},
|
},
|
||||||
series: {},
|
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);
|
const sortedNewResults = sortLogsResult(newResults, sortOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -2,6 +2,12 @@ import { configure } from 'enzyme';
|
|||||||
import Adapter from 'enzyme-adapter-react-16';
|
import Adapter from 'enzyme-adapter-react-16';
|
||||||
import 'jquery';
|
import 'jquery';
|
||||||
import $ from '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';
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
@ -18,9 +24,6 @@ jest.mock('app/features/plugins/plugin_loader', () => ({}));
|
|||||||
|
|
||||||
configure({ adapter: new Adapter() });
|
configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
const global = window as any;
|
|
||||||
global.$ = global.jQuery = $;
|
|
||||||
|
|
||||||
const localStorageMock = (() => {
|
const localStorageMock = (() => {
|
||||||
let store: any = {};
|
let store: any = {};
|
||||||
return {
|
return {
|
||||||
|
61
yarn.lock
61
yarn.lock
@ -3664,6 +3664,13 @@
|
|||||||
"@types/prop-types" "*"
|
"@types/prop-types" "*"
|
||||||
"@types/react" "*"
|
"@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":
|
"@types/react-window@1.7.0":
|
||||||
version "1.7.0"
|
version "1.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.7.0.tgz#8dd99822c54380c9c05df213b7b4400c24c9877e"
|
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:
|
dependencies:
|
||||||
color-name "1.1.3"
|
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:
|
color-name@1.1.3:
|
||||||
version "1.1.3"
|
version "1.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
|
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"
|
resolved "https://registry.yarnpkg.com/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae"
|
||||||
integrity sha1-xtJnJjKi5cg+AT5oZKQs6N79IK4=
|
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:
|
cssnano-preset-default@^4.0.7:
|
||||||
version "4.0.7"
|
version "4.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76"
|
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:
|
dependencies:
|
||||||
handlebars "^4.1.2"
|
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:
|
jest-changed-files@^24.9.0:
|
||||||
version "24.9.0"
|
version "24.9.0"
|
||||||
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.9.0.tgz#08d8c15eb79a7fa3fc98269bc14b451ee82f8039"
|
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"
|
pbkdf2 "^3.0.3"
|
||||||
safe-buffer "^5.1.1"
|
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:
|
parse-entities@^1.1.0, parse-entities@^1.1.2:
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50"
|
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"
|
prop-types "^15.6.2"
|
||||||
react-lifecycles-compat "^3.0.4"
|
react-lifecycles-compat "^3.0.4"
|
||||||
|
|
||||||
react-use@9.0.0:
|
react-use@12.8.0:
|
||||||
version "9.0.0"
|
version "12.8.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-use/-/react-use-9.0.0.tgz#142bec53fa465db2a6e43c68a8c9ef2acc000592"
|
resolved "https://registry.yarnpkg.com/react-use/-/react-use-12.8.0.tgz#72f03d9f3c82d8e86b0d0c5d0c5d7e7b1b4bb822"
|
||||||
integrity sha512-jlXJneB96yl4VvAXDKyE6cmdIeWk0cO7Gomh870Qu0vXZ9YM2JjjR09E9vIPPPI2M27RWo2dZKXspv44Wxtoog==
|
integrity sha512-uRnLUO1wLtjaVEqrtBndZe5x1SGF5NWfK5qTXbMmvowmTdVsZ757BQ/U1oJJMzY2h3iBC2khCK29XsXMb7hYYw==
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@types/react-wait" "^0.3.0"
|
||||||
copy-to-clipboard "^3.1.0"
|
copy-to-clipboard "^3.1.0"
|
||||||
nano-css "^5.1.0"
|
nano-css "^5.1.0"
|
||||||
react-fast-compare "^2.0.4"
|
react-fast-compare "^2.0.4"
|
||||||
react-wait "^0.3.0"
|
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"
|
throttle-debounce "^2.0.1"
|
||||||
ts-easing "^0.2.0"
|
ts-easing "^0.2.0"
|
||||||
|
tslib "^1.10.0"
|
||||||
|
|
||||||
react-virtualized@9.21.0:
|
react-virtualized@9.21.0:
|
||||||
version "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 "^6.10.2"
|
||||||
ajv-keywords "^3.4.1"
|
ajv-keywords "^3.4.1"
|
||||||
|
|
||||||
screenfull@^4.1.0:
|
screenfull@^5.0.0:
|
||||||
version "4.2.1"
|
version "5.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-4.2.1.tgz#3245b7bc73d2b7c9a15bd8caaf6965db7cbc7f04"
|
resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.0.0.tgz#5c2010c0e84fd4157bf852877698f90b8cbe96f6"
|
||||||
integrity sha512-PLSp6f5XdhvjCCCO8OjavRfzkSGL3Qmdm7P82bxyU8HDDDBhDV3UckRaYcRa/NDNTYt8YBpzjoLWHUAejmOjLg==
|
integrity sha512-yShzhaIoE9OtOhWVyBBffA6V98CDCoyHTsp8228blmqYy1Z5bddzE/4FPiJKlr8DVR4VBiiUyfPzIQPIYDkeMA==
|
||||||
|
|
||||||
scss-tokenizer@^0.2.3:
|
scss-tokenizer@^0.2.3:
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
|
||||||
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
|
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:
|
set-value@^2.0.0, set-value@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
|
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"
|
resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.1.4.tgz#ae27126960ebaefb874c6d7fa4729729ab200d90"
|
||||||
integrity sha512-1J/vefLC+BWSo+qe8OnJQfWTYRS6ingxjwqmHMqaMxXMj7kFtKLgAaYW3JeX3mktjgUL+etlU8/B4VUAUI9QGw==
|
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"
|
version "1.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
|
||||||
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
|
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
|
||||||
|
Reference in New Issue
Block a user