Suggestions: Update all suggestions suppliers to be functions (#113986)

* Suggestions: Convert panels to use function supplier

* rework deaggregation

* BarGauge

* cleanup and make consistent the deaggregation in suggestions

* Candlestick

* Implement timeseries and clean up some things that can already be deleted

* spotted some typos in self-review

* restore PanelDataSummary deprecated fields, we wont delete till Grafana 13

* change deprecation message

* remove some unused imports

* run prettier

* update radialbar defaults logic

* update tests and logic to DRY up the reduceOptions a bit and more thoroughly test the output

* Trend: Improve suggestions

* updates from review

* add unique DataFrameType list to PanelDataSummary

* add histogram suggestions

* rework panelDataSummary to be a class, change some things

* further boil down PanelDataSummary

* Improve FlameGgraph suggestions

* geomap and other defaults

* reorder the single frame with string and number test
This commit is contained in:
Paul Marbach
2025-11-26 08:30:38 -08:00
committed by GitHub
parent ca8cad68c8
commit ebaccc781b
53 changed files with 1573 additions and 1275 deletions
@@ -381,6 +381,11 @@ export class PanelPlugin<
const appender = builder.getListAppender<TOptions, TFieldConfigOptions>({ const appender = builder.getListAppender<TOptions, TFieldConfigOptions>({
pluginId: this.meta.id, pluginId: this.meta.id,
name: this.meta.name, name: this.meta.name,
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
}); });
const result = supplier(builder.dataSummary); const result = supplier(builder.dataSummary);
@@ -1,19 +1,27 @@
import { PreferredVisualisationType } from '../../types/data'; import { PreferredVisualisationType } from '../../types/data';
import { DataFrame, FieldType } from '../../types/dataFrame'; import { DataFrame, FieldType } from '../../types/dataFrame';
import { DataFrameType } from '../../types/dataFrameTypes';
/**
* @alpha
*/
export interface PanelDataSummary { export interface PanelDataSummary {
hasData?: boolean; hasData?: boolean;
rowCountTotal: number; rowCountTotal: number;
/** max number of rows in any given dataframe in the panel data */
rowCountMax: number; rowCountMax: number;
frameCount: number; frameCount: number;
fieldCount: number; fieldCount: number;
/** max number of fields in any given dataframe in the panel data */
fieldCountMax: number;
/** given a field type, return the number of fields across all dataframes which match this type */
fieldCountByType: (type: FieldType) => number; fieldCountByType: (type: FieldType) => number;
/** returns true if any fields in any frames match the field type */
hasFieldType: (type: FieldType) => boolean; hasFieldType: (type: FieldType) => boolean;
/** The first frame that set's this value */ /* returns true if any of the frames in this panel data summary have the type */
preferredVisualisationType?: PreferredVisualisationType; hasDataFrameType: (type: DataFrameType) => boolean;
/* returns true if any of the frames in this panel data summary have the type */
hasPreferredVisualisationType: (type: PreferredVisualisationType) => boolean;
/** pass along a reference to the DataFrame array in case it's needed by the plugin */
rawFrames?: DataFrame[];
/* --- DEPRECATED FIELDS BELOW --- */ /* --- DEPRECATED FIELDS BELOW --- */
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.number) */ /** @deprecated use PanelDataSummary.fieldCountByType(FieldType.number) */
@@ -23,60 +31,114 @@ export interface PanelDataSummary {
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.string) */ /** @deprecated use PanelDataSummary.fieldCountByType(FieldType.string) */
stringFieldCount: number; stringFieldCount: number;
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.number) */ /** @deprecated use PanelDataSummary.hasFieldType(FieldType.number) */
hasNumberField?: boolean;
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */
hasTimeField?: boolean; hasTimeField?: boolean;
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */
hasNumberField?: boolean;
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.string) */ /** @deprecated use PanelDataSummary.hasFieldType(FieldType.string) */
hasStringField?: boolean; hasStringField?: boolean;
} }
/**
* @alpha
*/
class PanelDataSummaryImpl implements PanelDataSummary {
public rowCountTotal = 0;
/** max number of rows in any single dataframe in the panel data */
public rowCountMax = 0;
public fieldCount = 0;
/** max number of fields in any single dataframe in the panel data */
public fieldCountMax = 0;
private countByType: Partial<Record<FieldType, number>> = {};
private preferredVisualisationTypes: Set<PreferredVisualisationType> = new Set<PreferredVisualisationType>();
private dataFrameTypes: Set<DataFrameType> = new Set<DataFrameType>();
public get hasData(): boolean {
return this.rowCountTotal > 0;
}
public get frameCount(): number {
return this.rawFrames?.length ?? 0;
}
constructor(public rawFrames?: DataFrame[]) {
this._processFrames();
}
private _processFrames() {
for (const frame of this.rawFrames ?? []) {
this.rowCountTotal += frame.length;
if (frame.meta?.preferredVisualisationType) {
this.preferredVisualisationTypes.add(frame.meta.preferredVisualisationType);
}
if (frame.meta?.type) {
this.dataFrameTypes.add(frame.meta.type);
}
for (const field of frame.fields) {
this.fieldCount++;
this.countByType[field.type] = (this.countByType[field.type] || 0) + 1;
}
if (frame.length > this.rowCountMax) {
this.rowCountMax = frame.length;
}
if (frame.fields.length > this.fieldCountMax) {
this.fieldCountMax = frame.fields.length;
}
}
}
public fieldCountByType(type: FieldType): number {
return this.countByType[type] ?? 0;
}
public hasFieldType(type: FieldType): boolean {
return this.fieldCountByType(type) > 0;
}
public hasPreferredVisualisationType(type: PreferredVisualisationType): boolean {
return this.preferredVisualisationTypes.has(type);
}
public hasDataFrameType(type: DataFrameType): boolean {
return this.dataFrameTypes.has(type);
}
/**** DEPRECATED ****/
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.number) */
public get numberFieldCount(): number {
return this.fieldCountByType(FieldType.number);
}
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.time) */
public get timeFieldCount(): number {
return this.fieldCountByType(FieldType.time);
}
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.string) */
public get stringFieldCount() {
return this.fieldCountByType(FieldType.string);
}
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.number) */
public get hasTimeField() {
return this.fieldCountByType(FieldType.time) > 0;
}
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */
public get hasNumberField() {
return this.fieldCountByType(FieldType.number) > 0;
}
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.string) */
public get hasStringField() {
return this.fieldCountByType(FieldType.string) > 0;
}
}
/** /**
* @alpha * @alpha
* given a list of dataframes, summarize attributes of those frames for features like suggestions. * given a list of dataframes, summarize attributes of those frames for features like suggestions.
* @param frames - dataframes to summarize * @param frames - dataframes to summarize
* @returns summary of the dataframes * @returns summary of the dataframes
*/ */
export function getPanelDataSummary(frames: DataFrame[] = []): PanelDataSummary { export function getPanelDataSummary(frames?: DataFrame[]): PanelDataSummary {
let rowCountTotal = 0; return new PanelDataSummaryImpl(frames);
let rowCountMax = 0;
let fieldCount = 0;
const countByType: Partial<Record<FieldType, number>> = {};
let preferredVisualisationType: PreferredVisualisationType | undefined;
for (const frame of frames) {
rowCountTotal += frame.length;
if (frame.meta?.preferredVisualisationType) {
preferredVisualisationType = frame.meta.preferredVisualisationType;
}
for (const field of frame.fields) {
fieldCount++;
countByType[field.type] = (countByType[field.type] || 0) + 1;
}
if (frame.length > rowCountMax) {
rowCountMax = frame.length;
}
}
const fieldCountByType = (f: FieldType) => countByType[f] ?? 0;
return {
rowCountTotal,
rowCountMax,
fieldCount,
preferredVisualisationType,
frameCount: frames.length,
hasData: rowCountTotal > 0,
hasFieldType: (f: FieldType) => fieldCountByType(f) > 0,
fieldCountByType,
// deprecated
numberFieldCount: fieldCountByType(FieldType.number),
timeFieldCount: fieldCountByType(FieldType.time),
stringFieldCount: fieldCountByType(FieldType.string),
hasTimeField: fieldCountByType(FieldType.time) > 0,
hasNumberField: fieldCountByType(FieldType.number) > 0,
hasStringField: fieldCountByType(FieldType.string) > 0,
};
} }
@@ -72,7 +72,7 @@ interface IndexOptions {
asPercentile: boolean; asPercentile: boolean;
} }
const defaultReduceOptions: ReduceOptions = { const defaultNumericVizOptions: ReduceOptions = {
reducer: ReducerID.sum, reducer: ReducerID.sum,
}; };
@@ -149,10 +149,10 @@ export const calculateFieldTransformer: DataTransformerInfo<CalculateFieldTransf
switch (mode) { switch (mode) {
case CalculateFieldMode.ReduceRow: case CalculateFieldMode.ReduceRow:
creator = getReduceRowCreator(defaults(options.reduce, defaultReduceOptions), data); creator = getReduceRowCreator(defaults(options.reduce, defaultNumericVizOptions), data);
break; break;
case CalculateFieldMode.CumulativeFunctions: case CalculateFieldMode.CumulativeFunctions:
creator = getCumulativeCreator(defaults(options.cumulative, defaultReduceOptions), data); creator = getCumulativeCreator(defaults(options.cumulative, defaultNumericVizOptions), data);
break; break;
case CalculateFieldMode.WindowFunctions: case CalculateFieldMode.WindowFunctions:
creator = getWindowCreator(defaults(options.window, defaultWindowOptions), data); creator = getWindowCreator(defaults(options.window, defaultWindowOptions), data);
@@ -75,7 +75,9 @@ export interface VisualizationSuggestion<TOptions extends unknown = {}, TFieldCo
* mutate the suggestion object which is passed in as the first argument. * mutate the suggestion object which is passed in as the first argument.
*/ */
previewModifier?: (suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) => void; previewModifier?: (suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) => void;
/** @deprecated this will no longer be supported in the new Suggestions UI. */
icon?: string; icon?: string;
/** @deprecated this will no longer be supported in the new Suggestions UI. */
imgSrc?: string; imgSrc?: string;
}; };
} }
+10
View File
@@ -68,6 +68,16 @@ const defaultMatchers: LocationFieldMatchers = {
geo: (frame: DataFrame) => frame.fields.find((f) => f.type === FieldType.geo), geo: (frame: DataFrame) => frame.fields.find((f) => f.type === FieldType.geo),
}; };
/**
* suggestions needs to run sync, and we just want to use the default matchers in that situation.
*/
export function getDefaultLocationMatchers(): LocationFieldMatchers {
return {
...defaultMatchers,
mode: FrameGeometrySourceMode.Auto,
};
}
export async function getLocationMatchers(src?: FrameGeometrySource): Promise<LocationFieldMatchers> { export async function getLocationMatchers(src?: FrameGeometrySource): Promise<LocationFieldMatchers> {
const info: LocationFieldMatchers = { const info: LocationFieldMatchers = {
...defaultMatchers, ...defaultMatchers,
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import { import {
CoreApp, CoreApp,
FieldType,
getPanelDataSummary, getPanelDataSummary,
GrafanaTheme2, GrafanaTheme2,
PanelDataSummary, PanelDataSummary,
@@ -134,15 +135,15 @@ function getMessageFor(
return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data'); return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data');
} }
if (needsStringField && !dataSummary.hasStringField) { if (needsStringField && !dataSummary.hasFieldType(FieldType.string)) {
return t('panel.panel-data-error-view.missing-value.string', 'Data is missing a string field'); return t('panel.panel-data-error-view.missing-value.string', 'Data is missing a string field');
} }
if (needsNumberField && !dataSummary.hasNumberField) { if (needsNumberField && !dataSummary.hasFieldType(FieldType.number)) {
return t('panel.panel-data-error-view.missing-value.number', 'Data is missing a number field'); return t('panel.panel-data-error-view.missing-value.number', 'Data is missing a number field');
} }
if (needsTimeField && !dataSummary.hasTimeField) { if (needsTimeField && !dataSummary.hasFieldType(FieldType.time)) {
return t('panel.panel-data-error-view.missing-value.time', 'Data is missing a time field'); return t('panel.panel-data-error-view.missing-value.time', 'Data is missing a time field');
} }
@@ -5,12 +5,18 @@ import {
LoadingState, LoadingState,
PanelData, PanelData,
PanelPluginMeta, PanelPluginMeta,
toDataFrame,
PanelPluginVisualizationSuggestion, PanelPluginVisualizationSuggestion,
toDataFrame,
} from '@grafana/data'; } from '@grafana/data';
import { GraphFieldConfig, ReduceDataOptions } from '@grafana/schema'; import {
BarGaugeDisplayMode,
BigValueColorMode,
GraphFieldConfig,
ReduceDataOptions,
StackingMode,
VizOrientation,
} from '@grafana/schema';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
import { SuggestionName } from 'app/types/suggestions';
import { getAllSuggestions, panelsToCheckFirst } from './getAllSuggestions'; import { getAllSuggestions, panelsToCheckFirst } from './getAllSuggestions';
@@ -21,6 +27,8 @@ for (const pluginId of panelsToCheckFirst) {
} as PanelPluginMeta; } as PanelPluginMeta;
} }
const SCALAR_PLUGINS = ['gauge', 'stat', 'bargauge', 'piechart', 'radialbar'];
config.panels['text'] = { config.panels['text'] = {
id: 'text', id: 'text',
name: 'Text', name: 'Text',
@@ -69,7 +77,10 @@ scenario('No series', (ctx) => {
ctx.setData([]); ctx.setData([]);
it('should return correct suggestions', () => { it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel]); expect(ctx.suggestions).toEqual([
expect.objectContaining({ pluginId: 'table' }),
expect.objectContaining({ pluginId: 'text' }),
]);
}); });
}); });
@@ -84,7 +95,7 @@ scenario('No rows', (ctx) => {
]); ]);
it('should return correct suggestions', () => { it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([SuggestionName.Table]); expect(ctx.suggestions).toEqual([expect.objectContaining({ pluginId: 'table' })]);
}); });
}); });
@@ -100,33 +111,46 @@ scenario('Single frame with time and number field', (ctx) => {
it('should return correct suggestions', () => { it('should return correct suggestions', () => {
expect(ctx.suggestions).toEqual([ expect(ctx.suggestions).toEqual([
expect.objectContaining({ name: SuggestionName.LineChart }), expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart' }),
expect.objectContaining({ name: SuggestionName.LineChartSmooth }), expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart - smooth' }),
expect.objectContaining({ name: SuggestionName.AreaChart }), expect.objectContaining({ pluginId: 'timeseries', name: 'Area chart' }),
expect.objectContaining({ name: SuggestionName.LineChartGradientColorScheme }), expect.objectContaining({ pluginId: 'timeseries', name: 'Bar chart' }),
expect.objectContaining({ name: SuggestionName.BarChart }), expect.objectContaining({ pluginId: 'gauge' }),
expect.objectContaining({ name: SuggestionName.BarChartGradientColorScheme }), expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }),
expect.objectContaining({ name: SuggestionName.Gauge }), expect.objectContaining({ pluginId: 'stat' }),
expect.objectContaining({ name: SuggestionName.GaugeNoThresholds }), expect.objectContaining({
expect.objectContaining({ name: SuggestionName.Stat }), pluginId: 'stat',
expect.objectContaining({ name: SuggestionName.StatColoredBackground }), options: expect.objectContaining({ colorMode: BigValueColorMode.Background }),
expect.objectContaining({ name: SuggestionName.BarGaugeBasic }), }),
expect.objectContaining({ name: SuggestionName.BarGaugeLCD }), expect.objectContaining({
expect.objectContaining({ name: SuggestionName.Table }), pluginId: 'bargauge',
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }),
}),
expect.objectContaining({
pluginId: 'bargauge',
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }),
}),
expect.objectContaining({ pluginId: 'table' }),
expect.objectContaining({ pluginId: 'state-timeline' }), expect.objectContaining({ pluginId: 'state-timeline' }),
expect.objectContaining({ name: SuggestionName.StatusHistory }), expect.objectContaining({ pluginId: 'status-history' }),
expect.objectContaining({ pluginId: 'heatmap' }),
expect.objectContaining({ pluginId: 'histogram' }),
]); ]);
}); });
it('Bar chart suggestion should be using timeseries panel', () => { it('Bar chart suggestion should be using timeseries panel', () => {
expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)?.pluginId).toBe('timeseries'); expect(ctx.suggestions.find((x) => x.name === 'Bar chart')?.pluginId).toBe('timeseries');
}); });
it('Stat panels have reduce values disabled', () => { it('Scalar panels should use calcs', () => {
for (const suggestion of ctx.suggestions) { for (const suggestion of ctx.suggestions.filter((s) => SCALAR_PLUGINS.includes(s.pluginId))) {
if (suggestion.options?.reduceOptions?.values) { expect(suggestion).toEqual(
throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`); expect.objectContaining({
} options: expect.objectContaining({
reduceOptions: expect.objectContaining({ values: false, calcs: ['lastNotNull'] }),
}),
})
);
} }
}); });
}); });
@@ -144,31 +168,46 @@ scenario('Single frame with time 2 number fields', (ctx) => {
it('should return correct suggestions', () => { it('should return correct suggestions', () => {
expect(ctx.suggestions).toEqual([ expect(ctx.suggestions).toEqual([
expect.objectContaining({ name: SuggestionName.LineChart }), expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart' }),
expect.objectContaining({ name: SuggestionName.LineChartSmooth }), expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart - smooth' }),
expect.objectContaining({ name: SuggestionName.AreaChartStacked }), expect.objectContaining({ pluginId: 'timeseries', name: 'Area chart - stacked' }),
expect.objectContaining({ name: SuggestionName.AreaChartStackedPercent }), expect.objectContaining({ pluginId: 'timeseries', name: 'Area chart - stacked by percentage' }),
expect.objectContaining({ name: SuggestionName.BarChartStacked }), expect.objectContaining({ pluginId: 'timeseries', name: 'Bar chart - stacked' }),
expect.objectContaining({ name: SuggestionName.BarChartStackedPercent }), expect.objectContaining({ pluginId: 'timeseries', name: 'Bar chart - stacked by percentage' }),
expect.objectContaining({ name: SuggestionName.Gauge }), expect.objectContaining({ pluginId: 'gauge' }),
expect.objectContaining({ name: SuggestionName.GaugeNoThresholds }), expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }),
expect.objectContaining({ name: SuggestionName.Stat }), expect.objectContaining({ pluginId: 'stat' }),
expect.objectContaining({ name: SuggestionName.StatColoredBackground }), expect.objectContaining({
expect.objectContaining({ name: SuggestionName.PieChart }), pluginId: 'stat',
expect.objectContaining({ name: SuggestionName.PieChartDonut }), options: expect.objectContaining({ colorMode: BigValueColorMode.Background }),
expect.objectContaining({ name: SuggestionName.BarGaugeBasic }), }),
expect.objectContaining({ name: SuggestionName.BarGaugeLCD }), expect.objectContaining({ pluginId: 'piechart' }),
expect.objectContaining({ name: SuggestionName.Table }), expect.objectContaining({ pluginId: 'piechart', options: expect.objectContaining({ pieType: 'donut' }) }),
expect.objectContaining({
pluginId: 'bargauge',
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }),
}),
expect.objectContaining({
pluginId: 'bargauge',
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }),
}),
expect.objectContaining({ pluginId: 'table' }),
expect.objectContaining({ pluginId: 'state-timeline' }), expect.objectContaining({ pluginId: 'state-timeline' }),
expect.objectContaining({ name: SuggestionName.StatusHistory }), expect.objectContaining({ pluginId: 'status-history' }),
expect.objectContaining({ pluginId: 'heatmap' }),
expect.objectContaining({ pluginId: 'histogram' }),
]); ]);
}); });
it('Stat panels have reduceOptions.values disabled', () => { it('Scalar panels should use calcs', () => {
for (const suggestion of ctx.suggestions) { for (const suggestion of ctx.suggestions.filter((s) => SCALAR_PLUGINS.includes(s.pluginId))) {
if (suggestion.options?.reduceOptions?.values) { expect(suggestion).toEqual(
throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`); expect.objectContaining({
} options: expect.objectContaining({
reduceOptions: expect.objectContaining({ values: false, calcs: ['lastNotNull'] }),
}),
})
);
} }
}); });
}); });
@@ -184,7 +223,7 @@ scenario('Single time series with 100 data points', (ctx) => {
]); ]);
it('should not suggest bar chart', () => { it('should not suggest bar chart', () => {
expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)).toBe(undefined); expect(ctx.suggestions.find((x) => x.name === 'Bar chart')).toBe(undefined);
}); });
}); });
@@ -235,26 +274,42 @@ scenario('Single frame with string and number field', (ctx) => {
]); ]);
it('should return correct suggestions', () => { it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([ expect(ctx.suggestions).toEqual([
SuggestionName.BarChart, expect.objectContaining({ pluginId: 'piechart' }),
SuggestionName.BarChartHorizontal, expect.objectContaining({ pluginId: 'piechart', options: expect.objectContaining({ pieType: 'donut' }) }),
SuggestionName.Gauge, expect.objectContaining({ pluginId: 'barchart' }),
SuggestionName.GaugeNoThresholds, expect.objectContaining({
SuggestionName.Stat, pluginId: 'barchart',
SuggestionName.StatColoredBackground, options: expect.objectContaining({ orientation: VizOrientation.Horizontal }),
SuggestionName.PieChart, }),
SuggestionName.PieChartDonut, expect.objectContaining({ pluginId: 'gauge' }),
SuggestionName.BarGaugeBasic, expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }),
SuggestionName.BarGaugeLCD, expect.objectContaining({ pluginId: 'stat' }),
SuggestionName.Table, expect.objectContaining({
pluginId: 'stat',
options: expect.objectContaining({ colorMode: BigValueColorMode.Background }),
}),
expect.objectContaining({
pluginId: 'bargauge',
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }),
}),
expect.objectContaining({
pluginId: 'bargauge',
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }),
}),
expect.objectContaining({ pluginId: 'table' }),
expect.objectContaining({ pluginId: 'histogram' }),
]); ]);
}); });
it('Stat/Gauge/BarGauge/PieChart panels to have reduceOptions.values enabled', () => { it('Scalar panels should contain raw values', () => {
for (const suggestion of ctx.suggestions) { for (const suggestion of ctx.suggestions.filter((s) => SCALAR_PLUGINS.includes(s.pluginId))) {
if (suggestion.options?.reduceOptions && !suggestion.options?.reduceOptions?.values) { expect(suggestion).toEqual(
throw new Error(`Suggestion ${suggestion.name} reduce.values set to false when it should be true`); expect.objectContaining({
} options: expect.objectContaining({ reduceOptions: expect.objectContaining({ values: true, calcs: [] }) }),
})
);
} }
}); });
}); });
@@ -271,22 +326,48 @@ scenario('Single frame with string and 2 number field', (ctx) => {
]); ]);
it('should return correct suggestions', () => { it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([ expect(ctx.suggestions).toEqual([
SuggestionName.BarChart, expect.objectContaining({ pluginId: 'barchart' }),
SuggestionName.BarChartStacked, expect.objectContaining({
SuggestionName.BarChartStackedPercent, pluginId: 'barchart',
SuggestionName.BarChartHorizontal, options: expect.objectContaining({ stacking: StackingMode.Normal }),
SuggestionName.BarChartHorizontalStacked, }),
SuggestionName.BarChartHorizontalStackedPercent, expect.objectContaining({
SuggestionName.Gauge, pluginId: 'barchart',
SuggestionName.GaugeNoThresholds, options: expect.objectContaining({ stacking: StackingMode.Percent }),
SuggestionName.Stat, }),
SuggestionName.StatColoredBackground,
SuggestionName.PieChart, expect.objectContaining({
SuggestionName.PieChartDonut, pluginId: 'barchart',
SuggestionName.BarGaugeBasic, options: expect.objectContaining({ orientation: VizOrientation.Horizontal }),
SuggestionName.BarGaugeLCD, }),
SuggestionName.Table, expect.objectContaining({
pluginId: 'barchart',
options: expect.objectContaining({ orientation: VizOrientation.Horizontal, stacking: StackingMode.Normal }),
}),
expect.objectContaining({
pluginId: 'barchart',
options: expect.objectContaining({ orientation: VizOrientation.Horizontal, stacking: StackingMode.Percent }),
}),
expect.objectContaining({ pluginId: 'gauge' }),
expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }),
expect.objectContaining({ pluginId: 'stat' }),
expect.objectContaining({
pluginId: 'stat',
options: expect.objectContaining({ colorMode: BigValueColorMode.Background }),
}),
expect.objectContaining({ pluginId: 'piechart' }),
expect.objectContaining({ pluginId: 'piechart', options: expect.objectContaining({ pieType: 'donut' }) }),
expect.objectContaining({
pluginId: 'bargauge',
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }),
}),
expect.objectContaining({
pluginId: 'bargauge',
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }),
}),
expect.objectContaining({ pluginId: 'table' }),
expect.objectContaining({ pluginId: 'histogram' }),
]); ]);
}); });
}); });
@@ -299,11 +380,14 @@ scenario('Single frame with only string field', (ctx) => {
]); ]);
it('should return correct suggestions', () => { it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([SuggestionName.Stat, SuggestionName.Table]); expect(ctx.suggestions).toEqual([
expect.objectContaining({ pluginId: 'stat' }),
expect.objectContaining({ pluginId: 'table' }),
]);
}); });
it('Stat panels have reduceOptions.fields set to show all fields', () => { it('Stat panels have reduceOptions.fields set to show all fields', () => {
for (const suggestion of ctx.suggestions) { for (const suggestion of ctx.suggestions.filter((s) => s.pluginId === 'stat')) {
if (suggestion.options?.reduceOptions) { if (suggestion.options?.reduceOptions) {
expect(suggestion.options.reduceOptions.fields).toBe('/.*/'); expect(suggestion.options.reduceOptions.fields).toBe('/.*/');
} }
@@ -333,7 +417,10 @@ scenario('Given default loki logs data', (ctx) => {
]); ]);
it('should return correct suggestions', () => { it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([SuggestionName.Logs, SuggestionName.Table]); expect(ctx.suggestions).toEqual([
expect.objectContaining({ pluginId: 'logs' }),
expect.objectContaining({ pluginId: 'table' }),
]);
}); });
}); });
@@ -356,7 +443,7 @@ scenario('Given a preferredVisualisationType', (ctx) => {
]); ]);
it('should return the preferred visualization first', () => { it('should return the preferred visualization first', () => {
expect(ctx.names()[0]).toEqual(SuggestionName.Table); expect(ctx.suggestions[0]).toEqual(expect.objectContaining({ pluginId: 'table' }));
}); });
}); });
@@ -4,6 +4,7 @@ import {
VisualizationSuggestionsBuilder, VisualizationSuggestionsBuilder,
PanelModel, PanelModel,
VisualizationSuggestionScore, VisualizationSuggestionScore,
PreferredVisualisationType,
} from '@grafana/data'; } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
@@ -23,8 +24,26 @@ export const panelsToCheckFirst = [
'flamegraph', 'flamegraph',
'traces', 'traces',
'nodeGraph', 'nodeGraph',
'heatmap',
'histogram',
'geomap',
]; ];
/**
* some of the PreferredVisualisationTypes do not match the panel plugin ids, so we have to map them. d'oh.
*/
const PLUGIN_ID_TO_PREFERRED_VIZ_TYPE: Record<string, PreferredVisualisationType> = {
traces: 'trace',
timeseries: 'graph',
table: 'table',
logs: 'logs',
nodeGraph: 'nodeGraph',
flamegraph: 'flamegraph',
};
const mapPreferredVisualisationTypeToPlugin = (type: string): PreferredVisualisationType | undefined => {
return PLUGIN_ID_TO_PREFERRED_VIZ_TYPE[type];
};
export async function getAllSuggestions( export async function getAllSuggestions(
data?: PanelData, data?: PanelData,
panel?: PanelModel panel?: PanelModel
@@ -61,13 +80,13 @@ export async function getAllSuggestions(
} }
return list.sort((a, b) => { return list.sort((a, b) => {
if (builder.dataSummary.preferredVisualisationType) { const mappedA = mapPreferredVisualisationTypeToPlugin(a.pluginId);
if (a.pluginId === builder.dataSummary.preferredVisualisationType) { if (mappedA && builder.dataSummary.hasPreferredVisualisationType(mappedA)) {
return -1; return -1;
} }
if (b.pluginId === builder.dataSummary.preferredVisualisationType) { const mappedB = mapPreferredVisualisationTypeToPlugin(a.pluginId);
return 1; if (mappedB && builder.dataSummary.hasPreferredVisualisationType(mappedB)) {
} return 1;
} }
return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK); return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK);
}); });
+44 -1
View File
@@ -1,4 +1,11 @@
import { PanelData, PanelDataSummary } from '@grafana/data'; import {
DataFrameType,
PanelData,
PanelDataSummary,
VisualizationSuggestion,
VisualizationSuggestionScore,
} from '@grafana/data';
import { ReduceDataOptions } from '@grafana/schema';
/** /**
* @internal * @internal
@@ -10,6 +17,42 @@ export function showDefaultSuggestion(fn: (panelDataSummary: PanelDataSummary) =
return (panelDataSummary: PanelDataSummary) => (fn(panelDataSummary) ? [{}] : undefined); return (panelDataSummary: PanelDataSummary) => (fn(panelDataSummary) ? [{}] : undefined);
} }
/**
* @internal
* for panel plugins which render "scalar" data (stat, gauge, etc), this helper provides default reduce options
* depending on whether deaggregation is likely needed.
* @param suggestion the suggestion to modify
* @param panelDataSummary the panel data summary to use for scoring
* @param shouldUseRawValues if true, reduceOptions will be set to use raw values,
* otherwise a calcs will be used with the default value of `lastNotNull`.
*/
export function defaultNumericVizOptions(
suggestion: VisualizationSuggestion<{ reduceOptions?: ReduceDataOptions }>,
panelDataSummary: PanelDataSummary,
shouldUseRawValues: boolean
): VisualizationSuggestion {
suggestion.score =
(suggestion.score ??
(panelDataSummary.hasDataFrameType(DataFrameType.NumericLong) ||
panelDataSummary.hasDataFrameType(DataFrameType.NumericWide) ||
panelDataSummary.hasDataFrameType(DataFrameType.NumericMulti)))
? VisualizationSuggestionScore.Good
: VisualizationSuggestionScore.OK;
suggestion.options = suggestion.options ?? {};
suggestion.options.reduceOptions =
suggestion.options.reduceOptions ??
(shouldUseRawValues
? {
values: true,
calcs: [],
}
: {
values: false,
calcs: ['lastNotNull'],
});
return suggestion;
}
/** /**
* @internal * @internal
* Checks if the panel has data * Checks if the panel has data
+2 -2
View File
@@ -18,7 +18,7 @@ import { BarChartPanel } from './BarChartPanel';
import { TickSpacingEditor } from './TickSpacingEditor'; import { TickSpacingEditor } from './TickSpacingEditor';
import { changeToBarChartPanelMigrationHandler } from './migrations'; import { changeToBarChartPanelMigrationHandler } from './migrations';
import { FieldConfig, Options, defaultFieldConfig, defaultOptions } from './panelcfg.gen'; import { FieldConfig, Options, defaultFieldConfig, defaultOptions } from './panelcfg.gen';
import { BarChartSuggestionsSupplier } from './suggestions'; import { barchartSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel) export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel)
.setPanelChangeHandler(changeToBarChartPanelMigrationHandler) .setPanelChangeHandler(changeToBarChartPanelMigrationHandler)
@@ -257,7 +257,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel)
commonOptionsBuilder.addLegendOptions(builder); commonOptionsBuilder.addLegendOptions(builder);
commonOptionsBuilder.addTextSizeOptions(builder, { withValue: true }); commonOptionsBuilder.addTextSizeOptions(builder, { withValue: true });
}) })
.setSuggestionsSupplier(new BarChartSuggestionsSupplier()); .setSuggestionsSupplier(barchartSuggestionsSupplier);
function countNumberFields(data?: DataFrame[]): number { function countNumberFields(data?: DataFrame[]): number {
let count = 0; let count = 0;
@@ -1,99 +1,112 @@
import { VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data'; import { defaultsDeep } from 'lodash';
import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn, VizOrientation } from '@grafana/data';
import { t } from '@grafana/i18n';
import { LegendDisplayMode, StackingMode, VisibilityMode } from '@grafana/schema'; import { LegendDisplayMode, StackingMode, VisibilityMode } from '@grafana/schema';
import { SuggestionName } from 'app/types/suggestions';
import { FieldConfig, Options } from './panelcfg.gen'; import { FieldConfig, Options } from './panelcfg.gen';
export class BarChartSuggestionsSupplier { const withDefaults = (suggestion: VisualizationSuggestion<Options, FieldConfig>) =>
getListWithDefaults(builder: VisualizationSuggestionsBuilder) { defaultsDeep(suggestion, {
return builder.getListAppender<Options, FieldConfig>({ options: {
name: SuggestionName.BarChart, showValue: VisibilityMode.Never,
pluginId: 'barchart', legend: {
options: { calcs: [],
showValue: VisibilityMode.Never, displayMode: LegendDisplayMode.List,
legend: { showLegend: true,
calcs: [], placement: 'right',
displayMode: LegendDisplayMode.List,
showLegend: true,
placement: 'right',
},
}, },
fieldConfig: { },
defaults: { fieldConfig: {
unit: 'short', defaults: {
custom: {}, unit: 'short',
}, custom: {},
overrides: [],
}, },
cardOptions: { overrides: [],
previewModifier: (s) => { },
s.options!.barWidth = 0.8; cardOptions: {
}, previewModifier: (s) => {
s.options!.barWidth = 0.8;
s.fieldConfig!.defaults!.custom!.hideFrom = { tooltip: false, legend: true, viz: false }; // hide legend in preview
}, },
}); },
} satisfies VisualizationSuggestion<Options, FieldConfig>);
export const barchartSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, FieldConfig> = (dataSummary) => {
if (dataSummary.frameCount !== 1) {
return;
} }
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { if (!dataSummary.hasFieldType(FieldType.number) || !dataSummary.hasFieldType(FieldType.string)) {
const list = this.getListWithDefaults(builder); return;
const { dataSummary } = builder; }
if (dataSummary.frameCount !== 1) { // if you have this many rows barchart might not be a good fit
return; if (dataSummary.rowCountTotal > 50) {
} return;
}
if (!dataSummary.hasNumberField || !dataSummary.hasStringField) { const result: Array<VisualizationSuggestion<Options, FieldConfig>> = [
return; {
} name: t('barchart.suggestions.vertical', 'Bar chart'),
},
];
// if you have this many rows barchart might not be a good fit if (dataSummary.fieldCountByType(FieldType.number) > 1) {
if (dataSummary.rowCountTotal > 50) { result.push(
return; {
} name: t('barchart.suggestions.vert-stacked', 'Bar chart - stacked'),
// Vertical bars
list.append({
name: SuggestionName.BarChart,
});
if (dataSummary.numberFieldCount > 1) {
list.append({
name: SuggestionName.BarChartStacked,
options: { options: {
stacking: StackingMode.Normal, stacking: StackingMode.Normal,
}, },
}); },
list.append({ {
name: SuggestionName.BarChartStackedPercent, name: t('barchart.suggestions.vert-stacked-percent', 'Bar chart - stacked by percentage'),
options: { options: {
stacking: StackingMode.Percent, stacking: StackingMode.Percent,
}, },
}); fieldConfig: {
} overrides: [],
defaults: {
// horizontal bars unit: 'percentunit',
list.append({ },
name: SuggestionName.BarChartHorizontal,
options: {
orientation: VizOrientation.Horizontal,
},
});
if (dataSummary.numberFieldCount > 1) {
list.append({
name: SuggestionName.BarChartHorizontalStacked,
options: {
stacking: StackingMode.Normal,
orientation: VizOrientation.Horizontal,
}, },
}); }
);
}
list.append({ // horizontal bars
name: SuggestionName.BarChartHorizontalStackedPercent, result.push({
name: t('barchart.suggestions.horizontal', 'Horizontal bar chart'),
options: {
orientation: VizOrientation.Horizontal,
},
});
if (dataSummary.fieldCountByType(FieldType.number) > 1) {
result.push(
{
name: t('barchart.suggestions.hz-stacked', 'Horizontal bar chart - stacked'),
options: {
orientation: VizOrientation.Horizontal,
stacking: StackingMode.Normal,
},
},
{
name: t('barchart.suggestions.hz-stacked-percent', 'Horizontal bar chart - stacked by percentage'),
options: { options: {
orientation: VizOrientation.Horizontal, orientation: VizOrientation.Horizontal,
stacking: StackingMode.Percent, stacking: StackingMode.Percent,
}, },
}); fieldConfig: {
} overrides: [],
defaults: {
unit: 'percentunit',
},
},
}
);
} }
}
return result.map(withDefaults);
};
+2 -2
View File
@@ -8,7 +8,7 @@ import { addOrientationOption, addStandardDataReduceOptions } from '../stat/comm
import { barGaugePanelMigrationHandler } from './BarGaugeMigrations'; import { barGaugePanelMigrationHandler } from './BarGaugeMigrations';
import { BarGaugePanel } from './BarGaugePanel'; import { BarGaugePanel } from './BarGaugePanel';
import { Options, defaultOptions } from './panelcfg.gen'; import { Options, defaultOptions } from './panelcfg.gen';
import { BarGaugeSuggestionsSupplier } from './suggestions'; import { barGaugeSugggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options>(BarGaugePanel) export const plugin = new PanelPlugin<Options>(BarGaugePanel)
.useFieldConfig() .useFieldConfig()
@@ -151,4 +151,4 @@ export const plugin = new PanelPlugin<Options>(BarGaugePanel)
}) })
.setPanelChangeHandler(sharedSingleStatPanelChangedHandler) .setPanelChangeHandler(sharedSingleStatPanelChangedHandler)
.setMigrationHandler(barGaugePanelMigrationHandler) .setMigrationHandler(barGaugePanelMigrationHandler)
.setSuggestionsSupplier(new BarGaugeSuggestionsSupplier()); .setSuggestionsSupplier(barGaugeSugggestionsSupplier);
+51 -106
View File
@@ -1,115 +1,60 @@
import { FieldColorModeId, VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data'; import { defaultsDeep } from 'lodash';
import {
FieldColorModeId,
FieldType,
VisualizationSuggestion,
VisualizationSuggestionsSupplierFn,
VizOrientation,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { BarGaugeDisplayMode } from '@grafana/ui'; import { BarGaugeDisplayMode } from '@grafana/ui';
import { SuggestionName } from 'app/types/suggestions'; import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
import { Options } from './panelcfg.gen'; import { Options } from './panelcfg.gen';
export class BarGaugeSuggestionsSupplier { const withDefaults = (suggestion: VisualizationSuggestion<Options>): VisualizationSuggestion<Options> =>
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { defaultsDeep(suggestion, {
const { dataSummary } = builder; options: {
displayMode: BarGaugeDisplayMode.Basic,
if (!dataSummary.hasData || !dataSummary.hasNumberField) { orientation: VizOrientation.Horizontal,
return; },
} fieldConfig: {
defaults: {
const list = builder.getListAppender<Options, {}>({ color: {
name: '', mode: FieldColorModeId.ContinuousGrYlRd,
pluginId: 'bargauge',
options: {},
fieldConfig: {
defaults: {
custom: {},
}, },
overrides: [],
}, },
}); overrides: [],
},
});
// This is probably not a good option for many numeric fields const BAR_LIMIT = 30;
if (dataSummary.numberFieldCount > 50) {
return;
}
// To use show individual row values we also need a string field to give each value a name export const barGaugeSugggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 30) { if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
list.append({ return;
name: SuggestionName.BarGaugeBasic,
options: {
reduceOptions: {
values: true,
calcs: [],
},
displayMode: BarGaugeDisplayMode.Basic,
orientation: VizOrientation.Horizontal,
},
fieldConfig: {
defaults: {
color: {
mode: FieldColorModeId.ContinuousGrYlRd,
},
},
overrides: [],
},
});
list.append({
name: SuggestionName.BarGaugeLCD,
options: {
reduceOptions: {
values: true,
calcs: [],
},
displayMode: BarGaugeDisplayMode.Lcd,
orientation: VizOrientation.Horizontal,
},
fieldConfig: {
defaults: {
color: {
mode: FieldColorModeId.ContinuousGrYlRd,
},
},
overrides: [],
},
});
} else {
list.append({
name: SuggestionName.BarGaugeBasic,
options: {
displayMode: BarGaugeDisplayMode.Basic,
orientation: VizOrientation.Horizontal,
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
},
fieldConfig: {
defaults: {
color: {
mode: FieldColorModeId.ContinuousGrYlRd,
},
},
overrides: [],
},
});
list.append({
name: SuggestionName.BarGaugeLCD,
options: {
displayMode: BarGaugeDisplayMode.Lcd,
orientation: VizOrientation.Horizontal,
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
},
fieldConfig: {
defaults: {
color: {
mode: FieldColorModeId.ContinuousGrYlRd,
},
},
overrides: [],
},
});
}
} }
}
// This is probably not a good option for many numeric fields
if (dataSummary.fieldCountByType(FieldType.number) > BAR_LIMIT) {
return;
}
const suggestions: Array<VisualizationSuggestion<Options>> = [
{ name: t('bargauge.suggestions.basic', 'Bar gauge') },
{
name: t('bargauge.suggestions.lcd', 'Bar gauge - LCD'),
options: {
displayMode: BarGaugeDisplayMode.Lcd,
},
},
];
const shouldUseRawValues =
dataSummary.hasFieldType(FieldType.string) &&
dataSummary.frameCount === 1 &&
dataSummary.rowCountTotal <= BAR_LIMIT;
return suggestions.map((s) => defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues));
};
@@ -8,7 +8,7 @@ import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config';
import { CandlestickPanel } from './CandlestickPanel'; import { CandlestickPanel } from './CandlestickPanel';
import { CandlestickData, getCandlestickFieldsInfo, FieldPickerInfo, prepareCandlestickFields } from './fields'; import { CandlestickData, getCandlestickFieldsInfo, FieldPickerInfo, prepareCandlestickFields } from './fields';
import { CandlestickSuggestionsSupplier } from './suggestions'; import { candlestickSuggestionSupplier } from './suggestions';
import { defaultCandlestickColors, defaultOptions, Options, VizDisplayMode, ColorStrategy, CandleStyle } from './types'; import { defaultCandlestickColors, defaultOptions, Options, VizDisplayMode, ColorStrategy, CandleStyle } from './types';
const numericFieldFilter = (f: Field) => f.type === FieldType.number; const numericFieldFilter = (f: Field) => f.type === FieldType.number;
@@ -147,4 +147,4 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(CandlestickPane
commonOptionsBuilder.addLegendOptions(builder); commonOptionsBuilder.addLegendOptions(builder);
}) })
.setDataSupport({ annotations: true, alertStates: true }) .setDataSupport({ annotations: true, alertStates: true })
.setSuggestionsSupplier(new CandlestickSuggestionsSupplier()); .setSuggestionsSupplier(candlestickSuggestionSupplier);
@@ -1,54 +1,29 @@
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data'; import { FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { SuggestionName } from 'app/types/suggestions';
import { prepareCandlestickFields } from './fields'; import { prepareCandlestickFields } from './fields';
import { defaultOptions, Options } from './types'; import { defaultOptions, Options } from './types';
export class CandlestickSuggestionsSupplier { export const candlestickSuggestionSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { if (
const { dataSummary } = builder; !dataSummary.rawFrames ||
!dataSummary.hasData ||
if ( dataSummary.fieldCountByType(FieldType.time) < 1 ||
!builder.data?.series || dataSummary.fieldCountByType(FieldType.number) < 2 ||
!dataSummary.hasData || dataSummary.fieldCountByType(FieldType.number) > 10
dataSummary.timeFieldCount < 1 || ) {
dataSummary.numberFieldCount < 2 || return;
dataSummary.numberFieldCount > 10
) {
return;
}
const info = prepareCandlestickFields(builder.data.series, defaultOptions, config.theme2);
if (!info) {
return;
}
// Regular timeseries
if (info.open === info.high && info.open === info.low) {
return;
}
const list = builder.getListAppender<Options, {}>({
name: '',
pluginId: 'candlestick',
options: {},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
});
list.append({
name: SuggestionName.Candlestick,
options: defaultOptions,
fieldConfig: {
defaults: {},
overrides: [],
},
score: info.autoOpenClose ? VisualizationSuggestionScore.Good : VisualizationSuggestionScore.Best,
});
} }
}
const info = prepareCandlestickFields(dataSummary.rawFrames, defaultOptions, config.theme2);
if (!info) {
return;
}
// Regular timeseries
if (info.open === info.high && info.open === info.low) {
return;
}
return [{ score: info.autoOpenClose ? VisualizationSuggestionScore.Good : VisualizationSuggestionScore.Best }];
};
@@ -2,6 +2,8 @@ import { CoreApp, PanelProps } from '@grafana/data';
import { FlameGraph, checkFields, getMessageCheckFieldsResult } from '@grafana/flamegraph'; import { FlameGraph, checkFields, getMessageCheckFieldsResult } from '@grafana/flamegraph';
import { PanelDataErrorView, reportInteraction, config } from '@grafana/runtime'; import { PanelDataErrorView, reportInteraction, config } from '@grafana/runtime';
import { Options } from './types';
function interaction(name: string, context: Record<string, string | number> = {}) { function interaction(name: string, context: Record<string, string | number> = {}) {
reportInteraction(`grafana_flamegraph_${name}`, { reportInteraction(`grafana_flamegraph_${name}`, {
app: CoreApp.Unknown, app: CoreApp.Unknown,
@@ -10,7 +12,7 @@ function interaction(name: string, context: Record<string, string | number> = {}
}); });
} }
export const FlameGraphPanel = (props: PanelProps) => { export const FlameGraphPanel = (props: PanelProps<Options>) => {
const wrongFields = checkFields(props.data.series[0]); const wrongFields = checkFields(props.data.series[0]);
if (wrongFields) { if (wrongFields) {
return ( return (
+20 -3
View File
@@ -1,12 +1,29 @@
import { FieldConfigProperty, PanelPlugin } from '@grafana/data'; import { FieldConfigProperty, PanelPlugin } from '@grafana/data';
import { checkFields } from '@grafana/flamegraph';
import { FlameGraphPanel } from './FlameGraphPanel'; import { FlameGraphPanel } from './FlameGraphPanel';
import { FlameGraphSuggestionsSupplier } from './suggestions'; import { Options } from './types';
const flamegraphConfigOptions = [FieldConfigProperty.Unit, FieldConfigProperty.Decimals]; const flamegraphConfigOptions = [FieldConfigProperty.Unit, FieldConfigProperty.Decimals];
export const plugin = new PanelPlugin(FlameGraphPanel) export const plugin = new PanelPlugin<Options>(FlameGraphPanel)
.setSuggestionsSupplier(new FlameGraphSuggestionsSupplier()) // check that the first frame of the data has the required fields for a flamegraph
.setSuggestionsSupplier((ds) => {
if (!ds.rawFrames?.some((frame) => checkFields(frame) === undefined)) {
return;
}
return [
{
cardOptions: {
previewModifier: (s) => {
s.options = s.options || {};
s.options.showFlameGraphOnly = true;
},
},
},
];
})
.useFieldConfig({ .useFieldConfig({
disableStandardOptions: Object.values(FieldConfigProperty).filter((v) => !flamegraphConfigOptions.includes(v)), disableStandardOptions: Object.values(FieldConfigProperty).filter((v) => !flamegraphConfigOptions.includes(v)),
}); });
@@ -1,31 +0,0 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data';
import { checkFields } from '@grafana/flamegraph';
import { SuggestionName } from 'app/types/suggestions';
export class FlameGraphSuggestionsSupplier {
getListWithDefaults(builder: VisualizationSuggestionsBuilder) {
return builder.getListAppender<{}, {}>({
name: SuggestionName.FlameGraph,
pluginId: 'flamegraph',
});
}
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
if (!builder.data) {
return;
}
const dataFrame = builder.data.series[0];
if (!dataFrame) {
return;
}
const wrongFields = checkFields(dataFrame);
if (wrongFields) {
return;
}
this.getListWithDefaults(builder).append({
name: SuggestionName.FlameGraph,
});
}
}
@@ -0,0 +1,3 @@
export interface Options {
showFlameGraphOnly?: boolean;
}
+2 -2
View File
@@ -8,7 +8,7 @@ import { addOrientationOption, addStandardDataReduceOptions } from '../stat/comm
import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations'; import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations';
import { GaugePanel } from './GaugePanel'; import { GaugePanel } from './GaugePanel';
import { Options, defaultOptions } from './panelcfg.gen'; import { Options, defaultOptions } from './panelcfg.gen';
import { GaugeSuggestionsSupplier } from './suggestions'; import { gaugeSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options>(GaugePanel) export const plugin = new PanelPlugin<Options>(GaugePanel)
.useFieldConfig({ .useFieldConfig({
@@ -88,5 +88,5 @@ export const plugin = new PanelPlugin<Options>(GaugePanel)
commonOptionsBuilder.addTextSizeOptions(builder, { withTitle: true, withValue: true }); commonOptionsBuilder.addTextSizeOptions(builder, { withTitle: true, withValue: true });
}) })
.setPanelChangeHandler(gaugePanelChangedHandler) .setPanelChangeHandler(gaugePanelChangedHandler)
.setSuggestionsSupplier(new GaugeSuggestionsSupplier()) .setSuggestionsSupplier(gaugeSuggestionsSupplier)
.setMigrationHandler(gaugePanelMigrationHandler); .setMigrationHandler(gaugePanelMigrationHandler);
+55 -81
View File
@@ -1,89 +1,63 @@
import { ThresholdsMode, VisualizationSuggestionsBuilder } from '@grafana/data'; import { defaultsDeep } from 'lodash';
import { GraphFieldConfig } from '@grafana/ui';
import { SuggestionName } from 'app/types/suggestions'; import { ThresholdsMode, FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { t } from '@grafana/i18n';
import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
import { Options } from './panelcfg.gen'; import { Options } from './panelcfg.gen';
export class GaugeSuggestionsSupplier { const withDefaults = (suggestion: VisualizationSuggestion<Options>): VisualizationSuggestion<Options> =>
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { defaultsDeep(suggestion, {
const { dataSummary } = builder; fieldConfig: {
defaults: {
if (!dataSummary.hasData || !dataSummary.hasNumberField) { thresholds: {
return; steps: [
} { value: -Infinity, color: 'green' },
{ value: 70, color: 'orange' },
// for many fields / series this is probably not a good fit { value: 85, color: 'red' },
if (dataSummary.numberFieldCount >= 50) { ],
return; mode: ThresholdsMode.Percentage,
}
const list = builder.getListAppender<Options, GraphFieldConfig>({
name: SuggestionName.Gauge,
pluginId: 'gauge',
options: {},
fieldConfig: {
defaults: {
thresholds: {
steps: [
{ value: -Infinity, color: 'green' },
{ value: 70, color: 'orange' },
{ value: 85, color: 'red' },
],
mode: ThresholdsMode.Percentage,
},
custom: {},
}, },
overrides: [], custom: {},
}, },
cardOptions: { overrides: [],
previewModifier: (s) => { },
if (s.options?.reduceOptions?.values) { cardOptions: {
s.options.reduceOptions.limit = 2; previewModifier: (s) => {
} if (s.options?.reduceOptions?.values) {
}, s.options.reduceOptions.limit = 2;
}
}, },
}); },
} satisfies VisualizationSuggestion<Options>);
if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10) { const GAUGE_LIMIT = 10;
list.append({
name: SuggestionName.Gauge, export const gaugeSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
options: { if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
reduceOptions: { return;
values: true,
calcs: [],
},
},
});
list.append({
name: SuggestionName.GaugeNoThresholds,
options: {
reduceOptions: {
values: true,
calcs: [],
},
showThresholdMarkers: false,
},
});
} else {
list.append({
name: SuggestionName.Gauge,
options: {
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
},
});
list.append({
name: SuggestionName.GaugeNoThresholds,
options: {
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
showThresholdMarkers: false,
},
});
}
} }
}
// for many fields / series this is probably not a good fit
if (dataSummary.fieldCountByType(FieldType.number) > GAUGE_LIMIT) {
return;
}
const suggestions: Array<VisualizationSuggestion<Options>> = [
{ name: t('gauge.suggestions.arc', 'Gauge') },
{
name: t('gauge.suggestions.no-thresholds', 'Gauge - no thresholds'),
options: {
showThresholdMarkers: false,
},
},
];
// sometimes, we want to de-aggregate the data for the gauge suggestion
const shouldUseRawValues =
dataSummary.hasFieldType(FieldType.string) &&
dataSummary.frameCount === 1 &&
dataSummary.rowCountTotal <= GAUGE_LIMIT;
return suggestions.map((s) => defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues));
};
+3 -1
View File
@@ -8,6 +8,7 @@ import { LayersEditor } from './editor/LayersEditor';
import { MapViewEditor } from './editor/MapViewEditor'; import { MapViewEditor } from './editor/MapViewEditor';
import { getLayerEditor } from './editor/layerEditor'; import { getLayerEditor } from './editor/layerEditor';
import { mapPanelChangedHandler, mapMigrationHandler } from './migrations'; import { mapPanelChangedHandler, mapMigrationHandler } from './migrations';
import { geomapSuggestionsSupplier } from './suggestions';
import { defaultMapViewConfig, Options, TooltipMode, GeomapInstanceState } from './types'; import { defaultMapViewConfig, Options, TooltipMode, GeomapInstanceState } from './types';
export const plugin = new PanelPlugin<Options>(GeomapPanel) export const plugin = new PanelPlugin<Options>(GeomapPanel)
@@ -171,4 +172,5 @@ export const plugin = new PanelPlugin<Options>(GeomapPanel)
], ],
}, },
}); });
}); })
.setSuggestionsSupplier(geomapSuggestionsSupplier);
@@ -0,0 +1,46 @@
import { VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { GraphFieldConfig } from '@grafana/ui';
import { getGeometryField, getDefaultLocationMatchers } from 'app/features/geo/utils/location';
import { Options } from './panelcfg.gen';
export const geomapSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
dataSummary
) => {
if (!dataSummary.hasData || !dataSummary.rawFrames) {
return;
}
// use getGeometryField to see if any frames have geolocation info
const location = getDefaultLocationMatchers();
if (!dataSummary.rawFrames.some((frame) => !getGeometryField(frame, location).warning)) {
return;
}
return [
{
score: VisualizationSuggestionScore.Best,
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
cardOptions: {
previewModifier: (s) => {
s.options!.controls = {
showZoom: false,
showScale: false,
showAttribution: false,
showMeasure: false,
};
// FIXME: this doesn't work. I want to disable legends in the preview.
s.options?.layers?.forEach((layer) => {
layer.config = layer.config || {};
layer.config.showLegend = false;
});
},
},
},
];
};
+2 -2
View File
@@ -18,7 +18,7 @@ import { HeatmapPanel } from './HeatmapPanel';
import { prepareHeatmapData } from './fields'; import { prepareHeatmapData } from './fields';
import { heatmapChangedHandler, heatmapMigrationHandler } from './migrations'; import { heatmapChangedHandler, heatmapMigrationHandler } from './migrations';
import { colorSchemes, quantizeScheme } from './palettes'; import { colorSchemes, quantizeScheme } from './palettes';
import { HeatmapSuggestionsSupplier } from './suggestions'; import { heatmapSuggestionsSupplier } from './suggestions';
import { Options, defaultOptions, HeatmapColorMode, HeatmapColorScale } from './types'; import { Options, defaultOptions, HeatmapColorMode, HeatmapColorScale } from './types';
export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel) export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
@@ -472,5 +472,5 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
annotations?.some((df) => df.meta?.custom?.resultType === 'exemplar'), annotations?.some((df) => df.meta?.custom?.resultType === 'exemplar'),
}); });
}) })
.setSuggestionsSupplier(new HeatmapSuggestionsSupplier()) .setSuggestionsSupplier(heatmapSuggestionsSupplier)
.setDataSupport({ annotations: true }); .setDataSupport({ annotations: true });
@@ -0,0 +1,226 @@
import {
createDataFrame,
DataFrameType,
FieldType,
getPanelDataSummary,
VisualizationSuggestionScore,
} from '@grafana/data';
import { heatmapSuggestionsSupplier } from './suggestions';
describe('heatmap suggestions', () => {
describe('applicability', () => {
it('should not suggest for data without time field', () => {
const dataSummary = getPanelDataSummary([
createDataFrame({
fields: [
{ name: 'value1', type: FieldType.number, values: [1, 2, 3] },
{ name: 'value2', type: FieldType.number, values: [4, 5, 6] },
],
}),
]);
const suggestions = heatmapSuggestionsSupplier(dataSummary);
expect(suggestions).toBeUndefined();
});
it('should suggest for data with time and number fields', () => {
const dataSummary = getPanelDataSummary([
createDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1609459200000, 1609462800000, 1609466400000] },
{ name: 'value1', type: FieldType.number, values: [1, 2, 3] },
{ name: 'value2', type: FieldType.number, values: [4, 5, 6] },
],
}),
]);
const suggestions = heatmapSuggestionsSupplier(dataSummary);
expect(suggestions).toHaveLength(1);
});
});
describe('scoring', () => {
it('should score this as "OK" if the data is not particularly heatmap-y', () => {
const dataSummary = getPanelDataSummary([
createDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1609459200000, 1609462800000, 1609466400000] },
{ name: 'value1', type: FieldType.number, values: [1, 2, 3] },
{ name: 'value2', type: FieldType.number, values: [4, 5, 6] },
],
}),
]);
const suggestions = heatmapSuggestionsSupplier(dataSummary);
expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.OK })]);
});
it.each([DataFrameType.HeatmapRows, DataFrameType.HeatmapCells])(
'should score this as "Best" if the data explicitly has %s frame type',
(frameType: DataFrameType) => {
const dataSummary = getPanelDataSummary([
createDataFrame({
meta: { type: frameType },
fields: [
{
name: 'time',
type: FieldType.time,
values: [1609459200000, 1609462800000, 1609466400000],
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: 'value1',
type: FieldType.number,
values: [1, 2, 3],
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: 'value2',
type: FieldType.number,
values: [4, 5, 6],
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: 'value3',
type: FieldType.number,
values: [7, 8, 9],
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
],
}),
]);
const suggestions = heatmapSuggestionsSupplier(dataSummary);
expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]);
}
);
it('should score this as "Best" if the data has "ge" labels', () => {
const dataSummary = getPanelDataSummary([
createDataFrame({
fields: [
{
name: 'time',
type: FieldType.time,
values: [1609459200000, 1609462800000, 1609466400000],
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: 'value1',
type: FieldType.number,
values: [1, 2, 3],
labels: { ge: '-Inf' },
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: 'value2',
type: FieldType.number,
values: [4, 5, 6],
labels: { ge: '0' },
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: 'value3',
type: FieldType.number,
values: [7, 8, 9],
labels: { ge: '10' },
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
],
}),
]);
const suggestions = heatmapSuggestionsSupplier(dataSummary);
expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]);
});
it('should score this as "Best" if the data has "le" labels', () => {
const dataSummary = getPanelDataSummary([
createDataFrame({
fields: [
{
name: 'time',
type: FieldType.time,
values: [1609459200000, 1609462800000, 1609466400000],
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: 'value1',
type: FieldType.number,
values: [1, 2, 3],
labels: { le: '1' },
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: 'value2',
type: FieldType.number,
values: [4, 5, 6],
labels: { le: '2' },
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: 'value2',
type: FieldType.number,
values: [6, 2, 6],
labels: { le: '4' },
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: 'value3',
type: FieldType.number,
values: [7, 8, 9],
labels: { le: 'Inf' },
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
],
}),
]);
const suggestions = heatmapSuggestionsSupplier(dataSummary);
expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]);
});
it('should score this as "Best" if the field names are numeric in a way that makes sense', () => {
const dataSummary = getPanelDataSummary([
createDataFrame({
fields: [
{
name: 'time',
type: FieldType.time,
values: [1609459200000, 1609462800000, 1609466400000],
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: '0',
type: FieldType.number,
values: [1, 2, 3],
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: '10',
type: FieldType.number,
values: [4, 5, 6],
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: '20',
type: FieldType.number,
values: [7, 8, 9],
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
{
name: 'Inf',
type: FieldType.number,
values: [10, 9, 8],
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
},
],
}),
]);
const suggestions = heatmapSuggestionsSupplier(dataSummary);
expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]);
});
});
});
+65 -37
View File
@@ -1,45 +1,73 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data'; import {
DataFrameType,
FieldType,
PanelDataSummary,
VisualizationSuggestionScore,
VisualizationSuggestionsSupplierFn,
} from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { GraphFieldConfig } from '@grafana/schema';
import { prepareHeatmapData } from './fields'; import { prepareHeatmapData } from './fields';
import { quantizeScheme } from './palettes'; import { quantizeScheme } from './palettes';
import { Options, defaultOptions } from './types'; import { Options, defaultOptions } from './types';
export class HeatmapSuggestionsSupplier { function determineScore(dataSummary: PanelDataSummary): VisualizationSuggestionScore {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { // look to see if the data has an explicity marker for heatmap data on it.
const { dataSummary } = builder; if ([DataFrameType.HeatmapRows, DataFrameType.HeatmapCells].some((t) => dataSummary.hasDataFrameType(t))) {
return VisualizationSuggestionScore.Best;
if (
!builder.data?.series ||
!dataSummary.hasData ||
dataSummary.timeFieldCount < 1 ||
dataSummary.numberFieldCount < 2 ||
dataSummary.numberFieldCount > 10
) {
return;
}
const palette = quantizeScheme(defaultOptions.color, config.theme2);
const info = prepareHeatmapData({
frames: builder.data.series,
options: defaultOptions,
palette,
theme: config.theme2,
});
if (!info || info.warning) {
return;
}
builder.getListAppender<Options, {}>({
name: '',
pluginId: 'heatmap',
options: {},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
});
} }
// we'll also look more closely at frames which return between 3 and 10 numeric fields.
if (dataSummary.fieldCountByType(FieldType.number) > 2 || dataSummary.fieldCountByType(FieldType.number) <= 10) {
// look through the names of the panels
const hasPotentialHeatmapSeries = dataSummary.rawFrames!.some((frame) => {
for (const field of frame.fields) {
if (field.type === FieldType.number) {
// if the field name, or "ge" or "le" label on the field, are numeric, then it's very possibly part of a heatmap.
if ([field.name, field.labels?.ge, field.labels?.le].some((v) => !isNaN(Number(v)))) {
return true;
}
}
}
return false;
});
// if at least all but 1 of the numeric fields in the frame have numeric names, then this is probably a heatmap.
// (the out-of-place one would be "Inf" or "-Inf")
if (hasPotentialHeatmapSeries) {
return VisualizationSuggestionScore.Best;
}
}
return VisualizationSuggestionScore.OK;
} }
export const heatmapSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
dataSummary: PanelDataSummary
) => {
if (
!dataSummary.rawFrames ||
!dataSummary.hasData ||
!dataSummary.hasFieldType(FieldType.time) ||
!dataSummary.hasFieldType(FieldType.number)
) {
return;
}
// parse the frame into a heatmap structure to see if it's possible.
const palette = quantizeScheme(defaultOptions.color, config.theme2);
const info = prepareHeatmapData({
frames: dataSummary.rawFrames,
options: defaultOptions,
palette,
theme: config.theme2,
});
// if we can't parse the data into a heatmap, then bail out and prevent showing suggestions.
if (!info || info.warning) {
return;
}
return [{ score: determineScore(dataSummary) }];
};
@@ -5,6 +5,9 @@ import {
identityOverrideProcessor, identityOverrideProcessor,
PanelPlugin, PanelPlugin,
histogramFieldInfo, histogramFieldInfo,
buildHistogram,
VisualizationSuggestionScore,
DataFrameType,
} from '@grafana/data'; } from '@grafana/data';
import { t } from '@grafana/i18n'; import { t } from '@grafana/i18n';
import { commonOptionsBuilder, getGraphFieldOptions } from '@grafana/ui'; import { commonOptionsBuilder, getGraphFieldOptions } from '@grafana/ui';
@@ -149,4 +152,16 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(HistogramPanel)
commonOptionsBuilder.addHideFrom(builder); commonOptionsBuilder.addHideFrom(builder);
}, },
})
.setSuggestionsSupplier((ds) => {
if (ds.rawFrames && ds.hasData && buildHistogram(ds.rawFrames)) {
return [
{
score: ds.hasDataFrameType(DataFrameType.Histogram)
? VisualizationSuggestionScore.Best
: VisualizationSuggestionScore.OK,
},
];
}
return;
}); });
+5 -3
View File
@@ -1,10 +1,10 @@
import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription } from '@grafana/data'; import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription, FieldType } from '@grafana/data';
import { t } from '@grafana/i18n'; import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { showDefaultSuggestion } from 'app/features/panel/suggestions/utils';
import { LogsPanel } from './LogsPanel'; import { LogsPanel } from './LogsPanel';
import { Options } from './panelcfg.gen'; import { Options } from './panelcfg.gen';
import { LogsPanelSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options>(LogsPanel) export const plugin = new PanelPlugin<Options>(LogsPanel)
.setPanelOptions((builder, context) => { .setPanelOptions((builder, context) => {
@@ -207,4 +207,6 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
defaultValue: LogsSortOrder.Descending, defaultValue: LogsSortOrder.Descending,
}); });
}) })
.setSuggestionsSupplier(new LogsPanelSuggestionsSupplier()); .setSuggestionsSupplier(
showDefaultSuggestion((ds) => ds.hasData && ds.hasFieldType(FieldType.time) && ds.hasFieldType(FieldType.string))
);
@@ -1,33 +0,0 @@
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data';
import { SuggestionName } from 'app/types/suggestions';
import { Options } from './panelcfg.gen';
export class LogsPanelSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const list = builder.getListAppender<Options, {}>({
name: '',
pluginId: 'logs',
options: {},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
});
const { dataSummary: ds } = builder;
// Require a string & time field
if (!ds.hasData || !ds.hasTimeField || !ds.hasStringField) {
return;
}
if (ds.preferredVisualisationType === 'logs') {
list.append({ name: SuggestionName.Logs, score: VisualizationSuggestionScore.Best });
} else {
list.append({ name: SuggestionName.Logs });
}
}
}
@@ -4,7 +4,7 @@ import { t } from '@grafana/i18n';
import { NodeGraphPanel } from './NodeGraphPanel'; import { NodeGraphPanel } from './NodeGraphPanel';
import { ArcOptionsEditor } from './editor/ArcOptionsEditor'; import { ArcOptionsEditor } from './editor/ArcOptionsEditor';
import { LayoutAlgorithm, Options as NodeGraphOptions } from './panelcfg.gen'; import { LayoutAlgorithm, Options as NodeGraphOptions } from './panelcfg.gen';
import { NodeGraphSuggestionsSupplier } from './suggestions'; import { nodeGraphSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel) export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel)
.useFieldConfig({ .useFieldConfig({
@@ -92,4 +92,4 @@ export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel)
}, },
}); });
}) })
.setSuggestionsSupplier(new NodeGraphSuggestionsSupplier()); .setSuggestionsSupplier(nodeGraphSuggestionsSupplier);
@@ -1,71 +1,60 @@
import { DataFrame, FieldType, VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data'; import { DataFrame, FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { SuggestionName } from 'app/types/suggestions';
export class NodeGraphSuggestionsSupplier { import { Options } from './panelcfg.gen';
getListWithDefaults(builder: VisualizationSuggestionsBuilder) {
return builder.getListAppender<{}, {}>({
name: SuggestionName.NodeGraph,
pluginId: 'nodeGraph',
});
}
hasCorrectFields(frames: DataFrame[]): boolean { function checkFields(fields: Array<[string, FieldType]>, frame: DataFrame): boolean {
let hasNodesFrame = false; let hasCorrectFields = true;
let hasEdgesFrame = false;
const nodeFields: Array<[string, FieldType]> = [ for (const field of fields) {
['id', FieldType.string], const [name, type] = field;
['title', FieldType.string], const frameField = frame.fields.find((f) => f.name === name);
['mainstat', FieldType.number], if (!frameField || type !== frameField.type) {
]; hasCorrectFields = false;
const edgeFields: Array<[string, FieldType]> = [ break;
['id', FieldType.string],
['source', FieldType.string],
['target', FieldType.string],
];
for (const frame of frames) {
if (this.checkFields(nodeFields, frame)) {
hasNodesFrame = true;
}
if (this.checkFields(edgeFields, frame)) {
hasEdgesFrame = true;
}
}
return hasNodesFrame && hasEdgesFrame;
}
checkFields(fields: Array<[string, FieldType]>, frame: DataFrame): boolean {
let hasCorrectFields = true;
for (const field of fields) {
const [name, type] = field;
const frameField = frame.fields.find((f) => f.name === name);
if (!frameField || type !== frameField.type) {
hasCorrectFields = false;
break;
}
}
return hasCorrectFields;
}
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
if (!builder.data) {
return;
}
const hasCorrectFields = this.hasCorrectFields(builder.data.series);
const nodeGraphFrames = builder.data.series.filter(
(df) => df.meta && df.meta.preferredVisualisationType === 'nodeGraph'
);
if (hasCorrectFields || nodeGraphFrames.length === 2) {
this.getListWithDefaults(builder).append({
name: SuggestionName.NodeGraph,
score: VisualizationSuggestionScore.Best,
});
} }
} }
return hasCorrectFields;
} }
function frameHasCorrectFields(frames: DataFrame[]): boolean {
let hasNodesFrame = false;
let hasEdgesFrame = false;
const nodeFields: Array<[string, FieldType]> = [
['id', FieldType.string],
['title', FieldType.string],
['mainstat', FieldType.number],
];
const edgeFields: Array<[string, FieldType]> = [
['id', FieldType.string],
['source', FieldType.string],
['target', FieldType.string],
];
for (const frame of frames) {
if (checkFields(nodeFields, frame)) {
hasNodesFrame = true;
}
if (checkFields(edgeFields, frame)) {
hasEdgesFrame = true;
}
}
return hasNodesFrame && hasEdgesFrame;
}
export const nodeGraphSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
if (!dataSummary.rawFrames) {
return;
}
const hasCorrectFields = frameHasCorrectFields(dataSummary.rawFrames);
const nodeGraphFrames = dataSummary.hasPreferredVisualisationType('nodeGraph');
if (!hasCorrectFields && !nodeGraphFrames) {
return;
}
return [{ score: VisualizationSuggestionScore.Best }];
};
+2 -2
View File
@@ -9,7 +9,7 @@ import { addStandardDataReduceOptions } from '../stat/common';
import { PieChartPanel } from './PieChartPanel'; import { PieChartPanel } from './PieChartPanel';
import { PieChartPanelChangedHandler } from './migrations'; import { PieChartPanelChangedHandler } from './migrations';
import { Options, FieldConfig, PieChartType, PieChartLabels, PieChartLegendValues } from './panelcfg.gen'; import { Options, FieldConfig, PieChartType, PieChartLabels, PieChartLegendValues } from './panelcfg.gen';
import { PieChartSuggestionsSupplier } from './suggestions'; import { piechartSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options, FieldConfig>(PieChartPanel) export const plugin = new PanelPlugin<Options, FieldConfig>(PieChartPanel)
.setPanelChangeHandler(PieChartPanelChangedHandler) .setPanelChangeHandler(PieChartPanelChangedHandler)
@@ -92,4 +92,4 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(PieChartPanel)
showIf: (c) => c.legend.showLegend !== false, showIf: (c) => c.legend.showLegend !== false,
}); });
}) })
.setSuggestionsSupplier(new PieChartSuggestionsSupplier()); .setSuggestionsSupplier(piechartSuggestionsSupplier);
@@ -1,79 +1,78 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data'; import { defaultsDeep } from 'lodash';
import {
FieldType,
VisualizationSuggestion,
VisualizationSuggestionScore,
VisualizationSuggestionsSupplierFn,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { LegendDisplayMode } from '@grafana/schema'; import { LegendDisplayMode } from '@grafana/schema';
import { SuggestionName } from 'app/types/suggestions'; import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
import { PieChartLabels, Options, PieChartType } from './panelcfg.gen'; import { PieChartLabels, Options, PieChartType } from './panelcfg.gen';
export class PieChartSuggestionsSupplier { const withDefaults = (suggestion: VisualizationSuggestion<Options>): VisualizationSuggestion<Options> =>
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { defaultsDeep(suggestion, {
const list = builder.getListAppender<Options, {}>({ options: {
name: SuggestionName.PieChart, displayLabels: [PieChartLabels.Percent],
pluginId: 'piechart', legend: {
options: { calcs: [],
reduceOptions: { displayMode: LegendDisplayMode.Hidden,
values: false, placement: 'right',
calcs: ['lastNotNull'], values: [],
}, showLegend: false,
displayLabels: [PieChartLabels.Percent],
legend: {
calcs: [],
displayMode: LegendDisplayMode.Hidden,
placement: 'right',
values: [],
showLegend: false,
},
}, },
}); },
} satisfies VisualizationSuggestion<Options>);
const { dataSummary } = builder; const SLICE_MAX = 30;
const SLICE_MIN = 2;
if (!dataSummary.hasNumberField) { export const piechartSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
return; if (!dataSummary.hasFieldType(FieldType.number)) {
} return;
}
if (dataSummary.hasStringField && dataSummary.frameCount === 1) { const suggestions: Array<VisualizationSuggestion<Options>> = [
// if many values this or single value PieChart is not a good option {
if (dataSummary.rowCountTotal > 30 || dataSummary.rowCountTotal < 2) { name: t('piechart.suggestions.pie', 'Pie chart'),
return; },
} {
name: t('piechart.suggestions.donut', 'Donut chart'),
list.append({
name: SuggestionName.PieChart,
options: {
reduceOptions: {
values: true,
calcs: [],
},
},
});
list.append({
name: SuggestionName.PieChartDonut,
options: {
reduceOptions: {
values: true,
calcs: [],
},
pieType: PieChartType.Donut,
},
});
return;
}
if (dataSummary.numberFieldCount > 30 || dataSummary.numberFieldCount < 2) {
return;
}
list.append({
name: SuggestionName.PieChart,
});
list.append({
name: SuggestionName.PieChartDonut,
options: { options: {
pieType: PieChartType.Donut, pieType: PieChartType.Donut,
}, },
}); },
];
let shouldUseRawValues = false;
// we're filtering out data which has more than 30 slices or less than 2, and we're also
// determining whether the reduce options should be set based on the data summary.
if (dataSummary.hasFieldType(FieldType.string) && dataSummary.frameCount === 1) {
if (dataSummary.rowCountTotal > SLICE_MAX && dataSummary.rowCountTotal < SLICE_MIN) {
return;
}
shouldUseRawValues = true;
} else if (
dataSummary.fieldCountByType(FieldType.number) > SLICE_MAX ||
dataSummary.fieldCountByType(FieldType.number) < SLICE_MIN
) {
return;
} }
}
return suggestions.map((s) => {
const result = defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues);
// bump the score up to best if we have exactly one numeric and one string field
if (
dataSummary.fieldCount === 2 &&
dataSummary.fieldCountByType(FieldType.string) === 1 &&
dataSummary.fieldCountByType(FieldType.number) === 1
) {
result.score = VisualizationSuggestionScore.Best;
}
return result;
});
};
@@ -8,7 +8,7 @@ import { EffectsEditor } from './EffectsEditor';
import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './GaugeMigrations'; import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './GaugeMigrations';
import { RadialBarPanel } from './RadialBarPanel'; import { RadialBarPanel } from './RadialBarPanel';
import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen'; import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen';
import { radialBarSuggestionsHandler } from './suggestions'; import { radialBarSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options>(RadialBarPanel) export const plugin = new PanelPlugin<Options>(RadialBarPanel)
.useFieldConfig({}) .useFieldConfig({})
@@ -112,6 +112,6 @@ export const plugin = new PanelPlugin<Options>(RadialBarPanel)
defaultValue: defaultGaugePanelEffects, defaultValue: defaultGaugePanelEffects,
}); });
}) })
.setSuggestionsSupplier(radialBarSuggestionsHandler) .setSuggestionsSupplier(radialBarSuggestionsSupplier)
.setMigrationHandler(gaugePanelMigrationHandler, shouldMigrateGauge) .setMigrationHandler(gaugePanelMigrationHandler, shouldMigrateGauge)
.setPanelChangeHandler(gaugePanelChangedHandler); .setPanelChangeHandler(gaugePanelChangedHandler);
@@ -1,13 +1,13 @@
import { createDataFrame, Field, FieldType, getPanelDataSummary } from '@grafana/data'; import { createDataFrame, Field, FieldType, getPanelDataSummary } from '@grafana/data';
import { radialBarSuggestionsHandler } from './suggestions'; import { radialBarSuggestionsSupplier } from './suggestions';
describe('RadialBarPanel Suggestions', () => { describe('RadialBarPanel Suggestions', () => {
it('does not suggest gauge if no data is present', () => { it('does not suggest gauge if no data is present', () => {
expect(radialBarSuggestionsHandler(getPanelDataSummary([]))).toBeFalsy(); expect(radialBarSuggestionsSupplier(getPanelDataSummary([]))).toBeFalsy();
expect(radialBarSuggestionsHandler(getPanelDataSummary(undefined))).toBeFalsy(); expect(radialBarSuggestionsSupplier(getPanelDataSummary(undefined))).toBeFalsy();
expect( expect(
radialBarSuggestionsHandler( radialBarSuggestionsSupplier(
getPanelDataSummary([ getPanelDataSummary([
createDataFrame({ createDataFrame({
fields: [ fields: [
@@ -27,7 +27,7 @@ describe('RadialBarPanel Suggestions', () => {
{ name: 'status', type: FieldType.string }, { name: 'status', type: FieldType.string },
], ],
}); });
expect(radialBarSuggestionsHandler(getPanelDataSummary([df]))).toBeFalsy(); expect(radialBarSuggestionsSupplier(getPanelDataSummary([df]))).toBeFalsy();
}); });
it('does not suggest gauge if there are too many numeric fields', () => { it('does not suggest gauge if there are too many numeric fields', () => {
@@ -35,12 +35,12 @@ describe('RadialBarPanel Suggestions', () => {
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
fields.push({ name: `numeric-${i}`, type: FieldType.number, values: [0, 100, 200, 300, 400, 500], config: {} }); fields.push({ name: `numeric-${i}`, type: FieldType.number, values: [0, 100, 200, 300, 400, 500], config: {} });
} }
expect(radialBarSuggestionsHandler(getPanelDataSummary([createDataFrame({ fields })]))).toBeFalsy(); expect(radialBarSuggestionsSupplier(getPanelDataSummary([createDataFrame({ fields })]))).toBeFalsy();
}); });
it('suggests gauge for a single numeric field', () => { it('suggests gauge for a single numeric field', () => {
expect( expect(
radialBarSuggestionsHandler( radialBarSuggestionsSupplier(
getPanelDataSummary([ getPanelDataSummary([
createDataFrame({ createDataFrame({
fields: [ fields: [
@@ -58,7 +58,7 @@ describe('RadialBarPanel Suggestions', () => {
it('suggests gauge for a few numeric fields, with other fields mixed in', () => { it('suggests gauge for a few numeric fields, with other fields mixed in', () => {
expect( expect(
radialBarSuggestionsHandler( radialBarSuggestionsSupplier(
getPanelDataSummary([ getPanelDataSummary([
createDataFrame({ createDataFrame({
fields: [ fields: [
@@ -136,7 +136,7 @@ describe('RadialBarPanel Suggestions', () => {
], ],
}, },
])('$description suggests aggregated=$aggregated', ({ dataframes, aggregated }) => { ])('$description suggests aggregated=$aggregated', ({ dataframes, aggregated }) => {
const suggestions = radialBarSuggestionsHandler(getPanelDataSummary(dataframes)); const suggestions = radialBarSuggestionsSupplier(getPanelDataSummary(dataframes));
const expected = aggregated ? { values: false, calcs: ['lastNotNull'] } : { values: true, calcs: [] }; const expected = aggregated ? { values: false, calcs: ['lastNotNull'] } : { values: true, calcs: [] };
if (Array.isArray(suggestions)) { if (Array.isArray(suggestions)) {
for (const suggestion of suggestions) { for (const suggestion of suggestions) {
@@ -8,10 +8,39 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { t } from '@grafana/i18n'; import { t } from '@grafana/i18n';
import { GraphFieldConfig } from '@grafana/ui'; import { GraphFieldConfig } from '@grafana/ui';
import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
import { Options } from './panelcfg.gen'; import { Options } from './panelcfg.gen';
export const radialBarSuggestionsHandler: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = ( const withDefaults = (
suggestion: VisualizationSuggestion<Options, GraphFieldConfig>
): VisualizationSuggestion<Options, GraphFieldConfig> =>
defaultsDeep(suggestion, {
cardOptions: {
previewModifier: (s) => {
if (s.options?.reduceOptions) {
s.options.reduceOptions.limit = 4;
}
},
},
// styles: [{
// name: t('gauge.suggestions.style.circular', 'Glowing'),
// options: {
// effects: {
// rounded: true,
// barGlow: true,
// centerGlow: true,
// spotlight: true,
// },
// },
// }, {
// name: t('gauge.suggestions.style.simple', 'Simple'),
// }]
} satisfies VisualizationSuggestion<Options, GraphFieldConfig>);
const MAX_GAUGES = 10;
export const radialBarSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
dataSummary dataSummary
) => { ) => {
if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) { if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
@@ -19,69 +48,40 @@ export const radialBarSuggestionsHandler: VisualizationSuggestionsSupplierFn<Opt
} }
// for many fields / series this is probably not a good fit // for many fields / series this is probably not a good fit
if (dataSummary.fieldCountByType(FieldType.number) >= 10) { if (dataSummary.fieldCountByType(FieldType.number) > MAX_GAUGES) {
return; return;
} }
const withDefaults = ( const suggestions: Array<VisualizationSuggestion<Options, GraphFieldConfig>> = [
suggestion: VisualizationSuggestion<Options, GraphFieldConfig> { name: t('gauge.suggestions.arc', 'Gauge') },
): VisualizationSuggestion<Options, GraphFieldConfig> => { {
// if there is a string field and there are few enough rows, we assume it's tabular data and not numeric series data,
// and the de-aggregated version of the viz probably makes more sense
const isTabularData =
dataSummary.hasFieldType(FieldType.string) && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10;
return defaultsDeep(suggestion, {
options: {
reduceOptions: isTabularData
? {
values: true,
calcs: [],
}
: {
values: false,
calcs: ['lastNotNull'],
},
},
fieldConfig: {
defaults: isTabularData
? {
color: { mode: FieldColorModeId.PaletteClassic },
}
: {},
overrides: [],
},
cardOptions: {
previewModifier: (s) => {
if (s.options?.reduceOptions) {
s.options.reduceOptions.limit = 4;
}
},
},
// styles: [{
// name: t('gauge.suggestions.style.circular', 'Glowing'),
// options: {
// effects: {
// rounded: true,
// barGlow: true,
// centerGlow: true,
// spotlight: true,
// },
// },
// }, {
// name: t('gauge.suggestions.style.simple', 'Simple'),
// }]
} satisfies VisualizationSuggestion<Options, GraphFieldConfig>);
};
return [
withDefaults({ name: t('gauge.suggestions.arc', 'Gauge') }),
withDefaults({
name: t('gauge.suggestions.circular', 'Circular gauge'), name: t('gauge.suggestions.circular', 'Circular gauge'),
options: { options: {
shape: 'circle', shape: 'circle',
showThresholdMarkers: false, showThresholdMarkers: false,
barWidthFactor: 0.3, barWidthFactor: 0.3,
}, },
}), },
]; ];
const shouldUseRawValues =
dataSummary.hasFieldType(FieldType.string) &&
dataSummary.frameCount === 1 &&
dataSummary.rowCountTotal <= MAX_GAUGES;
return suggestions.map((s) => {
const suggestion = defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues);
if (shouldUseRawValues) {
suggestion.fieldConfig = suggestion.fieldConfig ?? {
defaults: {},
overrides: [],
};
suggestion.fieldConfig.defaults.color = suggestion.fieldConfig.defaults.color ?? {
mode: FieldColorModeId.PaletteClassic,
};
}
return suggestion;
});
}; };
+2 -2
View File
@@ -13,7 +13,7 @@ import { statPanelChangedHandler } from './StatMigrations';
import { StatPanel } from './StatPanel'; import { StatPanel } from './StatPanel';
import { addStandardDataReduceOptions, addOrientationOption } from './common'; import { addStandardDataReduceOptions, addOrientationOption } from './common';
import { defaultOptions, Options } from './panelcfg.gen'; import { defaultOptions, Options } from './panelcfg.gen';
import { StatSuggestionsSupplier } from './suggestions'; import { statSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options>(StatPanel) export const plugin = new PanelPlugin<Options>(StatPanel)
.useFieldConfig() .useFieldConfig()
@@ -137,5 +137,5 @@ export const plugin = new PanelPlugin<Options>(StatPanel)
}) })
.setNoPadding() .setNoPadding()
.setPanelChangeHandler(statPanelChangedHandler) .setPanelChangeHandler(statPanelChangedHandler)
.setSuggestionsSupplier(new StatSuggestionsSupplier()) .setSuggestionsSupplier(statSuggestionsSupplier)
.setMigrationHandler(sharedSingleStatMigrationHandler); .setMigrationHandler(sharedSingleStatMigrationHandler);
+68 -59
View File
@@ -1,41 +1,46 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data'; import { defaultsDeep } from 'lodash';
import { BigValueColorMode, BigValueGraphMode, GraphFieldConfig } from '@grafana/schema';
import { SuggestionName } from 'app/types/suggestions'; import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { t } from '@grafana/i18n';
import { BigValueColorMode, BigValueGraphMode } from '@grafana/schema';
import { Options } from './panelcfg.gen'; import { Options } from './panelcfg.gen';
export class StatSuggestionsSupplier { const withDefaults = (s: VisualizationSuggestion<Options>): VisualizationSuggestion<Options> =>
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { defaultsDeep(s, {
const { dataSummary: ds } = builder; fieldConfig: {
defaults: {
if (!ds.hasData) { unit: 'short',
return; custom: {},
}
const list = builder.getListAppender<Options, GraphFieldConfig>({
name: SuggestionName.Stat,
pluginId: 'stat',
options: {},
fieldConfig: {
defaults: {
unit: 'short',
custom: {},
},
overrides: [],
}, },
cardOptions: { overrides: [],
previewModifier: (s) => { },
if (s.options?.reduceOptions?.values) { cardOptions: {
s.options.reduceOptions.limit = 1; previewModifier: (s) => {
} if (s.options?.reduceOptions?.values) {
}, s.options.reduceOptions.limit = 1;
}
}, },
}); },
} satisfies VisualizationSuggestion<Options>);
// String and number field with low row count show individual rows export const statSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (ds) => {
if (ds.hasStringField && ds.hasNumberField && ds.frameCount === 1 && ds.rowCountTotal < 10) { if (!ds.hasData) {
list.append({ return;
name: SuggestionName.Stat, }
const suggestions: Array<VisualizationSuggestion<Options>> = [];
// String and number field with low row count show individual rows
if (
ds.hasFieldType(FieldType.string) &&
ds.hasFieldType(FieldType.number) &&
ds.frameCount === 1 &&
ds.rowCountTotal < 10
) {
suggestions.push(
{
name: t('stat.suggestions.stat-discrete-values', 'Stat - discrete values'),
options: { options: {
reduceOptions: { reduceOptions: {
values: true, values: true,
@@ -43,9 +48,9 @@ export class StatSuggestionsSupplier {
fields: '/.*/', fields: '/.*/',
}, },
}, },
}); },
list.append({ {
name: SuggestionName.StatColoredBackground, name: t('stat.suggestions.stat-discrete-values-color-background', 'Stat - discrete values - color background'),
options: { options: {
reduceOptions: { reduceOptions: {
values: true, values: true,
@@ -54,36 +59,38 @@ export class StatSuggestionsSupplier {
}, },
colorMode: BigValueColorMode.Background, colorMode: BigValueColorMode.Background,
}, },
}); }
} );
}
// Just a single string field // just a single string field
if (ds.stringFieldCount === 1 && ds.frameCount === 1 && ds.rowCountTotal < 10 && ds.fieldCount === 1) { if (ds.fieldCount === 1 && ds.hasFieldType(FieldType.string)) {
list.append({ suggestions.push({
name: SuggestionName.Stat, name: t('stat.suggestions.stat-single-string', 'Stat - single string'),
options: { options: {
reduceOptions: { reduceOptions: {
values: true, values: true,
calcs: [], calcs: [],
fields: '/.*/', fields: '/.*/',
},
colorMode: BigValueColorMode.None,
}, },
}); colorMode: BigValueColorMode.None,
} },
});
}
if (ds.hasNumberField && ds.hasTimeField) { // aggregated suggestions for number fields
list.append({ if (ds.hasFieldType(FieldType.number) && ds.hasFieldType(FieldType.time)) {
suggestions.push(
{
options: { options: {
reduceOptions: { reduceOptions: {
values: false, values: false,
calcs: ['lastNotNull'], calcs: ['lastNotNull'],
}, },
}, },
}); },
{
list.append({ name: t('stat.suggestions.stat-color-background', 'Stat - color background'),
name: SuggestionName.StatColoredBackground,
options: { options: {
reduceOptions: { reduceOptions: {
values: false, values: false,
@@ -92,7 +99,9 @@ export class StatSuggestionsSupplier {
graphMode: BigValueGraphMode.None, graphMode: BigValueGraphMode.None,
colorMode: BigValueColorMode.Background, colorMode: BigValueColorMode.Background,
}, },
}); }
} );
} }
}
return suggestions.map(withDefaults);
};
@@ -177,7 +177,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel)
} }
// Probably better ways to filter out this by inspecting the types of string values so view this as temporary // Probably better ways to filter out this by inspecting the types of string values so view this as temporary
if (ds.preferredVisualisationType === 'logs') { if (ds.hasPreferredVisualisationType('logs')) {
return; return;
} }
@@ -1,11 +1,10 @@
import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data'; import { FieldColorModeId, FieldConfigProperty, FieldType, PanelPlugin } from '@grafana/data';
import { t } from '@grafana/i18n'; import { t } from '@grafana/i18n';
import { AxisPlacement, VisibilityMode } from '@grafana/schema'; import { AxisPlacement, VisibilityMode } from '@grafana/schema';
import { commonOptionsBuilder } from '@grafana/ui'; import { commonOptionsBuilder } from '@grafana/ui';
import { StatusHistoryPanel } from './StatusHistoryPanel'; import { StatusHistoryPanel } from './StatusHistoryPanel';
import { Options, FieldConfig, defaultFieldConfig } from './panelcfg.gen'; import { Options, FieldConfig, defaultFieldConfig } from './panelcfg.gen';
import { StatusHistorySuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options, FieldConfig>(StatusHistoryPanel) export const plugin = new PanelPlugin<Options, FieldConfig>(StatusHistoryPanel)
.useFieldConfig({ .useFieldConfig({
@@ -113,5 +112,42 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StatusHistoryPanel)
commonOptionsBuilder.addLegendOptions(builder, false); commonOptionsBuilder.addLegendOptions(builder, false);
commonOptionsBuilder.addTooltipOptions(builder); commonOptionsBuilder.addTooltipOptions(builder);
}) })
.setSuggestionsSupplier(new StatusHistorySuggestionsSupplier()) .setSuggestionsSupplier((ds) => {
if (!ds.hasData) {
return;
}
// This panel needs a time field and a string or number field
if (
!ds.hasFieldType(FieldType.time) ||
(!ds.hasFieldType(FieldType.string) && !ds.hasFieldType(FieldType.number))
) {
return;
}
// If there are many series then they won't fit on y-axis so this panel is not good fit
if (ds.fieldCountByType(FieldType.number) >= 30) {
return;
}
// if there a lot of data points for each series then this is not a good match
if (ds.rowCountMax > 100) {
return;
}
// Probably better ways to filter out this by inspecting the types of string values so view this as temporary
if (ds.hasPreferredVisualisationType('logs')) {
return;
}
return [
{
cardOptions: {
previewModifier: (s) => {
s.options!.colWidth = 0.7;
},
},
},
];
})
.setDataSupport({ annotations: true }); .setDataSupport({ annotations: true });
@@ -1,56 +0,0 @@
import { FieldColorModeId, VisualizationSuggestionsBuilder } from '@grafana/data';
import { SuggestionName } from 'app/types/suggestions';
import { Options, FieldConfig } from './panelcfg.gen';
export class StatusHistorySuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const { dataSummary: ds } = builder;
if (!ds.hasData) {
return;
}
// This panel needs a time field and a string or number field
if (!ds.hasTimeField || (!ds.hasStringField && !ds.hasNumberField)) {
return;
}
// If there are many series then they won't fit on y-axis so this panel is not good fit
if (ds.numberFieldCount >= 30) {
return;
}
// if there a lot of data points for each series then this is not a good match
if (ds.rowCountMax > 100) {
return;
}
// Probably better ways to filter out this by inspecting the types of string values so view this as temporary
if (ds.preferredVisualisationType === 'logs') {
return;
}
const list = builder.getListAppender<Options, FieldConfig>({
name: '',
pluginId: 'status-history',
options: {},
fieldConfig: {
defaults: {
color: {
mode: FieldColorModeId.ContinuousGrYlRd,
},
custom: {},
},
overrides: [],
},
cardOptions: {
previewModifier: (s) => {
s.options!.colWidth = 0.7;
},
},
});
list.append({ name: SuggestionName.StatusHistory });
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ import { TableCellOptionEditor } from './TableCellOptionEditor';
import { TablePanel } from './TablePanel'; import { TablePanel } from './TablePanel';
import { tableMigrationHandler, tablePanelChangedHandler } from './migrations'; import { tableMigrationHandler, tablePanelChangedHandler } from './migrations';
import { Options, defaultOptions, FieldConfig } from './panelcfg.gen'; import { Options, defaultOptions, FieldConfig } from './panelcfg.gen';
import { TableSuggestionsSupplier } from './suggestions'; import { tableSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel) export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
.setPanelChangeHandler(tablePanelChangedHandler) .setPanelChangeHandler(tablePanelChangedHandler)
@@ -227,4 +227,4 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
defaultValue: defaultOptions?.enablePagination, defaultValue: defaultOptions?.enablePagination,
}); });
}) })
.setSuggestionsSupplier(new TableSuggestionsSupplier()); .setSuggestionsSupplier(tableSuggestionsSupplier);
+28 -33
View File
@@ -1,38 +1,33 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data'; import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
import { TableFieldOptions } from '@grafana/schema';
import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg'; import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg';
import { SuggestionName } from 'app/types/suggestions';
import { Options } from './panelcfg.gen'; import { Options, FieldConfig } from './panelcfg.gen';
export class TableSuggestionsSupplier { function getTableSuggestionScore(dataSummary: PanelDataSummary): VisualizationSuggestionScore {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { if (dataSummary.hasPreferredVisualisationType('table')) {
const list = builder.getListAppender<Options, TableFieldOptions>({ return VisualizationSuggestionScore.Best;
name: SuggestionName.Table,
pluginId: 'table',
options: {},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
cardOptions: {
previewModifier: (s) => {
s.fieldConfig!.defaults.custom!.minWidth = 50;
},
},
});
// If there are not data suggest table anyway but use icon instead of real preview
if (builder.dataSummary.fieldCount === 0) {
list.append({
cardOptions: {
imgSrc: icnTablePanelSvg,
},
});
} else {
list.append({});
}
} }
// table is best suited to showing many fields with many rows.
if (dataSummary.fieldCountMax > 5 && dataSummary.rowCountMax > 50) {
return VisualizationSuggestionScore.Good;
}
return VisualizationSuggestionScore.OK;
} }
export const tableSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, FieldConfig> = (dataSummary) => [
{
score: getTableSuggestionScore(dataSummary),
cardOptions: {
previewModifier: (s) => {
if (s.fieldConfig && s.fieldConfig.defaults.custom) {
s.fieldConfig.defaults.custom.minWidth = 50;
}
},
// If there is no data, suggest table anyway, but use icon instead of real preview
// TODO: delete this in once "new" suggestions are fully rolled out
imgSrc: dataSummary.fieldCount === 0 ? icnTablePanelSvg : undefined,
},
},
];
@@ -8,7 +8,7 @@ import { TimezonesEditor } from './TimezonesEditor';
import { defaultGraphConfig, getGraphFieldConfig } from './config'; import { defaultGraphConfig, getGraphFieldConfig } from './config';
import { graphPanelChangedHandler } from './migrations'; import { graphPanelChangedHandler } from './migrations';
import { FieldConfig, Options } from './panelcfg.gen'; import { FieldConfig, Options } from './panelcfg.gen';
import { TimeSeriesSuggestionsSupplier } from './suggestions'; import { timeseriesSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options, FieldConfig>(TimeSeriesPanel) export const plugin = new PanelPlugin<Options, FieldConfig>(TimeSeriesPanel)
.setPanelChangeHandler(graphPanelChangedHandler) .setPanelChangeHandler(graphPanelChangedHandler)
@@ -26,5 +26,5 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TimeSeriesPanel)
defaultValue: undefined, defaultValue: undefined,
}); });
}) })
.setSuggestionsSupplier(new TimeSeriesSuggestionsSupplier()) .setSuggestionsSupplier(timeseriesSuggestionsSupplier)
.setDataSupport({ annotations: true, alertStates: true }); .setDataSupport({ annotations: true, alertStates: true });
+118 -179
View File
@@ -1,9 +1,15 @@
import { defaultsDeep } from 'lodash';
import { import {
FieldColorModeId, DataFrameType,
VisualizationSuggestionsBuilder,
DataTransformerID, DataTransformerID,
FieldType,
PanelPluginVisualizationSuggestion, PanelPluginVisualizationSuggestion,
VisualizationSuggestion,
VisualizationSuggestionScore,
VisualizationSuggestionsSupplierFn,
} from '@grafana/data'; } from '@grafana/data';
import { t } from '@grafana/i18n';
import { import {
GraphDrawStyle, GraphDrawStyle,
GraphFieldConfig, GraphFieldConfig,
@@ -13,209 +19,142 @@ import {
StackingMode, StackingMode,
} from '@grafana/schema'; } from '@grafana/schema';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { SuggestionName } from 'app/types/suggestions';
import { Options } from './panelcfg.gen'; import { Options } from './panelcfg.gen';
export class TimeSeriesSuggestionsSupplier { const MAX_BARS = 100;
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { const MAX_ROWS_SMOOTH_CHART = 200;
const { dataSummary } = builder;
if (!dataSummary.hasTimeField || !dataSummary.hasNumberField || dataSummary.rowCountTotal < 2) { const withDefaults = (
return; suggestion: VisualizationSuggestion<Options, GraphFieldConfig>
} ): VisualizationSuggestion<Options, GraphFieldConfig> =>
defaultsDeep(suggestion, {
const list = builder.getListAppender<Options, GraphFieldConfig>({ options: {
name: SuggestionName.LineChart, legend: {
pluginId: 'timeseries', calcs: [],
options: { displayMode: LegendDisplayMode.Hidden,
legend: { placement: 'right',
calcs: [], showLegend: false,
displayMode: LegendDisplayMode.Hidden,
placement: 'right',
showLegend: false,
},
}, },
fieldConfig: { },
defaults: { fieldConfig: {
custom: {}, defaults: {
}, custom: {},
overrides: [],
}, },
cardOptions: { overrides: [],
previewModifier: (s) => { },
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { cardOptions: {
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); previewModifier: (s) => {
} if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
}, s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
}
}, },
}); },
} satisfies VisualizationSuggestion<Options, GraphFieldConfig>);
const maxBarsCount = 100; const areaChart = (name: string, stacking?: StackingMode) => ({
name,
fieldConfig: {
defaults: {
custom: {
fillOpacity: 25,
...(stacking ? { stacking: { mode: stacking, group: 'A' } } : {}),
},
},
overrides: [],
},
});
list.append({ const barChart = (name: string, stacking?: StackingMode) => ({
name: SuggestionName.LineChart, name,
}); fieldConfig: {
defaults: {
custom: {
drawStyle: GraphDrawStyle.Bars,
fillOpacity: 100,
lineWidth: 1,
gradientMode: GraphGradientMode.Hue,
...(stacking ? { stacking: { mode: stacking, group: 'A' } } : {}),
},
},
overrides: [],
},
});
if (dataSummary.rowCountMax < 200) { // TODO: all "gradient color scheme" suggestions have been removed. they will be re-added as part of the "styles" feature.
list.append({
name: SuggestionName.LineChartSmooth,
fieldConfig: {
defaults: {
custom: {
lineInterpolation: LineInterpolation.Smooth,
},
},
overrides: [],
},
});
}
// Single series suggestions export const timeseriesSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
if (dataSummary.numberFieldCount === 1) { dataSummary
list.append({ ) => {
name: SuggestionName.AreaChart, if (
fieldConfig: { !dataSummary.hasFieldType(FieldType.time) ||
defaults: { !dataSummary.hasFieldType(FieldType.number) ||
custom: { dataSummary.rowCountTotal < 2
fillOpacity: 25, ) {
}, return;
}, }
overrides: [],
},
});
list.append({ const score: VisualizationSuggestionScore =
name: SuggestionName.LineChartGradientColorScheme, dataSummary.hasDataFrameType(DataFrameType.TimeSeriesLong) ||
fieldConfig: { dataSummary.hasDataFrameType(DataFrameType.TimeSeriesWide) ||
defaults: { dataSummary.hasDataFrameType(DataFrameType.TimeSeriesMulti)
color: { ? VisualizationSuggestionScore.Good
mode: FieldColorModeId.ContinuousGrYlRd, : VisualizationSuggestionScore.OK;
},
custom: {
gradientMode: GraphGradientMode.Scheme,
lineInterpolation: LineInterpolation.Smooth,
lineWidth: 3,
fillOpacity: 20,
},
},
overrides: [],
},
});
if (dataSummary.rowCountMax < maxBarsCount) { const suggestions: Array<VisualizationSuggestion<Options, GraphFieldConfig>> = [
list.append({ {
name: SuggestionName.BarChart, name: t('timeseries.suggestions.line', 'Line chart'),
fieldConfig: { },
defaults: { ];
custom: {
drawStyle: GraphDrawStyle.Bars,
fillOpacity: 100,
lineWidth: 1,
gradientMode: GraphGradientMode.Hue,
},
},
overrides: [],
},
});
list.append({ if (dataSummary.rowCountMax < MAX_ROWS_SMOOTH_CHART) {
name: SuggestionName.BarChartGradientColorScheme, suggestions.push({
fieldConfig: { name: t('timeseries.suggestions.line-smooth', 'Line chart - smooth'),
defaults: {
color: {
mode: FieldColorModeId.ContinuousGrYlRd,
},
custom: {
drawStyle: GraphDrawStyle.Bars,
fillOpacity: 90,
lineWidth: 1,
gradientMode: GraphGradientMode.Scheme,
},
},
overrides: [],
},
});
}
return;
}
// Multiple series suggestions
list.append({
name: SuggestionName.AreaChartStacked,
fieldConfig: { fieldConfig: {
defaults: { defaults: {
custom: { custom: {
fillOpacity: 25, lineInterpolation: LineInterpolation.Smooth,
stacking: {
mode: StackingMode.Normal,
group: 'A',
},
}, },
}, },
overrides: [], overrides: [],
}, },
}); });
}
list.append({ // Single-series suggestions
name: SuggestionName.AreaChartStackedPercent, if (dataSummary.fieldCountByType(FieldType.number) === 1) {
fieldConfig: { suggestions.push(areaChart(t('timeseries.suggestions.area', 'Area chart')));
defaults: {
custom: {
fillOpacity: 25,
stacking: {
mode: StackingMode.Percent,
group: 'A',
},
},
},
overrides: [],
},
});
if (dataSummary.rowCountTotal / dataSummary.numberFieldCount < maxBarsCount) { if (dataSummary.rowCountMax < MAX_BARS) {
list.append({ suggestions.push(barChart(t('timeseries.suggestions.bar', 'Bar chart')));
name: SuggestionName.BarChartStacked,
fieldConfig: {
defaults: {
custom: {
drawStyle: GraphDrawStyle.Bars,
fillOpacity: 100,
lineWidth: 1,
gradientMode: GraphGradientMode.Hue,
stacking: {
mode: StackingMode.Normal,
group: 'A',
},
},
},
overrides: [],
},
});
list.append({
name: SuggestionName.BarChartStackedPercent,
fieldConfig: {
defaults: {
custom: {
drawStyle: GraphDrawStyle.Bars,
fillOpacity: 100,
lineWidth: 1,
gradientMode: GraphGradientMode.Hue,
stacking: {
mode: StackingMode.Percent,
group: 'A',
},
},
},
overrides: [],
},
});
} }
} }
} // Multiple series suggestions
else {
suggestions.push(
areaChart(t('timeseries.suggestions.area-stacked', 'Area chart - stacked'), StackingMode.Normal),
areaChart(
t('timeseries.suggestions.area-stacked-percentage', 'Area chart - stacked by percentage'),
StackingMode.Percent
)
);
if (dataSummary.rowCountTotal / dataSummary.fieldCountByType(FieldType.number) < MAX_BARS) {
suggestions.push(
barChart(t('timeseries.suggestions.bar-stacked', 'Bar chart - stacked'), StackingMode.Normal),
barChart(
t('timeseries.suggestions.bar-stacked-percent', 'Bar chart - stacked by percentage'),
StackingMode.Percent
)
);
}
}
return suggestions.map((s) => {
s.score = score;
return withDefaults(s);
});
};
// This will try to get a suggestion that will add a long to wide conversion // This will try to get a suggestion that will add a long to wide conversion
export function getPrepareTimeseriesSuggestion(panelId: number): PanelPluginVisualizationSuggestion | undefined { export function getPrepareTimeseriesSuggestion(panelId: number): PanelPluginVisualizationSuggestion | undefined {
+2 -2
View File
@@ -1,11 +1,11 @@
import { PanelPlugin } from '@grafana/data'; import { PanelPlugin } from '@grafana/data';
import { t } from '@grafana/i18n'; import { t } from '@grafana/i18n';
import { showDefaultSuggestion } from 'app/features/panel/suggestions/utils';
import { migrateToAdhocFilters } from '../../../features/explore/TraceView/useSearch'; import { migrateToAdhocFilters } from '../../../features/explore/TraceView/useSearch';
import { FiltersEditor } from './FiltersEditor'; import { FiltersEditor } from './FiltersEditor';
import { TracesPanel } from './TracesPanel'; import { TracesPanel } from './TracesPanel';
import { TracesSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin(TracesPanel) export const plugin = new PanelPlugin(TracesPanel)
.setMigrationHandler((panel) => { .setMigrationHandler((panel) => {
@@ -44,4 +44,4 @@ export const plugin = new PanelPlugin(TracesPanel)
category, category,
}); });
}) })
.setSuggestionsSupplier(new TracesSuggestionsSupplier()); .setSuggestionsSupplier(showDefaultSuggestion((ds) => ds.hasPreferredVisualisationType('trace')));
@@ -1,29 +0,0 @@
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data';
import { SuggestionName } from 'app/types/suggestions';
export class TracesSuggestionsSupplier {
getListWithDefaults(builder: VisualizationSuggestionsBuilder) {
return builder.getListAppender<{}, {}>({
name: SuggestionName.Trace,
pluginId: 'traces',
});
}
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
if (!builder.data) {
return;
}
const dataFrame = builder.data.series[0];
if (!dataFrame) {
return;
}
if (builder.data.series[0].meta?.preferredVisualisationType === 'trace') {
this.getListWithDefaults(builder).append({
name: SuggestionName.Trace,
score: VisualizationSuggestionScore.Best,
});
}
}
}
+3 -47
View File
@@ -1,7 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import {
isLikelyAscendingVector,
DataFrame, DataFrame,
FieldMatcherID, FieldMatcherID,
fieldMatchers, fieldMatchers,
@@ -10,18 +9,17 @@ import {
TimeRange, TimeRange,
useDataLinksContext, useDataLinksContext,
} from '@grafana/data'; } from '@grafana/data';
import { config, PanelDataErrorView } from '@grafana/runtime'; import { PanelDataErrorView } from '@grafana/runtime';
import { KeyboardPlugin, TooltipDisplayMode, TooltipPlugin2, usePanelContext } from '@grafana/ui'; import { KeyboardPlugin, TooltipDisplayMode, TooltipPlugin2, usePanelContext } from '@grafana/ui';
import { TooltipHoverMode } from '@grafana/ui/internal'; import { TooltipHoverMode } from '@grafana/ui/internal';
import { XYFieldMatchers } from 'app/core/components/GraphNG/types'; import { XYFieldMatchers } from 'app/core/components/GraphNG/types';
import { preparePlotFrame } from 'app/core/components/GraphNG/utils'; import { preparePlotFrame } from 'app/core/components/GraphNG/utils';
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
import { findFieldIndex } from 'app/features/dimensions/utils';
import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip'; import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip';
import { prepareGraphableFields } from '../timeseries/utils';
import { Options } from './panelcfg.gen'; import { Options } from './panelcfg.gen';
import { prepSeries } from './utils';
export const TrendPanel = ({ export const TrendPanel = ({
data, data,
@@ -51,49 +49,7 @@ export const TrendPanel = ({
return preparePlotFrame(frames, dimFields); return preparePlotFrame(frames, dimFields);
}; };
const info = useMemo(() => { const info = useMemo(() => prepSeries(data.series, options.xField), [data.series, options.xField]);
if (data.series.length > 1) {
return {
warning: 'Only one frame is supported, consider adding a join transformation',
frames: data.series,
};
}
let frames = data.series;
let xFieldIdx: number | undefined;
if (options.xField) {
xFieldIdx = findFieldIndex(options.xField, frames[0]);
if (xFieldIdx == null) {
return {
warning: 'Unable to find field: ' + options.xField,
frames: data.series,
};
}
} else {
// first number field
// Perhaps we can/should support any ordinal rather than an error here
xFieldIdx = frames[0] ? frames[0].fields.findIndex((f) => f.type === FieldType.number) : -1;
if (xFieldIdx === -1) {
return {
warning: 'No numeric fields found for X axis',
frames,
};
}
}
// Make sure values are ascending
if (xFieldIdx != null) {
const field = frames[0].fields[xFieldIdx];
if (field.type === FieldType.number && !isLikelyAscendingVector(field.values)) {
return {
warning: `Values must be in ascending order`,
frames,
};
}
}
return { frames: prepareGraphableFields(frames, config.theme2, undefined, xFieldIdx) };
}, [data.series, options.xField]);
if (info.warning || !info.frames) { if (info.warning || !info.frames) {
return ( return (
+47 -4
View File
@@ -1,13 +1,14 @@
import { Field, FieldType, PanelPlugin } from '@grafana/data'; import { Field, FieldType, PanelPlugin, VisualizationSuggestionScore } from '@grafana/data';
import { t } from '@grafana/i18n'; import { t } from '@grafana/i18n';
import { commonOptionsBuilder } from '@grafana/ui'; import { GraphDrawStyle } from '@grafana/schema';
import { commonOptionsBuilder, LegendDisplayMode } from '@grafana/ui';
import { optsWithHideZeros } from '@grafana/ui/internal'; import { optsWithHideZeros } from '@grafana/ui/internal';
import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config'; import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config';
import { TrendPanel } from './TrendPanel'; import { TrendPanel } from './TrendPanel';
import { FieldConfig, Options } from './panelcfg.gen'; import { FieldConfig, Options } from './panelcfg.gen';
import { TrendSuggestionsSupplier } from './suggestions'; import { prepSeries } from './utils';
export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel) export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
.useFieldConfig(getGraphFieldConfig(defaultGraphConfig, false)) .useFieldConfig(getGraphFieldConfig(defaultGraphConfig, false))
@@ -29,5 +30,47 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
commonOptionsBuilder.addTooltipOptions(builder, false, true, optsWithHideZeros); commonOptionsBuilder.addTooltipOptions(builder, false, true, optsWithHideZeros);
commonOptionsBuilder.addLegendOptions(builder); commonOptionsBuilder.addLegendOptions(builder);
}) })
.setSuggestionsSupplier(new TrendSuggestionsSupplier()); .setSuggestionsSupplier((ds) => {
if (
!ds.rawFrames ||
ds.fieldCountByType(FieldType.number) < 2 ||
ds.rowCountTotal < 2 ||
ds.rowCountTotal < 2 ||
ds.frameCount > 1
) {
return;
}
const info = prepSeries(ds.rawFrames);
if (info.warning || !info.frames) {
return;
}
return [
{
score: VisualizationSuggestionScore.Good,
options: {
legend: {
calcs: [],
displayMode: LegendDisplayMode.Hidden,
placement: 'right',
showLegend: false,
},
},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
cardOptions: {
previewModifier: (s) => {
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
}
},
},
},
];
});
//.setDataSupport({ annotations: true, alertStates: true }); //.setDataSupport({ annotations: true, alertStates: true });
@@ -1,43 +0,0 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data';
import { GraphDrawStyle, GraphFieldConfig, LegendDisplayMode } from '@grafana/schema';
import { SuggestionName } from 'app/types/suggestions';
import { Options } from './panelcfg.gen';
export class TrendSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const { dataSummary } = builder;
if (dataSummary.numberFieldCount < 2 || dataSummary.rowCountTotal < 2 || dataSummary.rowCountTotal < 2) {
return;
}
// Super basic
const list = builder.getListAppender<Options, GraphFieldConfig>({
name: SuggestionName.LineChart,
pluginId: 'trend',
options: {
legend: {
calcs: [],
displayMode: LegendDisplayMode.Hidden,
placement: 'right',
showLegend: false,
},
},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
cardOptions: {
previewModifier: (s) => {
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
}
},
},
});
return list;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { DataFrame, FieldType, isLikelyAscendingVector } from '@grafana/data';
import config from 'app/core/config';
import { findFieldIndex } from 'app/features/dimensions/utils';
import { prepareGraphableFields } from '../timeseries/utils';
export function prepSeries(frames: DataFrame[], xField?: string): { warning?: string; frames: DataFrame[] | null } {
if (frames.length > 1) {
return {
warning: 'Only one frame is supported, consider adding a join transformation',
frames: frames,
};
}
let xFieldIdx: number | undefined;
if (xField) {
xFieldIdx = findFieldIndex(xField, frames[0]);
if (xFieldIdx == null) {
return {
warning: 'Unable to find field: ' + xField,
frames: frames,
};
}
} else {
// first number field
// Perhaps we can/should support any ordinal rather than an error here
xFieldIdx = frames[0] ? frames[0].fields.findIndex((f) => f.type === FieldType.number) : -1;
if (xFieldIdx === -1) {
return {
warning: 'No numeric fields found for X axis',
frames,
};
}
}
// Make sure values are ascending
if (xFieldIdx != null) {
const field = frames[0].fields[xFieldIdx];
if (field.type === FieldType.number && !isLikelyAscendingVector(field.values)) {
return {
warning: `Values must be in ascending order`,
frames,
};
}
}
return { frames: prepareGraphableFields(frames, config.theme2, undefined, xFieldIdx) };
}
-34
View File
@@ -1,34 +0,0 @@
export enum SuggestionName {
LineChart = 'Line chart',
LineChartSmooth = 'Line chart smooth',
LineChartGradientColorScheme = 'Line chart with gradient color scheme',
AreaChart = 'Area chart',
AreaChartStacked = 'Area chart stacked',
AreaChartStackedPercent = 'Area chart 100% stacked',
BarChart = 'Bar chart',
BarChartGradientColorScheme = 'Bar chart with gradient color scheme',
BarChartStacked = 'Bar chart stacked',
BarChartStackedPercent = 'Bar chart 100% stacked',
BarChartHorizontal = 'Bar chart horizontal',
BarChartHorizontalStacked = 'Bar chart horizontal stacked',
BarChartHorizontalStackedPercent = 'Bar chart horizontal 100% stacked',
Candlestick = 'Candlestick',
PieChart = 'Pie chart',
PieChartDonut = 'Pie chart donut',
Stat = 'Stat',
StatColoredBackground = 'Stat colored background',
Gauge = 'Gauge',
GaugeCircular = 'Circular gauge',
GaugeNoThresholds = 'Gauge no thresholds',
BarGaugeBasic = 'Bar gauge basic',
BarGaugeLCD = 'Bar gauge LCD',
Table = 'Table',
StateTimeline = 'State timeline',
StatusHistory = 'Status history',
TextPanel = 'Text',
DashboardList = 'Dashboard list',
Logs = 'Logs',
FlameGraph = 'Flame graph',
Trace = 'Trace',
NodeGraph = 'Node graph',
}
+33
View File
@@ -3477,6 +3477,14 @@
"label-negative-y": "Negative Y" "label-negative-y": "Negative Y"
} }
}, },
"suggestions": {
"horizontal": "Horizontal bar chart",
"hz-stacked": "Horizontal bar chart - stacked",
"hz-stacked-percent": "Horizontal bar chart - stacked by percentage",
"vert-stacked": "Bar chart - stacked",
"vert-stacked-percent": "Bar chart - stacked by percentage",
"vertical": "Bar chart"
},
"tick-spacing-editor": { "tick-spacing-editor": {
"content-require-space-from-the-right-side": "Require space from the right side", "content-require-space-from-the-right-side": "Require space from the right side",
"gaps-options": { "gaps-options": {
@@ -3521,6 +3529,10 @@
}, },
"name-show-unfilled-area": "Show unfilled area", "name-show-unfilled-area": "Show unfilled area",
"name-value-display": "Value display", "name-value-display": "Value display",
"suggestions": {
"basic": "Bar gauge",
"lcd": "Bar gauge - LCD"
},
"value-display-options": { "value-display-options": {
"label-hidden": "Hidden", "label-hidden": "Hidden",
"label-text-color": "Text color", "label-text-color": "Text color",
@@ -7849,6 +7861,7 @@
"suggestions": { "suggestions": {
"arc": "Gauge", "arc": "Gauge",
"circular": "Circular gauge", "circular": "Circular gauge",
"no-thresholds": "Gauge - no thresholds",
"style": { "style": {
"circular": "Glowing", "circular": "Glowing",
"simple": "Simple" "simple": "Simple"
@@ -11103,6 +11116,10 @@
"pie-chart-type-options": { "pie-chart-type-options": {
"label-donut": "Donut", "label-donut": "Donut",
"label-pie": "Pie" "label-pie": "Pie"
},
"suggestions": {
"donut": "Donut chart",
"pie": "Pie chart"
} }
}, },
"playlist": { "playlist": {
@@ -13051,6 +13068,12 @@
"label-same-as-value": "Same as Value", "label-same-as-value": "Same as Value",
"label-standard": "Standard" "label-standard": "Standard"
}, },
"suggestions": {
"stat-color-background": "Stat - color background",
"stat-discrete-values": "Stat - discrete values",
"stat-discrete-values-color-background": "Stat - discrete values - color background",
"stat-single-string": "Stat - single string"
},
"text-alignment-options": { "text-alignment-options": {
"label-auto": "Auto", "label-auto": "Auto",
"label-center": "Center" "label-center": "Center"
@@ -13490,6 +13513,16 @@
"label-threshold": "Threshold" "label-threshold": "Threshold"
} }
}, },
"suggestions": {
"area": "Area chart",
"area-stacked": "Area chart - stacked",
"area-stacked-percentage": "Area chart - stacked by percentage",
"bar": "Bar chart",
"bar-stacked": "Bar chart - stacked",
"bar-stacked-percent": "Bar chart - stacked by percentage",
"line": "Line chart",
"line-smooth": "Line chart - smooth"
},
"timezones-editor": { "timezones-editor": {
"tooltip-add-timezone": "Add timezone", "tooltip-add-timezone": "Add timezone",
"tooltip-remove-timezone": "Remove timezone" "tooltip-remove-timezone": "Remove timezone"