mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Graph NG: fix toggling queries and extract Graph component from graph3 panel (#28290)
* Fix issue when data and config is not in sync * Extract GraphNG component from graph panel and add some tests coverage * Update packages/grafana-ui/src/components/uPlot/hooks.test.ts * Update packages/grafana-ui/src/components/uPlot/hooks.test.ts * Update packages/grafana-ui/src/components/uPlot/hooks.test.ts * Fix grid color and annotations refresh
This commit is contained in:
parent
448114f649
commit
f989e37132
209
packages/grafana-ui/src/components/GraphNG/GraphNG.test.tsx
Normal file
209
packages/grafana-ui/src/components/GraphNG/GraphNG.test.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
import React from 'react';
|
||||
import { GraphNG } from './GraphNG';
|
||||
import { render } from '@testing-library/react';
|
||||
import {
|
||||
ArrayVector,
|
||||
DataTransformerID,
|
||||
dateTime,
|
||||
FieldConfig,
|
||||
FieldType,
|
||||
MutableDataFrame,
|
||||
standardTransformers,
|
||||
standardTransformersRegistry,
|
||||
} from '@grafana/data';
|
||||
import { Canvas, GraphCustomFieldConfig } from '..';
|
||||
|
||||
const mockData = () => {
|
||||
const data = new MutableDataFrame();
|
||||
|
||||
data.addField({
|
||||
type: FieldType.time,
|
||||
name: 'Time',
|
||||
values: new ArrayVector([1602630000000, 1602633600000, 1602637200000]),
|
||||
config: {},
|
||||
});
|
||||
|
||||
data.addField({
|
||||
type: FieldType.number,
|
||||
name: 'Value',
|
||||
values: new ArrayVector([10, 20, 5]),
|
||||
config: {
|
||||
custom: {
|
||||
line: { show: true },
|
||||
},
|
||||
} as FieldConfig<GraphCustomFieldConfig>,
|
||||
});
|
||||
|
||||
const timeRange = {
|
||||
from: dateTime(1602673200000),
|
||||
to: dateTime(1602680400000),
|
||||
raw: { from: '1602673200000', to: '1602680400000' },
|
||||
};
|
||||
return { data, timeRange };
|
||||
};
|
||||
|
||||
describe('GraphNG', () => {
|
||||
beforeAll(() => {
|
||||
standardTransformersRegistry.setInit(() => [
|
||||
{
|
||||
id: DataTransformerID.seriesToColumns,
|
||||
editor: () => null,
|
||||
transformation: standardTransformers.seriesToColumnsTransformer,
|
||||
name: 'outer join',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw when rendered without Canvas as child', () => {
|
||||
const { data, timeRange } = mockData();
|
||||
expect(() => {
|
||||
render(<GraphNG data={[data]} timeRange={timeRange} timeZone={'browser'} width={100} height={100} />);
|
||||
}).toThrow('Missing Canvas component as a child of the plot.');
|
||||
});
|
||||
|
||||
describe('data update', () => {
|
||||
it('does not re-initialise uPlot when there are no field config changes', () => {
|
||||
const { data, timeRange } = mockData();
|
||||
const onDataUpdateSpy = jest.fn();
|
||||
const onPlotInitSpy = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<GraphNG
|
||||
data={[data]}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
onDataUpdate={onDataUpdateSpy}
|
||||
onPlotInit={onPlotInitSpy}
|
||||
>
|
||||
<Canvas />
|
||||
</GraphNG>
|
||||
);
|
||||
|
||||
data.fields[1].values.set(0, 1);
|
||||
|
||||
rerender(
|
||||
<GraphNG
|
||||
data={[data]}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
onDataUpdate={onDataUpdateSpy}
|
||||
onPlotInit={onPlotInitSpy}
|
||||
>
|
||||
<Canvas />
|
||||
</GraphNG>
|
||||
);
|
||||
|
||||
expect(onPlotInitSpy).toBeCalledTimes(1);
|
||||
expect(onDataUpdateSpy).toHaveBeenLastCalledWith([
|
||||
[1602630000, 1602633600, 1602637200],
|
||||
[1, 20, 5],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config update', () => {
|
||||
it('should skip plot intialization for width and height equal 0', () => {
|
||||
const { data, timeRange } = mockData();
|
||||
const onPlotInitSpy = jest.fn();
|
||||
|
||||
render(
|
||||
<GraphNG
|
||||
data={[data]}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={0}
|
||||
height={0}
|
||||
onPlotInit={onPlotInitSpy}
|
||||
>
|
||||
<Canvas />
|
||||
</GraphNG>
|
||||
);
|
||||
|
||||
expect(onPlotInitSpy).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('reinitializes plot when number of series change', () => {
|
||||
const { data, timeRange } = mockData();
|
||||
const onPlotInitSpy = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<GraphNG
|
||||
data={[data]}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
onPlotInit={onPlotInitSpy}
|
||||
>
|
||||
<Canvas />
|
||||
</GraphNG>
|
||||
);
|
||||
|
||||
data.addField({
|
||||
name: 'Value1',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, 2, 3]),
|
||||
config: {
|
||||
custom: {
|
||||
line: { show: true },
|
||||
},
|
||||
} as FieldConfig<GraphCustomFieldConfig>,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<GraphNG
|
||||
data={[data]}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
onPlotInit={onPlotInitSpy}
|
||||
>
|
||||
<Canvas />
|
||||
</GraphNG>
|
||||
);
|
||||
|
||||
expect(onPlotInitSpy).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reinitializes plot when series field config changes', () => {
|
||||
const { data, timeRange } = mockData();
|
||||
const onPlotInitSpy = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<GraphNG
|
||||
data={[data]}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
onPlotInit={onPlotInitSpy}
|
||||
>
|
||||
<Canvas />
|
||||
</GraphNG>
|
||||
);
|
||||
expect(onPlotInitSpy).toBeCalledTimes(1);
|
||||
|
||||
data.fields[1].config.custom.line.width = 5;
|
||||
|
||||
rerender(
|
||||
<GraphNG
|
||||
data={[data]}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
onPlotInit={onPlotInitSpy}
|
||||
>
|
||||
<Canvas />
|
||||
</GraphNG>
|
||||
);
|
||||
|
||||
expect(onPlotInitSpy).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
189
packages/grafana-ui/src/components/GraphNG/GraphNG.tsx
Normal file
189
packages/grafana-ui/src/components/GraphNG/GraphNG.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
DataFrame,
|
||||
FieldConfig,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getFieldColorModeForField,
|
||||
getTimeField,
|
||||
systemDateFormats,
|
||||
} from '@grafana/data';
|
||||
import { timeFormatToTemplate } from '../uPlot/utils';
|
||||
import { alignAndSortDataFramesByFieldName } from './utils';
|
||||
import { Area, Axis, Line, Point, Scale, SeriesGeometry } from '../uPlot/geometries';
|
||||
import { UPlotChart } from '../uPlot/Plot';
|
||||
import { GraphCustomFieldConfig, PlotProps } from '../uPlot/types';
|
||||
import { useTheme } from '../../themes';
|
||||
|
||||
const timeStampsConfig = [
|
||||
[3600 * 24 * 365, '{YYYY}', 7, '{YYYY}'],
|
||||
[3600 * 24 * 28, `{${timeFormatToTemplate(systemDateFormats.interval.month)}`, 7, '{MMM}\n{YYYY}'],
|
||||
[
|
||||
3600 * 24,
|
||||
`{${timeFormatToTemplate(systemDateFormats.interval.day)}`,
|
||||
7,
|
||||
`${timeFormatToTemplate(systemDateFormats.interval.day)}\n${timeFormatToTemplate(systemDateFormats.interval.year)}`,
|
||||
],
|
||||
[
|
||||
3600,
|
||||
`{${timeFormatToTemplate(systemDateFormats.interval.minute)}`,
|
||||
4,
|
||||
`${timeFormatToTemplate(systemDateFormats.interval.minute)}\n${timeFormatToTemplate(
|
||||
systemDateFormats.interval.day
|
||||
)}`,
|
||||
],
|
||||
[
|
||||
60,
|
||||
`{${timeFormatToTemplate(systemDateFormats.interval.second)}`,
|
||||
4,
|
||||
`${timeFormatToTemplate(systemDateFormats.interval.second)}\n${timeFormatToTemplate(
|
||||
systemDateFormats.interval.day
|
||||
)}`,
|
||||
],
|
||||
[
|
||||
1,
|
||||
`:{ss}`,
|
||||
2,
|
||||
`:{ss}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate(
|
||||
systemDateFormats.interval.minute
|
||||
)}`,
|
||||
],
|
||||
[
|
||||
1e-3,
|
||||
':{ss}.{fff}',
|
||||
2,
|
||||
`:{ss}.{fff}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate(
|
||||
systemDateFormats.interval.minute
|
||||
)}`,
|
||||
],
|
||||
];
|
||||
|
||||
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
|
||||
|
||||
const TIME_FIELD_NAME = 'Time';
|
||||
|
||||
interface GraphNGProps extends Omit<PlotProps, 'data'> {
|
||||
data: DataFrame[];
|
||||
}
|
||||
|
||||
export const GraphNG: React.FC<GraphNGProps> = ({ data, children, ...plotProps }) => {
|
||||
const theme = useTheme();
|
||||
const [alignedData, setAlignedData] = useState<DataFrame | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length === 0) {
|
||||
setAlignedData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = alignAndSortDataFramesByFieldName(data, TIME_FIELD_NAME).subscribe(setAlignedData);
|
||||
|
||||
return function unsubscribe() {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (!alignedData) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const geometries: React.ReactNode[] = [];
|
||||
const scales: React.ReactNode[] = [];
|
||||
const axes: React.ReactNode[] = [];
|
||||
|
||||
let { timeIndex } = getTimeField(alignedData);
|
||||
if (timeIndex === undefined) {
|
||||
timeIndex = 0; // assuming first field represents x-domain
|
||||
scales.push(<Scale key="scale-x" scaleKey="x" />);
|
||||
} else {
|
||||
scales.push(<Scale key="scale-x" scaleKey="x" time />);
|
||||
}
|
||||
|
||||
axes.push(<Axis key="axis-scale--x" scaleKey="x" values={timeStampsConfig} side={2} />);
|
||||
|
||||
let seriesIdx = 0;
|
||||
const uniqueScales: Record<string, boolean> = {};
|
||||
|
||||
for (let i = 0; i < alignedData.fields.length; i++) {
|
||||
const seriesGeometry = [];
|
||||
const field = alignedData.fields[i];
|
||||
const config = field.config as FieldConfig<GraphCustomFieldConfig>;
|
||||
const customConfig = config.custom;
|
||||
|
||||
if (i === timeIndex || field.type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fmt = field.display ?? defaultFormatter;
|
||||
const scale = config.unit || '__fixed';
|
||||
|
||||
if (!uniqueScales[scale]) {
|
||||
uniqueScales[scale] = true;
|
||||
scales.push(<Scale key={`scale-${scale}`} scaleKey={scale} />);
|
||||
axes.push(
|
||||
<Axis
|
||||
key={`axis-${scale}-${i}`}
|
||||
scaleKey={scale}
|
||||
label={config.custom?.axis?.label}
|
||||
size={config.custom?.axis?.width}
|
||||
side={config.custom?.axis?.side || 3}
|
||||
grid={config.custom?.axis?.grid}
|
||||
formatValue={v => formattedValueToString(fmt(v))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// need to update field state here because we use a transform to merge framesP
|
||||
field.state = { ...field.state, seriesIndex: seriesIdx };
|
||||
|
||||
const colorMode = getFieldColorModeForField(field);
|
||||
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
||||
|
||||
if (customConfig?.line?.show) {
|
||||
seriesGeometry.push(
|
||||
<Line
|
||||
key={`line-${scale}-${i}`}
|
||||
scaleKey={scale}
|
||||
stroke={seriesColor}
|
||||
width={customConfig?.line.show ? customConfig?.line.width || 1 : 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (customConfig?.points?.show) {
|
||||
seriesGeometry.push(
|
||||
<Point key={`point-${scale}-${i}`} scaleKey={scale} size={customConfig?.points?.radius} stroke={seriesColor} />
|
||||
);
|
||||
}
|
||||
|
||||
if (customConfig?.fill?.alpha) {
|
||||
seriesGeometry.push(
|
||||
<Area key={`area-${scale}-${i}`} scaleKey={scale} fill={customConfig?.fill.alpha} color={seriesColor} />
|
||||
);
|
||||
}
|
||||
if (seriesGeometry.length > 1) {
|
||||
geometries.push(
|
||||
<SeriesGeometry key={`seriesGeometry-${scale}-${i}`} scaleKey={scale}>
|
||||
{seriesGeometry}
|
||||
</SeriesGeometry>
|
||||
);
|
||||
} else {
|
||||
geometries.push(seriesGeometry);
|
||||
}
|
||||
|
||||
seriesIdx++;
|
||||
}
|
||||
|
||||
return (
|
||||
<UPlotChart data={alignedData} {...plotProps}>
|
||||
{scales}
|
||||
{axes}
|
||||
{geometries}
|
||||
{children}
|
||||
</UPlotChart>
|
||||
);
|
||||
};
|
@ -69,15 +69,6 @@ export {
|
||||
BigValueTextMode,
|
||||
} from './BigValue/BigValue';
|
||||
|
||||
export { GraphCustomFieldConfig } from './uPlot/types';
|
||||
export { UPlotChart } from './uPlot/Plot';
|
||||
export * from './uPlot/geometries';
|
||||
export { usePlotConfigContext } from './uPlot/context';
|
||||
export { Canvas } from './uPlot/Canvas';
|
||||
export * from './uPlot/plugins';
|
||||
export { useRefreshAfterGraphRendered } from './uPlot/hooks';
|
||||
export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context';
|
||||
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { GraphLegend } from './Graph/GraphLegend';
|
||||
@ -208,3 +199,14 @@ const LegacyForms = {
|
||||
Switch,
|
||||
};
|
||||
export { LegacyForms, LegacyInputStatus };
|
||||
|
||||
// WIP, need renames and exports cleanup
|
||||
export { GraphCustomFieldConfig } from './uPlot/types';
|
||||
export { UPlotChart } from './uPlot/Plot';
|
||||
export * from './uPlot/geometries';
|
||||
export { usePlotConfigContext } from './uPlot/context';
|
||||
export { Canvas } from './uPlot/Canvas';
|
||||
export * from './uPlot/plugins';
|
||||
export { useRefreshAfterGraphRendered } from './uPlot/hooks';
|
||||
export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context';
|
||||
export { GraphNG } from './GraphNG/GraphNG';
|
||||
|
@ -3,7 +3,7 @@ import { css } from 'emotion';
|
||||
import uPlot from 'uplot';
|
||||
import { usePrevious } from 'react-use';
|
||||
import { buildPlotContext, PlotContext } from './context';
|
||||
import { pluginLog, preparePlotData, shouldReinitialisePlot } from './utils';
|
||||
import { pluginLog, preparePlotData, shouldInitialisePlot } from './utils';
|
||||
import { usePlotConfig } from './hooks';
|
||||
import { PlotProps } from './types';
|
||||
|
||||
@ -13,6 +13,7 @@ import { PlotProps } from './types';
|
||||
export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [plotInstance, setPlotInstance] = useState<uPlot>();
|
||||
const plotData = useRef<uPlot.AlignedData>();
|
||||
|
||||
// uPlot config API
|
||||
const { currentConfig, addSeries, addAxis, addScale, registerPlugin } = usePlotConfig(
|
||||
@ -20,36 +21,29 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
props.height,
|
||||
props.timeZone
|
||||
);
|
||||
|
||||
const prevConfig = usePrevious(currentConfig);
|
||||
|
||||
const getPlotInstance = useCallback(() => {
|
||||
if (!plotInstance) {
|
||||
throw new Error("Plot hasn't initialised yet");
|
||||
}
|
||||
|
||||
return plotInstance;
|
||||
}, [plotInstance]);
|
||||
|
||||
// Main function initialising uPlot. If final config is not settled it will do nothing
|
||||
const initPlot = () => {
|
||||
if (!currentConfig || !canvasRef.current) {
|
||||
return null;
|
||||
}
|
||||
const data = preparePlotData(props.data);
|
||||
pluginLog('uPlot core', false, 'initialized with', data, currentConfig);
|
||||
return new uPlot(currentConfig, data, canvasRef.current);
|
||||
};
|
||||
|
||||
// Callback executed when there was no change in plot config
|
||||
const updateData = useCallback(() => {
|
||||
if (!plotInstance) {
|
||||
if (!plotInstance || !plotData.current) {
|
||||
return;
|
||||
}
|
||||
const data = preparePlotData(props.data);
|
||||
pluginLog('uPlot core', false, 'updating plot data(throttled log!)');
|
||||
pluginLog('uPlot core', false, 'updating plot data(throttled log!)', plotData.current);
|
||||
// If config hasn't changed just update uPlot's data
|
||||
plotInstance.setData(data);
|
||||
}, [plotInstance, props.data]);
|
||||
plotInstance.setData(plotData.current);
|
||||
|
||||
if (props.onDataUpdate) {
|
||||
props.onDataUpdate(plotData.current);
|
||||
}
|
||||
}, [plotInstance, props.onDataUpdate]);
|
||||
|
||||
// Destroys previous plot instance when plot re-initialised
|
||||
useEffect(() => {
|
||||
@ -59,22 +53,38 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
};
|
||||
}, [plotInstance]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
plotData.current = preparePlotData(props.data);
|
||||
}, [props.data]);
|
||||
|
||||
// Decides if plot should update data or re-initialise
|
||||
useEffect(() => {
|
||||
if (!currentConfig) {
|
||||
useLayoutEffect(() => {
|
||||
// Make sure everything is ready before proceeding
|
||||
if (!currentConfig || !plotData.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldReinitialisePlot(prevConfig, currentConfig)) {
|
||||
const instance = initPlot();
|
||||
if (!instance) {
|
||||
return;
|
||||
// Do nothing if there is data vs series config mismatch. This may happen when the data was updated and made this
|
||||
// effect fire before the config update triggered the effect.
|
||||
if (currentConfig.series.length !== plotData.current.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldInitialisePlot(prevConfig, currentConfig)) {
|
||||
if (!canvasRef.current) {
|
||||
throw new Error('Missing Canvas component as a child of the plot.');
|
||||
}
|
||||
const instance = initPlot(plotData.current, currentConfig, canvasRef.current);
|
||||
|
||||
if (props.onPlotInit) {
|
||||
props.onPlotInit();
|
||||
}
|
||||
|
||||
setPlotInstance(instance);
|
||||
} else {
|
||||
updateData();
|
||||
}
|
||||
}, [props.data, props.timeRange, props.timeZone, currentConfig, setPlotInstance]);
|
||||
}, [currentConfig, updateData, setPlotInstance, props.onPlotInit]);
|
||||
|
||||
// When size props changed update plot size synchronously
|
||||
useLayoutEffect(() => {
|
||||
@ -114,3 +124,9 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
</PlotContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Main function initialising uPlot. If final config is not settled it will do nothing
|
||||
function initPlot(data: uPlot.AlignedData, config: uPlot.Options, ref: HTMLDivElement) {
|
||||
pluginLog('uPlot core', false, 'initialized with', data, config);
|
||||
return new uPlot(config, data, ref);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { AxisProps } from './types';
|
||||
import { usePlotConfigContext } from '../context';
|
||||
import { useTheme } from '../../../themes';
|
||||
@ -32,6 +32,7 @@ export const useAxisConfig = (getConfig: () => any) => {
|
||||
|
||||
export const Axis: React.FC<AxisProps> = props => {
|
||||
const theme = useTheme();
|
||||
const gridColor = useMemo(() => (theme.isDark ? theme.palette.gray1 : theme.palette.gray4), [theme]);
|
||||
const {
|
||||
scaleKey,
|
||||
label,
|
||||
@ -54,7 +55,12 @@ export const Axis: React.FC<AxisProps> = props => {
|
||||
side,
|
||||
grid: {
|
||||
show: grid,
|
||||
stroke: theme.palette.gray4,
|
||||
stroke: gridColor,
|
||||
width: 1 / devicePixelRatio,
|
||||
},
|
||||
ticks: {
|
||||
show: true,
|
||||
stroke: gridColor,
|
||||
width: 1 / devicePixelRatio,
|
||||
},
|
||||
values: values ? values : formatValue ? (u: uPlot, vals: any[]) => vals.map(v => formatValue(v)) : undefined,
|
||||
|
@ -3,8 +3,7 @@ import { renderHook, act } from '@testing-library/react-hooks';
|
||||
|
||||
describe('usePlotConfig', () => {
|
||||
it('returns default plot config', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
await waitForNextUpdate();
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
@ -34,7 +33,7 @@ describe('usePlotConfig', () => {
|
||||
});
|
||||
describe('series config', () => {
|
||||
it('should add series', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addSeries = result.current.addSeries;
|
||||
|
||||
act(() => {
|
||||
@ -42,7 +41,6 @@ describe('usePlotConfig', () => {
|
||||
stroke: '#ff0000',
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.series).toHaveLength(2);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
@ -76,7 +74,7 @@ describe('usePlotConfig', () => {
|
||||
});
|
||||
|
||||
it('should update series', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addSeries = result.current.addSeries;
|
||||
|
||||
act(() => {
|
||||
@ -88,7 +86,6 @@ describe('usePlotConfig', () => {
|
||||
stroke: '#00ff00',
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.series).toHaveLength(2);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
@ -122,7 +119,7 @@ describe('usePlotConfig', () => {
|
||||
});
|
||||
|
||||
it('should remove series', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addSeries = result.current.addSeries;
|
||||
|
||||
act(() => {
|
||||
@ -132,7 +129,6 @@ describe('usePlotConfig', () => {
|
||||
|
||||
removeSeries();
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.series).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
@ -165,7 +161,7 @@ describe('usePlotConfig', () => {
|
||||
|
||||
describe('axis config', () => {
|
||||
it('should add axis', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addAxis = result.current.addAxis;
|
||||
|
||||
act(() => {
|
||||
@ -173,7 +169,6 @@ describe('usePlotConfig', () => {
|
||||
side: 1,
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.axes).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
@ -208,7 +203,7 @@ describe('usePlotConfig', () => {
|
||||
});
|
||||
|
||||
it('should update axis', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addAxis = result.current.addAxis;
|
||||
|
||||
act(() => {
|
||||
@ -220,7 +215,6 @@ describe('usePlotConfig', () => {
|
||||
side: 3,
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.axes).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
@ -255,7 +249,7 @@ describe('usePlotConfig', () => {
|
||||
});
|
||||
|
||||
it('should remove axis', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addAxis = result.current.addAxis;
|
||||
|
||||
act(() => {
|
||||
@ -265,7 +259,6 @@ describe('usePlotConfig', () => {
|
||||
|
||||
removeAxis();
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.axes).toHaveLength(0);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
@ -298,7 +291,7 @@ describe('usePlotConfig', () => {
|
||||
|
||||
describe('scales config', () => {
|
||||
it('should add scale', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addScale = result.current.addScale;
|
||||
|
||||
act(() => {
|
||||
@ -306,7 +299,6 @@ describe('usePlotConfig', () => {
|
||||
time: true,
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
@ -341,7 +333,7 @@ describe('usePlotConfig', () => {
|
||||
});
|
||||
|
||||
it('should update scale', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addScale = result.current.addScale;
|
||||
|
||||
act(() => {
|
||||
@ -353,7 +345,6 @@ describe('usePlotConfig', () => {
|
||||
time: false,
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
@ -388,7 +379,7 @@ describe('usePlotConfig', () => {
|
||||
});
|
||||
|
||||
it('should remove scale', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addScale = result.current.addScale;
|
||||
|
||||
act(() => {
|
||||
@ -398,7 +389,6 @@ describe('usePlotConfig', () => {
|
||||
|
||||
removeScale();
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(0);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
@ -431,7 +421,7 @@ describe('usePlotConfig', () => {
|
||||
|
||||
describe('plugins config', () => {
|
||||
it('should register plugin', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const registerPlugin = result.current.registerPlugin;
|
||||
|
||||
act(() => {
|
||||
@ -440,7 +430,6 @@ describe('usePlotConfig', () => {
|
||||
hooks: {},
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
@ -475,7 +464,7 @@ describe('usePlotConfig', () => {
|
||||
});
|
||||
|
||||
it('should unregister plugin', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const registerPlugin = result.current.registerPlugin;
|
||||
|
||||
let unregister: () => void;
|
||||
@ -485,7 +474,6 @@ describe('usePlotConfig', () => {
|
||||
hooks: {},
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1);
|
||||
|
||||
|
@ -14,8 +14,8 @@ export const usePlotPlugins = () => {
|
||||
|
||||
// arePluginsReady determines whether or not all plugins has already registered and uPlot should be initialised
|
||||
const [arePluginsReady, setPluginsReady] = useState(false);
|
||||
|
||||
const cancellationToken = useRef<number>();
|
||||
const isMounted = useRef(false);
|
||||
|
||||
const checkPluginsReady = useCallback(() => {
|
||||
if (cancellationToken.current) {
|
||||
@ -29,7 +29,9 @@ export const usePlotPlugins = () => {
|
||||
* and arePluginsReady will be deferred to next animation frame.
|
||||
*/
|
||||
cancellationToken.current = window.requestAnimationFrame(function() {
|
||||
setPluginsReady(true);
|
||||
if (isMounted.current) {
|
||||
setPluginsReady(true);
|
||||
}
|
||||
});
|
||||
}, [cancellationToken, setPluginsReady]);
|
||||
|
||||
@ -66,9 +68,9 @@ export const usePlotPlugins = () => {
|
||||
useEffect(() => {
|
||||
checkPluginsReady();
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (cancellationToken.current) {
|
||||
window.cancelAnimationFrame(cancellationToken.current);
|
||||
cancellationToken.current = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@ -92,6 +94,7 @@ export const DEFAULT_PLOT_CONFIG = {
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
series: [],
|
||||
hooks: {},
|
||||
};
|
||||
export const usePlotConfig = (width: number, height: number, timeZone: TimeZone) => {
|
||||
@ -113,7 +116,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone)
|
||||
return fmt;
|
||||
}, [timeZone]);
|
||||
|
||||
const defaultConfig = useMemo(() => {
|
||||
const defaultConfig = useMemo<uPlot.Options>(() => {
|
||||
return {
|
||||
...DEFAULT_PLOT_CONFIG,
|
||||
width,
|
||||
@ -122,7 +125,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone)
|
||||
hooks: p[1].hooks,
|
||||
})),
|
||||
tzDate,
|
||||
} as any;
|
||||
};
|
||||
}, [plugins, width, height, tzDate]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -56,9 +56,13 @@ export interface PlotPluginProps {
|
||||
|
||||
export interface PlotProps {
|
||||
data: DataFrame;
|
||||
width: number;
|
||||
height: number;
|
||||
timeRange: TimeRange;
|
||||
timeZone: TimeZone;
|
||||
children: React.ReactNode[];
|
||||
width: number;
|
||||
height: number;
|
||||
children?: React.ReactNode | React.ReactNode[];
|
||||
/** Callback performed when uPlot data is updated */
|
||||
onDataUpdate?: (data: uPlot.AlignedData) => {};
|
||||
/** Callback performed when uPlot is (re)initialized */
|
||||
onPlotInit?: () => {};
|
||||
}
|
||||
|
@ -93,16 +93,18 @@ const isPlottingTime = (config: uPlot.Options) => {
|
||||
* Based on two config objects indicates whether or not uPlot needs reinitialisation
|
||||
* This COULD be done based on data frames, but keeping it this way for now as a simplification
|
||||
*/
|
||||
export const shouldReinitialisePlot = (prevConfig?: uPlot.Options, config?: uPlot.Options) => {
|
||||
export const shouldInitialisePlot = (prevConfig?: uPlot.Options, config?: uPlot.Options) => {
|
||||
if (!config && !prevConfig) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!prevConfig && config) {
|
||||
if (config) {
|
||||
if (config.width === 0 || config.height === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
if (!prevConfig) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlottingTime(config!) && prevConfig!.tzDate !== config!.tzDate) {
|
||||
|
@ -1,90 +1,21 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
Area,
|
||||
Canvas,
|
||||
ContextMenuPlugin,
|
||||
GraphCustomFieldConfig,
|
||||
LegendDisplayMode,
|
||||
LegendPlugin,
|
||||
Line,
|
||||
Point,
|
||||
Scale,
|
||||
SeriesGeometry,
|
||||
TooltipPlugin,
|
||||
UPlotChart,
|
||||
ZoomPlugin,
|
||||
useTheme,
|
||||
GraphNG,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
FieldConfig,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getTimeField,
|
||||
PanelProps,
|
||||
getFieldColorModeForField,
|
||||
systemDateFormats,
|
||||
} from '@grafana/data';
|
||||
|
||||
import { PanelProps } from '@grafana/data';
|
||||
import { Options } from './types';
|
||||
import { alignAndSortDataFramesByFieldName } from './utils';
|
||||
import { VizLayout } from './VizLayout';
|
||||
|
||||
import { Axis } from '@grafana/ui/src/components/uPlot/geometries/Axis';
|
||||
import { timeFormatToTemplate } from '@grafana/ui/src/components/uPlot/utils';
|
||||
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
|
||||
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
|
||||
|
||||
interface GraphPanelProps extends PanelProps<Options> {}
|
||||
|
||||
const TIME_FIELD_NAME = 'Time';
|
||||
|
||||
const timeStampsConfig = [
|
||||
[3600 * 24 * 365, '{YYYY}', 7, '{YYYY}'],
|
||||
[3600 * 24 * 28, `{${timeFormatToTemplate(systemDateFormats.interval.month)}`, 7, '{MMM}\n{YYYY}'],
|
||||
[
|
||||
3600 * 24,
|
||||
`{${timeFormatToTemplate(systemDateFormats.interval.day)}`,
|
||||
7,
|
||||
`${timeFormatToTemplate(systemDateFormats.interval.day)}\n${timeFormatToTemplate(systemDateFormats.interval.year)}`,
|
||||
],
|
||||
[
|
||||
3600,
|
||||
`{${timeFormatToTemplate(systemDateFormats.interval.minute)}`,
|
||||
4,
|
||||
`${timeFormatToTemplate(systemDateFormats.interval.minute)}\n${timeFormatToTemplate(
|
||||
systemDateFormats.interval.day
|
||||
)}`,
|
||||
],
|
||||
[
|
||||
60,
|
||||
`{${timeFormatToTemplate(systemDateFormats.interval.second)}`,
|
||||
4,
|
||||
`${timeFormatToTemplate(systemDateFormats.interval.second)}\n${timeFormatToTemplate(
|
||||
systemDateFormats.interval.day
|
||||
)}`,
|
||||
],
|
||||
[
|
||||
1,
|
||||
`:{ss}`,
|
||||
2,
|
||||
`:{ss}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate(
|
||||
systemDateFormats.interval.minute
|
||||
)}`,
|
||||
],
|
||||
[
|
||||
1e-3,
|
||||
':{ss}.{fff}',
|
||||
2,
|
||||
`:{ss}.{fff}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate(
|
||||
systemDateFormats.interval.minute
|
||||
)}`,
|
||||
],
|
||||
];
|
||||
|
||||
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
|
||||
|
||||
export const GraphPanel: React.FC<GraphPanelProps> = ({
|
||||
data,
|
||||
timeRange,
|
||||
@ -94,117 +25,6 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
|
||||
options,
|
||||
onChangeTimeRange,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [alignedData, setAlignedData] = useState<DataFrame | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !data.series?.length) {
|
||||
setAlignedData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = alignAndSortDataFramesByFieldName(data.series, TIME_FIELD_NAME).subscribe(setAlignedData);
|
||||
|
||||
return function unsubscribe() {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (!alignedData) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const geometries: React.ReactNode[] = [];
|
||||
const scales: React.ReactNode[] = [];
|
||||
const axes: React.ReactNode[] = [];
|
||||
|
||||
let { timeIndex } = getTimeField(alignedData);
|
||||
|
||||
if (timeIndex === undefined) {
|
||||
timeIndex = 0; // assuming first field represents x-domain
|
||||
scales.push(<Scale scaleKey="x" />);
|
||||
} else {
|
||||
scales.push(<Scale scaleKey="x" time />);
|
||||
}
|
||||
|
||||
axes.push(<Axis scaleKey="x" values={timeStampsConfig} side={2} />);
|
||||
|
||||
let seriesIdx = 0;
|
||||
const uniqueScales: Record<string, boolean> = {};
|
||||
|
||||
for (let i = 0; i < alignedData.fields.length; i++) {
|
||||
const seriesGeometry = [];
|
||||
const field = alignedData.fields[i];
|
||||
const config = field.config as FieldConfig<GraphCustomFieldConfig>;
|
||||
const customConfig = config.custom;
|
||||
|
||||
if (i === timeIndex || field.type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fmt = field.display ?? defaultFormatter;
|
||||
const scale = config.unit || '__fixed';
|
||||
|
||||
if (!uniqueScales[scale]) {
|
||||
uniqueScales[scale] = true;
|
||||
scales.push(<Scale scaleKey={scale} />);
|
||||
axes.push(
|
||||
<Axis
|
||||
key={`axis-${scale}-${i}`}
|
||||
scaleKey={scale}
|
||||
label={config.custom?.axis?.label}
|
||||
size={config.custom?.axis?.width}
|
||||
side={config.custom?.axis?.side || 3}
|
||||
grid={config.custom?.axis?.grid}
|
||||
formatValue={v => formattedValueToString(fmt(v))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// need to update field state here because we use a transform to merge frames
|
||||
field.state = { ...field.state, seriesIndex: seriesIdx };
|
||||
|
||||
const colorMode = getFieldColorModeForField(field);
|
||||
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
||||
|
||||
if (customConfig?.line?.show) {
|
||||
seriesGeometry.push(
|
||||
<Line
|
||||
key={`line-${scale}-${i}`}
|
||||
scaleKey={scale}
|
||||
stroke={seriesColor}
|
||||
width={customConfig?.line.show ? customConfig?.line.width || 1 : 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (customConfig?.points?.show) {
|
||||
seriesGeometry.push(
|
||||
<Point key={`point-${scale}-${i}`} scaleKey={scale} size={customConfig?.points?.radius} stroke={seriesColor} />
|
||||
);
|
||||
}
|
||||
|
||||
if (customConfig?.fill?.alpha) {
|
||||
seriesGeometry.push(
|
||||
<Area key={`area-${scale}-${i}`} scaleKey={scale} fill={customConfig?.fill.alpha} color={seriesColor} />
|
||||
);
|
||||
}
|
||||
if (seriesGeometry.length > 1) {
|
||||
geometries.push(
|
||||
<SeriesGeometry key={`seriesGeometry-${scale}-${i}`} scaleKey={scale}>
|
||||
{seriesGeometry}
|
||||
</SeriesGeometry>
|
||||
);
|
||||
} else {
|
||||
geometries.push(seriesGeometry);
|
||||
}
|
||||
|
||||
seriesIdx++;
|
||||
}
|
||||
|
||||
return (
|
||||
<VizLayout width={width} height={height}>
|
||||
{({ builder, getLayout }) => {
|
||||
@ -230,10 +50,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<UPlotChart data={alignedData} timeRange={timeRange} timeZone={timeZone} {...canvasSize}>
|
||||
{scales}
|
||||
{axes}
|
||||
{geometries}
|
||||
<GraphNG data={data.series} timeRange={timeRange} timeZone={timeZone} {...canvasSize}>
|
||||
{builder.addSlot('canvas', <Canvas />).render()}
|
||||
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
|
||||
<ZoomPlugin onZoom={onChangeTimeRange} />
|
||||
@ -243,7 +60,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
|
||||
{data.annotations && <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} />}
|
||||
{/* TODO: */}
|
||||
{/*<AnnotationsEditorPlugin />*/}
|
||||
</UPlotChart>
|
||||
</GraphNG>
|
||||
);
|
||||
}}
|
||||
</VizLayout>
|
||||
|
@ -33,7 +33,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (plotCtx.isPlotReady && annotations.length > 0) {
|
||||
if (plotCtx.isPlotReady) {
|
||||
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
|
||||
|
||||
for (const frame of annotations) {
|
||||
|
@ -42,7 +42,7 @@ export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, tim
|
||||
|
||||
// THIS EVENT ONLY MOCKS EXEMPLAR Y VALUE!!!! TO BE REMOVED WHEN WE GET CORRECT EXEMPLARS SHAPE VIA PROPS
|
||||
useEffect(() => {
|
||||
if (plotCtx.isPlotReady && exemplars.length) {
|
||||
if (plotCtx.isPlotReady) {
|
||||
const mocks: DataFrame[] = [];
|
||||
|
||||
for (const frame of exemplars) {
|
||||
|
Loading…
Reference in New Issue
Block a user