GraphNG: stats in legend (#30251)

* StatsPickerEditor - add more config to the options ui

* Show calcs in the legend

* Refactor the way legend items are created

* Progress on refresh

* Migration update

* Use human-readable names in the legend stats

* Disable pointer cursor in table header
This commit is contained in:
Dominik Prokop
2021-01-15 09:14:50 +01:00
committed by GitHub
parent 61b9d811cb
commit f5dfb3b24b
19 changed files with 336 additions and 59 deletions

View File

@@ -132,3 +132,14 @@ export interface FieldColorConfigSettings {
*/
preferThresholdsMode?: boolean;
}
export interface StatsPickerConfigSettings {
/**
* Enable multi-selection in the stats picker
*/
allowMultiple: boolean;
/**
* Default stats to be use in the stats picker
*/
defaultStat?: string;
}

View File

@@ -81,7 +81,7 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
color: s.color || '',
disabled: !s.isVisible,
yAxis: s.yAxis.index,
displayValues: s.info || [],
getDisplayValues: () => s.info || [],
},
]);
}, []);

View File

@@ -63,8 +63,8 @@ export const Lines: React.FC = () => {
to: dateTime(1546380000000),
},
}}
legend={{ displayMode: LegendDisplayMode.List, placement: legendPlacement }}
legend={{ displayMode: LegendDisplayMode.List, placement: legendPlacement, calcs: [] }}
timeZone="browser"
></GraphNG>
/>
);
};

View File

@@ -2,19 +2,22 @@ import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
import {
compareDataFrameStructures,
DataFrame,
DisplayValue,
FieldConfig,
FieldMatcher,
fieldReducers,
FieldType,
formattedValueToString,
getFieldColorModeForField,
getFieldDisplayName,
reduceField,
TimeRange,
} from '@grafana/data';
import { alignDataFrames } from './utils';
import { useTheme } from '../../themes';
import { UPlotChart } from '../uPlot/Plot';
import { PlotProps } from '../uPlot/types';
import { AxisPlacement, DrawStyle, GraphFieldConfig, PointVisibility } from '../uPlot/config';
import { useTheme } from '../../themes';
import { VizLayout } from '../VizLayout/VizLayout';
import { LegendDisplayMode, VizLegendItem, VizLegendOptions } from '../VizLegend/types';
import { VizLegend } from '../VizLegend/VizLegend';
@@ -30,7 +33,7 @@ export interface XYFieldMatchers {
}
export interface GraphNGProps extends Omit<PlotProps, 'data' | 'config'> {
data: DataFrame[];
legend?: VizLegendOptions;
legend: VizLegendOptions;
fields?: XYFieldMatchers; // default will assume timeseries data
onLegendClick?: (event: GraphNGLegendEvent) => void;
onSeriesColorChange?: (label: string, color: string) => void;
@@ -55,10 +58,10 @@ export const GraphNG: React.FC<GraphNGProps> = ({
onSeriesColorChange,
...plotProps
}) => {
const alignedFrameWithGapTest = useMemo(() => alignDataFrames(data, fields), [data, fields]);
const theme = useTheme();
const legendItemsRef = useRef<VizLegendItem[]>([]);
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
const alignedFrameWithGapTest = useMemo(() => alignDataFrames(data, fields), [data, fields]);
const alignedFrame = alignedFrameWithGapTest?.frame;
const getDataFrameFieldIndex = alignedFrameWithGapTest?.getDataFrameFieldIndex;
@@ -87,6 +90,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
// reference change will not triger re-render
const currentTimeRange = useRef<TimeRange>(timeRange);
useLayoutEffect(() => {
currentTimeRange.current = timeRange;
}, [timeRange]);
@@ -130,8 +134,6 @@ export const GraphNG: React.FC<GraphNGProps> = ({
});
}
const legendItems: VizLegendItem[] = [];
for (let i = 0; i < alignedFrame.fields.length; i++) {
const field = alignedFrame.fields[i];
const config = field.config as FieldConfig<GraphFieldConfig>;
@@ -170,6 +172,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
}
const showPoints = customConfig.drawStyle === DrawStyle.Points ? PointVisibility.Always : customConfig.showPoints;
const dataFrameFieldIndex = getDataFrameFieldIndex ? getDataFrameFieldIndex(i) : undefined;
builder.addSeries({
scaleKey,
@@ -185,24 +188,13 @@ export const GraphNG: React.FC<GraphNGProps> = ({
spanNulls: customConfig.spanNulls || false,
show: !customConfig.hideFrom?.graph,
fillGradient: customConfig.fillGradient,
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
dataFrameFieldIndex,
fieldName: getFieldDisplayName(field, alignedFrame),
hideInLegend: customConfig.hideFrom?.legend,
});
if (hasLegend.current && !customConfig.hideFrom?.legend) {
const axisPlacement = builder.getAxisPlacement(scaleKey);
// we need to add this as dep or move it to be done outside.
const dataFrameFieldIndex = getDataFrameFieldIndex ? getDataFrameFieldIndex(i) : undefined;
legendItems.push({
disabled: field.config.custom?.hideFrom?.graph ?? false,
fieldIndex: dataFrameFieldIndex,
color: seriesColor,
label: getFieldDisplayName(field, alignedFrame),
yAxis: axisPlacement === AxisPlacement.Left ? 1 : 2,
});
}
}
legendItemsRef.current = legendItems;
return builder;
}, [configRev, timeZone]);
@@ -214,16 +206,58 @@ export const GraphNG: React.FC<GraphNGProps> = ({
);
}
const legendItems = configBuilder
.getSeries()
.map<VizLegendItem | undefined>(s => {
const seriesConfig = s.props;
const fieldIndex = seriesConfig.dataFrameFieldIndex;
const axisPlacement = configBuilder.getAxisPlacement(s.props.scaleKey);
if (seriesConfig.hideInLegend || !fieldIndex) {
return undefined;
}
const field = data[fieldIndex.frameIndex]?.fields[fieldIndex.fieldIndex];
// Hackish: when the data prop and config builder are not in sync yet
if (!field) {
return undefined;
}
return {
disabled: !seriesConfig.show ?? false,
fieldIndex,
color: seriesConfig.lineColor!,
label: seriesConfig.fieldName,
yAxis: axisPlacement === AxisPlacement.Left ? 1 : 2,
getDisplayValues: () => {
const fmt = field.display ?? defaultFormatter;
const fieldCalcs = reduceField({
field,
reducers: legend.calcs,
});
return legend.calcs.map<DisplayValue>(reducer => {
return {
...fmt(fieldCalcs[reducer]),
title: fieldReducers.get(reducer).name,
};
});
},
};
})
.filter(i => i !== undefined) as VizLegendItem[];
let legendElement: React.ReactElement | undefined;
if (hasLegend && legendItemsRef.current.length > 0) {
if (hasLegend && legendItems.length > 0) {
legendElement = (
<VizLayout.Legend position={legend!.placement} maxHeight="35%" maxWidth="60%">
<VizLayout.Legend position={legend.placement} maxHeight="35%" maxWidth="60%">
<VizLegend
onLabelClick={onLabelClick}
placement={legend!.placement}
items={legendItemsRef.current}
displayMode={legend!.displayMode}
placement={legend.placement}
items={legendItems}
displayMode={legend.displayMode}
onSeriesColorChange={onSeriesColorChange}
/>
</VizLayout.Legend>

View File

@@ -1,7 +1,18 @@
import React from 'react';
import { FieldConfigEditorProps, ReducerID } from '@grafana/data';
import { FieldConfigEditorProps, StatsPickerConfigSettings } from '@grafana/data';
import { StatsPicker } from '../StatsPicker/StatsPicker';
export const StatsPickerEditor: React.FC<FieldConfigEditorProps<string[], any>> = ({ value, onChange }) => {
return <StatsPicker stats={value} onChange={onChange} allowMultiple={false} defaultStat={ReducerID.mean} />;
export const StatsPickerEditor: React.FC<FieldConfigEditorProps<string[], StatsPickerConfigSettings>> = ({
value,
onChange,
item,
}) => {
return (
<StatsPicker
stats={value}
onChange={onChange}
allowMultiple={!!item.settings?.allowMultiple}
defaultStat={item.settings?.defaultStat}
/>
);
};

View File

@@ -8,6 +8,7 @@ import {
FieldType,
getFieldColorModeForField,
FieldConfig,
getFieldDisplayName,
} from '@grafana/data';
import { AxisPlacement, DrawStyle, GraphFieldConfig, PointVisibility } from '../uPlot/config';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
@@ -136,6 +137,7 @@ export class Sparkline extends PureComponent<Props, State> {
builder.addSeries({
scaleKey,
fieldName: getFieldDisplayName(field, data),
drawStyle: customConfig.drawStyle!,
lineColor: customConfig.lineColor ?? seriesColor,
lineWidth: customConfig.lineWidth,

View File

@@ -40,7 +40,7 @@ export const VizLegendListItem: React.FunctionComponent<Props> = ({ item, onSeri
{item.label}
</div>
{item.displayValues && <VizLegendStatsList stats={item.displayValues} />}
{item.getDisplayValues && <VizLegendStatsList stats={item.getDisplayValues()} />}
</div>
);
};

View File

@@ -22,8 +22,8 @@ export const VizLegendTable: FC<VizLegendTableProps> = ({
const columns = items
.map(item => {
if (item.displayValues) {
return item.displayValues.map(i => i.title);
if (item.getDisplayValues) {
return item.getDisplayValues().map(i => i.title);
}
return [];
})
@@ -39,8 +39,8 @@ export const VizLegendTable: FC<VizLegendTableProps> = ({
const sortedItems = sortKey
? sortBy(items, item => {
if (item.displayValues) {
const stat = item.displayValues.filter(stat => stat.title === sortKey)[0];
if (item.getDisplayValues) {
const stat = item.getDisplayValues().filter(stat => stat.title === sortKey)[0];
return stat && stat.numeric;
}
return undefined;
@@ -67,7 +67,7 @@ export const VizLegendTable: FC<VizLegendTableProps> = ({
return (
<th
key={columnHeader}
className={styles.header}
className={cx(styles.header, onToggleSort && styles.headerSortable)}
onClick={() => {
if (onToggleSort) {
onToggleSort(columnHeader);
@@ -99,6 +99,8 @@ const getStyles = (theme: GrafanaTheme) => ({
border-bottom: 1px solid ${theme.colors.border1};
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
text-align: right;
`,
headerSortable: css`
cursor: pointer;
`,
sortIcon: css`

View File

@@ -49,8 +49,8 @@ export const LegendTableItem: React.FunctionComponent<Props> = ({
</div>
</span>
</td>
{item.displayValues &&
item.displayValues.map((stat, index) => {
{item.getDisplayValues &&
item.getDisplayValues().map((stat, index) => {
return (
<td className={styles.value} key={`${stat.title}-${index}`}>
{formattedValueToString(stat)}

View File

@@ -24,7 +24,8 @@ export interface VizLegendItem {
color: string;
yAxis: number;
disabled?: boolean;
displayValues?: DisplayValue[];
// displayValues?: DisplayValue[];
getDisplayValues?: () => DisplayValue[];
fieldIndex?: DataFrameFieldIndex;
}
@@ -39,6 +40,7 @@ export type LegendPlacement = 'bottom' | 'right';
export interface VizLegendOptions {
displayMode: LegendDisplayMode;
placement: LegendPlacement;
calcs: string[];
}
export type SeriesOptionChangeHandler<TOption> = (label: string, option: TOption) => void;

View File

@@ -325,6 +325,7 @@ describe('UPlotConfigBuilder', () => {
builder.addSeries({
drawStyle: DrawStyle.Line,
scaleKey: 'scale-x',
fieldName: 'A-series',
lineColor: '#0000ff',
});
@@ -336,6 +337,7 @@ describe('UPlotConfigBuilder', () => {
builder.addSeries({
drawStyle: DrawStyle.Line,
scaleKey: 'scale-x',
fieldName: 'A-series',
lineColor: '#FFAABB',
fillOpacity: 50,
});
@@ -348,6 +350,7 @@ describe('UPlotConfigBuilder', () => {
builder.addSeries({
drawStyle: DrawStyle.Line,
scaleKey: 'scale-x',
fieldName: 'A-series',
lineColor: '#FFAABB',
fillOpacity: 50,
fillColor: '#FF0000',
@@ -361,6 +364,7 @@ describe('UPlotConfigBuilder', () => {
builder.addSeries({
drawStyle: DrawStyle.Line,
scaleKey: 'scale-x',
fieldName: 'A-series',
lineColor: '#FFAABB',
fillOpacity: 50,
fillGradient: FillGradientMode.Opacity,
@@ -374,6 +378,7 @@ describe('UPlotConfigBuilder', () => {
builder.addSeries({
drawStyle: DrawStyle.Line,
scaleKey: 'scale-x',
fieldName: 'A-series',
fillOpacity: 50,
fillGradient: FillGradientMode.Opacity,
showPoints: PointVisibility.Auto,

View File

@@ -57,6 +57,10 @@ export class UPlotConfigBuilder {
this.series.push(new UPlotSeriesBuilder(props));
}
getSeries() {
return this.series;
}
/** Add or update the scale with the scale key */
addScale(props: ScaleProps) {
const current = this.scales.find(v => v.props.scaleKey === props.scaleKey);

View File

@@ -11,11 +11,15 @@ import {
FillGradientMode,
} from '../config';
import { PlotConfigBuilder } from '../types';
import { DataFrameFieldIndex } from '@grafana/data';
export interface SeriesProps extends LineConfig, FillConfig, PointsConfig {
drawStyle: DrawStyle;
scaleKey: string;
fieldName: string;
drawStyle: DrawStyle;
show?: boolean;
dataFrameFieldIndex?: DataFrameFieldIndex;
hideInLegend?: boolean;
}
export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {

View File

@@ -21,6 +21,7 @@ import {
TimeZone,
FieldColor,
FieldColorConfigSettings,
StatsPickerConfigSettings,
} from '@grafana/data';
import { Switch } from '../components/Switch/Switch';
@@ -323,7 +324,7 @@ export const getStandardOptionEditors = () => {
editor: DataLinksValueEditor as any,
};
const statsPicker: StandardEditorsRegistryItem<string[]> = {
const statsPicker: StandardEditorsRegistryItem<string[], StatsPickerConfigSettings> = {
id: 'stats-picker',
name: 'Stats Picker',
editor: StatsPickerEditor as any,

View File

@@ -1,5 +1,5 @@
import { ApplyFieldOverrideOptions, DataTransformerConfig, dateMath, FieldColorModeId, PanelData } from '@grafana/data';
import { GraphNG, Table } from '@grafana/ui';
import { GraphNG, LegendDisplayMode, Table } from '@grafana/ui';
import { config } from 'app/core/config';
import React, { FC, useMemo, useState } from 'react';
import { useObservable } from 'react-use';
@@ -54,7 +54,14 @@ export const TestStuffPage: FC = () => {
{data && (
<div style={{ padding: '16px' }}>
<GraphNG width={1200} height={300} data={data.series} timeRange={data.timeRange} timeZone="browser" />
<GraphNG
width={1200}
height={300}
data={data.series}
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
timeRange={data.timeRange}
timeZone="browser"
/>
<hr></hr>
<Table data={data.series[0]} width={1200} height={300} />
</div>

View File

@@ -1,5 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Graph Migrations legend 1`] = `
Object {
"fieldConfig": Object {
"defaults": Object {
"custom": Object {
"axisPlacement": "auto",
"drawStyle": "line",
"fillOpacity": 50,
"lineInterpolation": "stepAfter",
"lineWidth": 5,
"pointSize": 6,
"showPoints": "never",
"spanNulls": true,
},
"nullValueMode": "null",
"unit": "short",
},
"overrides": Array [
Object {
"matcher": Object {
"id": "byName",
"options": "A-series",
},
"properties": Array [
Object {
"id": "color",
"value": Object {
"fixedColor": "red",
"mode": "fixed",
},
},
],
},
],
},
"options": Object {
"graph": Object {},
"legend": Object {
"calcs": Array [
"mean",
"lastNotNull",
"max",
"min",
"sum",
],
"displayMode": "table",
"placement": "bottom",
},
"tooltipOptions": Object {
"mode": "single",
},
},
}
`;
exports[`Graph Migrations simple bars 1`] = `
Object {
"fieldConfig": Object {
@@ -16,6 +71,7 @@ Object {
"options": Object {
"graph": Object {},
"legend": Object {
"calcs": Array [],
"displayMode": "list",
"placement": "bottom",
},
@@ -50,7 +106,14 @@ Object {
"options": Object {
"graph": Object {},
"legend": Object {
"displayMode": "list",
"calcs": Array [
"mean",
"lastNotNull",
"max",
"min",
"sum",
],
"displayMode": "table",
"placement": "bottom",
},
"tooltipOptions": Object {
@@ -98,6 +161,7 @@ Object {
"options": Object {
"graph": Object {},
"legend": Object {
"calcs": Array [],
"displayMode": "list",
"placement": "bottom",
},
@@ -179,6 +243,7 @@ Object {
"options": Object {
"graph": Object {},
"legend": Object {
"calcs": Array [],
"displayMode": "list",
"placement": "bottom",
},

View File

@@ -39,6 +39,15 @@ describe('Graph Migrations', () => {
panel.options = graphPanelChangedHandler(panel, 'graph', old);
expect(panel).toMatchSnapshot();
});
it('legend', () => {
const old: any = {
angular: legend,
};
const panel = {} as PanelModel;
panel.options = graphPanelChangedHandler(panel, 'graph', old);
expect(panel).toMatchSnapshot();
});
});
const stairscase = {
@@ -306,3 +315,97 @@ const stepedColordLine = {
timeShift: null,
datasource: null,
};
const legend = {
aliasColors: {
'A-series': 'red',
},
dashLength: 10,
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
fill: 5,
gridPos: {
h: 9,
w: 12,
x: 0,
y: 0,
},
id: 2,
legend: {
avg: true,
current: true,
max: false,
min: false,
show: true,
total: true,
values: true,
alignAsTable: true,
},
lines: true,
linewidth: 5,
maxDataPoints: 20,
nullPointMode: 'null',
options: {
alertThreshold: true,
},
pluginVersion: '7.4.0-pre',
pointradius: 2,
renderer: 'flot',
seriesOverrides: [],
spaceLength: 10,
steppedLine: true,
thresholds: [],
timeRegions: [],
title: 'Panel Title',
tooltip: {
shared: true,
sort: 0,
value_type: 'individual',
},
type: 'graph',
xaxis: {
buckets: null,
mode: 'time',
name: null,
show: true,
values: [],
},
yaxes: [
{
$$hashKey: 'object:38',
format: 'short',
label: null,
logBase: 1,
max: null,
min: null,
show: true,
},
{
$$hashKey: 'object:39',
format: 'short',
label: null,
logBase: 1,
max: null,
min: null,
show: true,
},
],
yaxis: {
align: false,
alignLevel: null,
},
bars: false,
dashes: false,
fillGradient: 0,
hiddenSeries: false,
percentage: false,
points: false,
stack: false,
timeFrom: null,
timeShift: null,
datasource: null,
};

View File

@@ -1,20 +1,20 @@
import {
ConfigOverrideRule,
DynamicConfigValue,
FieldColorModeId,
FieldConfig,
FieldConfigProperty,
FieldConfigSource,
FieldMatcherID,
fieldReducers,
NullValueMode,
PanelModel,
fieldReducers,
ConfigOverrideRule,
FieldMatcherID,
DynamicConfigValue,
FieldConfigProperty,
FieldColorModeId,
} from '@grafana/data';
import { GraphFieldConfig, LegendDisplayMode } from '@grafana/ui';
import {
FillGradientMode,
AxisPlacement,
DrawStyle,
FillGradientMode,
LineInterpolation,
LineStyle,
PointVisibility,
@@ -256,15 +256,29 @@ export function flotToGraphOptions(angular: any): { fieldConfig: FieldConfigSour
legend: {
displayMode: LegendDisplayMode.List,
placement: 'bottom',
calcs: [],
},
tooltipOptions: {
mode: 'single',
},
};
if (angular.legend?.values) {
const show = getReducersFromLegend(angular.legend?.values);
console.log('Migrate Legend', show);
// Legend config migration
const legendConfig = angular.legend;
if (legendConfig) {
if (legendConfig.show) {
options.legend.displayMode = legendConfig.alignAsTable ? LegendDisplayMode.Table : LegendDisplayMode.List;
} else {
options.legend.displayMode = LegendDisplayMode.Hidden;
}
if (legendConfig.rightSide) {
options.legend.placement = 'right';
}
if (angular.legend.values) {
options.legend.calcs = getReducersFromLegend(angular.legend);
}
}
return {

View File

@@ -1,9 +1,9 @@
import { PanelPlugin } from '@grafana/data';
import { PanelPlugin, standardEditorsRegistry, StatsPickerConfigSettings } from '@grafana/data';
import { GraphFieldConfig, LegendDisplayMode } from '@grafana/ui';
import { TimeSeriesPanel } from './TimeSeriesPanel';
import { graphPanelChangedHandler } from './migrations';
import { Options } from './types';
import { getGraphFieldConfig, defaultGraphConfig } from './config';
import { defaultGraphConfig, getGraphFieldConfig } from './config';
export const plugin = new PanelPlugin<Options, GraphFieldConfig>(TimeSeriesPanel)
.setPanelChangeHandler(graphPanelChangedHandler)
@@ -48,5 +48,17 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(TimeSeriesPanel
],
},
showIf: c => c.legend.displayMode !== LegendDisplayMode.Hidden,
})
.addCustomEditor<StatsPickerConfigSettings, string[]>({
id: 'legend.calcs',
path: 'legend.calcs',
name: 'Legend calculations',
description: 'Choose a reducer functions / calculations to include in legend',
editor: standardEditorsRegistry.get('stats-picker').editor as any,
defaultValue: [],
settings: {
allowMultiple: true,
},
showIf: currentConfig => currentConfig.legend.displayMode !== LegendDisplayMode.Hidden,
});
});