From 92ac33736b66ff1cd1f17dbba4c778804e9fde48 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 18 Nov 2025 12:49:29 -0500 Subject: [PATCH] Suggestions: Proposed PanelPlugin API (#113688) * Suggestions: Update ownership of core files and improve some types * Suggestions: Proposed PanelPlugin API * get rid of .useSuggestionsConfig * update i18n * iterate on TypeScript types, add example in radialbar * tweak implementation, add commentary * actually, suggestions really does not need panel defaults * split suggestions handler into its own file and add tests for radialbar * small comment revision * fix test * add ds.hasData check back for state-timeline * restore a handful of comments that got lost in the merge shuffle * more updated commnets * remove pluginId from VisualizationSuggestion, whoops * fix getAllSuggestions test * update i18n * might as well restore description here * move fieldconfig back on radialbar * call them suppliers, remove boolean return type in favor of internal util * Update packages/grafana-data/src/panel/PanelPlugin.ts * Update packages/grafana-data/src/panel/PanelPlugin.ts * Update packages/grafana-data/src/panel/PanelPlugin.ts * tweak return type for setSuggestionSupplier to be this --- packages/grafana-data/src/index.ts | 2 + .../grafana-data/src/panel/PanelPlugin.ts | 41 ++++- .../grafana-data/src/types/suggestions.ts | 74 +++++--- .../src/components/PanelDataErrorView.tsx | 4 +- .../panel/components/PanelDataErrorView.tsx | 10 +- .../VisualizationSuggestionCard.tsx | 4 +- .../VisualizationSuggestions.tsx | 6 +- .../suggestions/getAllSuggestions.test.ts | 72 ++++---- .../panel/suggestions/getAllSuggestions.ts | 7 +- .../features/panel/suggestions/utils.test.ts | 33 ++++ .../app/features/panel/suggestions/utils.ts | 11 ++ public/app/plugins/panel/radialbar/module.tsx | 4 +- .../panel/radialbar/suggestions.test.ts | 151 +++++++++++++++++ .../plugins/panel/radialbar/suggestions.ts | 158 ++++++++---------- .../plugins/panel/state-timeline/module.tsx | 30 +++- .../panel/state-timeline/suggestions.ts | 43 ----- .../plugins/panel/timeseries/suggestions.ts | 4 +- public/locales/en-US/grafana.json | 8 + 18 files changed, 456 insertions(+), 206 deletions(-) create mode 100644 public/app/features/panel/suggestions/utils.test.ts create mode 100644 public/app/features/panel/suggestions/utils.ts create mode 100644 public/app/plugins/panel/radialbar/suggestions.test.ts delete mode 100644 public/app/plugins/panel/state-timeline/suggestions.ts diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 06b7f19444e..ab6a55fb5ff 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -715,6 +715,8 @@ export { export { type VisualizationSuggestion, type VisualizationSuggestionsSupplier, + type VisualizationSuggestionsSupplierFn, + type PanelPluginVisualizationSuggestion, VisualizationSuggestionScore, VisualizationSuggestionsBuilder, VisualizationSuggestionsListAppender, diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts index e9119a37b52..fdae270fd48 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.ts +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -14,7 +14,7 @@ import { PanelPluginDataSupport, } from '../types/panel'; import { GrafanaPlugin } from '../types/plugin'; -import { VisualizationSuggestionsSupplier } from '../types/suggestions'; +import { VisualizationSuggestionsSupplierFn, VisualizationSuggestionsSupplier } from '../types/suggestions'; import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders'; import { deprecationWarning } from '../utils/deprecationWarning'; @@ -363,11 +363,34 @@ export class PanelPlugin< } /** - * Sets function that can return visualization examples and suggestions. - * @alpha + * @deprecated use VisualizationSuggestionsSupplierFn */ - setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier) { - this.suggestionsSupplier = supplier; + setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier): this; + /** + * @alpha + * sets function that can return visualization examples and suggestions. + */ + setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierFn): this; + setSuggestionsSupplier( + supplier: VisualizationSuggestionsSupplier | VisualizationSuggestionsSupplierFn + ): this { + this.suggestionsSupplier = + typeof supplier === 'function' + ? { + getSuggestionsForData: (builder) => { + const appender = builder.getListAppender({ + pluginId: this.meta.id, + name: this.meta.name, + }); + + const result = supplier(builder.dataSummary); + + if (Array.isArray(result)) { + appender.appendAll(result); + } + }, + } + : supplier; return this; } @@ -379,6 +402,14 @@ export class PanelPlugin< return this.suggestionsSupplier; } + /** + * @alpha + * returns whether the plugin has configured suggestions + */ + hasSuggestions(): boolean { + return this.suggestionsSupplier !== undefined; + } + hasPluginId(pluginId: string) { return this.meta.id === pluginId; } diff --git a/packages/grafana-data/src/types/suggestions.ts b/packages/grafana-data/src/types/suggestions.ts index a7b2611a990..1765e5406f2 100644 --- a/packages/grafana-data/src/types/suggestions.ts +++ b/packages/grafana-data/src/types/suggestions.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { defaultsDeep } from 'lodash'; import { DataTransformerConfig } from '@grafana/schema'; @@ -11,29 +10,45 @@ import { PanelData } from './panel'; /** * @alpha + * A suggestion for a visualization given some data. This represents the shape of the panel (including options and field config) + * that will be used to show a small preview in the Grafana UI when suggesting visualizations in the Panel Editor. */ -export interface VisualizationSuggestion { +export interface VisualizationSuggestion { /** Name of suggestion */ - name: string; + name?: string; /** Description */ description?: string; - /** Panel plugin id */ - pluginId: string; /** Panel plugin options */ options?: Partial; /** Panel plugin field options */ fieldConfig?: FieldConfigSource>; /** Data transformations */ transformations?: DataTransformerConfig[]; + /** A value between 0-100 how suitable suggestion is */ + score?: VisualizationSuggestionScore; /** Options for how to render suggestion card */ cardOptions?: { - /** Tweak for small preview */ - previewModifier?: (suggestion: VisualizationSuggestion) => void; + /** + * Given that the suggestion is being rendered as a small preview, you may want to modify certain options + * specifically for the smaller preview version of the visualization. In this method, you should directly + * mutate the suggestion object which is passed in as the first argument. + */ + previewModifier?: (suggestion: VisualizationSuggestion) => void; icon?: string; imgSrc?: string; }; - /** A value between 0-100 how suitable suggestion is */ - score?: VisualizationSuggestionScore; +} + +/** + * @internal + * the internal interface that the PanelPlugin transforms the supplied suggestions into. + */ +export interface PanelPluginVisualizationSuggestion + extends VisualizationSuggestion { + /** Name of suggestion */ + name: string; + /** Panel plugin id */ + pluginId: string; } /** @@ -49,12 +64,13 @@ export enum VisualizationSuggestionScore { } /** - * @alpha + * @internal + * TODO this will move into the grafana app code once suppliers are migrated. */ export class VisualizationSuggestionsBuilder { /** Summary stats for current data */ dataSummary: PanelDataSummary; - private list: VisualizationSuggestion[] = []; + private list: PanelPluginVisualizationSuggestion[] = []; constructor( /** Current data */ @@ -65,7 +81,9 @@ export class VisualizationSuggestionsBuilder { this.dataSummary = getPanelDataSummary(data?.series); } - getListAppender(defaults: VisualizationSuggestion) { + getListAppender( + defaults: PanelPluginVisualizationSuggestion + ) { return new VisualizationSuggestionsListAppender(this.list, defaults); } @@ -76,25 +94,43 @@ export class VisualizationSuggestionsBuilder { /** * @alpha + * TODO: this name is temporary; it will become just "VisualizationSuggestionsSupplier" when the other interface is deleted. + * + * executed while rendering suggestions each time the DataFrame changes, this method + * determines which suggestions can be shown for this PanelPlugin given the PanelDataSummary. + * + * - returns an array of VisualizationSuggestions + * - boolean return equates to "show a single suggestion card for this panel plugin with the default options" (true = show, false or void = hide) + */ +export type VisualizationSuggestionsSupplierFn = ( + panelDataSummary: PanelDataSummary +) => Array> | void; + +/** + * @deprecated use VisualizationSuggestionsSupplierFn instead. */ export type VisualizationSuggestionsSupplier = { /** - * Adds good suitable suggestions for the current data + * Adds suitable suggestions for the current data */ getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void; }; /** - * Helps with typings and defaults - * @alpha + * @internal + * TODO this will move into the grafana app code once suppliers are migrated. */ -export class VisualizationSuggestionsListAppender { +export class VisualizationSuggestionsListAppender { constructor( private list: VisualizationSuggestion[], - private defaults: VisualizationSuggestion + private defaults: Partial> = {} ) {} - append(overrides: Partial>) { - this.list.push(defaultsDeep(overrides, this.defaults)); + append(suggestion: VisualizationSuggestion) { + this.list.push(defaultsDeep(suggestion, this.defaults)); + } + + appendAll(suggestions: Array>) { + this.list.push(...suggestions.map((o) => defaultsDeep(o, this.defaults))); } } diff --git a/packages/grafana-runtime/src/components/PanelDataErrorView.tsx b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx index 53a716a4d8f..3636918bf86 100644 --- a/packages/grafana-runtime/src/components/PanelDataErrorView.tsx +++ b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { FieldConfigSource, PanelData, VisualizationSuggestion } from '@grafana/data'; +import { FieldConfigSource, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data'; /** * Describes the properties that can be passed to the PanelDataErrorView. @@ -15,7 +15,7 @@ export interface PanelDataErrorViewProps { needsTimeField?: boolean; needsNumberField?: boolean; needsStringField?: boolean; - suggestions?: VisualizationSuggestion[]; + suggestions?: PanelPluginVisualizationSuggestion[]; } /** diff --git a/public/app/features/panel/components/PanelDataErrorView.tsx b/public/app/features/panel/components/PanelDataErrorView.tsx index 96b9299b3d8..4c72a3c879f 100644 --- a/public/app/features/panel/components/PanelDataErrorView.tsx +++ b/public/app/features/panel/components/PanelDataErrorView.tsx @@ -1,6 +1,12 @@ import { css } from '@emotion/css'; -import { CoreApp, getPanelDataSummary, GrafanaTheme2, PanelDataSummary, VisualizationSuggestion } from '@grafana/data'; +import { + CoreApp, + getPanelDataSummary, + GrafanaTheme2, + PanelDataSummary, + PanelPluginVisualizationSuggestion, +} from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; import { PanelDataErrorViewProps, locationService } from '@grafana/runtime'; @@ -63,7 +69,7 @@ export function PanelDataErrorView(props: PanelDataErrorViewProps) { ); }; - const loadSuggestion = (s: VisualizationSuggestion) => { + const loadSuggestion = (s: PanelPluginVisualizationSuggestion) => { if (!panel) { return; } diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx index 5bf2e56e23f..32eae1afc87 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import { cloneDeep } from 'lodash'; import { CSSProperties } from 'react'; -import { GrafanaTheme2, PanelData, VisualizationSuggestion } from '@grafana/data'; +import { GrafanaTheme2, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Tooltip, useStyles2 } from '@grafana/ui'; @@ -13,7 +13,7 @@ import { VizTypeChangeDetails } from './types'; export interface Props { data: PanelData; width: number; - suggestion: VisualizationSuggestion; + suggestion: PanelPluginVisualizationSuggestion; onChange: (details: VizTypeChangeDetails) => void; } diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index d78b56f89a5..46e5bdbe012 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useAsync } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { GrafanaTheme2, PanelData, PanelModel, VisualizationSuggestion } from '@grafana/data'; +import { GrafanaTheme2, PanelData, PanelModel, PanelPluginVisualizationSuggestion } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { useStyles2 } from '@grafana/ui'; @@ -80,8 +80,8 @@ export function VisualizationSuggestions({ searchQuery, onChange, data, panel, t function filterSuggestionsBySearch( searchQuery: string, - suggestions?: VisualizationSuggestion[] -): VisualizationSuggestion[] { + suggestions?: PanelPluginVisualizationSuggestion[] +): PanelPluginVisualizationSuggestion[] { if (!searchQuery || !suggestions) { return suggestions || []; } diff --git a/public/app/features/panel/suggestions/getAllSuggestions.test.ts b/public/app/features/panel/suggestions/getAllSuggestions.test.ts index e0b194e1901..e4a0c72c5d9 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.test.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.test.ts @@ -6,7 +6,7 @@ import { PanelData, PanelPluginMeta, toDataFrame, - VisualizationSuggestion, + PanelPluginVisualizationSuggestion, } from '@grafana/data'; import { GraphFieldConfig, ReduceDataOptions } from '@grafana/schema'; import { config } from 'app/core/config'; @@ -33,7 +33,7 @@ config.panels['text'] = { class ScenarioContext { data: DataFrame[] = []; - suggestions: Array> = []; + suggestions: Array> = []; setData(scenarioData: DataFrame[]) { this.data = scenarioData; @@ -99,22 +99,22 @@ scenario('Single frame with time and number field', (ctx) => { ]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([ - SuggestionName.LineChart, - SuggestionName.LineChartSmooth, - SuggestionName.AreaChart, - SuggestionName.LineChartGradientColorScheme, - SuggestionName.BarChart, - SuggestionName.BarChartGradientColorScheme, - SuggestionName.Gauge, - SuggestionName.GaugeNoThresholds, - SuggestionName.Stat, - SuggestionName.StatColoredBackground, - SuggestionName.BarGaugeBasic, - SuggestionName.BarGaugeLCD, - SuggestionName.Table, - SuggestionName.StateTimeline, - SuggestionName.StatusHistory, + expect(ctx.suggestions).toEqual([ + expect.objectContaining({ name: SuggestionName.LineChart }), + expect.objectContaining({ name: SuggestionName.LineChartSmooth }), + expect.objectContaining({ name: SuggestionName.AreaChart }), + expect.objectContaining({ name: SuggestionName.LineChartGradientColorScheme }), + expect.objectContaining({ name: SuggestionName.BarChart }), + expect.objectContaining({ name: SuggestionName.BarChartGradientColorScheme }), + expect.objectContaining({ name: SuggestionName.Gauge }), + expect.objectContaining({ name: SuggestionName.GaugeNoThresholds }), + expect.objectContaining({ name: SuggestionName.Stat }), + expect.objectContaining({ name: SuggestionName.StatColoredBackground }), + expect.objectContaining({ name: SuggestionName.BarGaugeBasic }), + expect.objectContaining({ name: SuggestionName.BarGaugeLCD }), + expect.objectContaining({ name: SuggestionName.Table }), + expect.objectContaining({ pluginId: 'state-timeline' }), + expect.objectContaining({ name: SuggestionName.StatusHistory }), ]); }); @@ -143,24 +143,24 @@ scenario('Single frame with time 2 number fields', (ctx) => { ]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([ - SuggestionName.LineChart, - SuggestionName.LineChartSmooth, - SuggestionName.AreaChartStacked, - SuggestionName.AreaChartStackedPercent, - SuggestionName.BarChartStacked, - SuggestionName.BarChartStackedPercent, - SuggestionName.Gauge, - SuggestionName.GaugeNoThresholds, - SuggestionName.Stat, - SuggestionName.StatColoredBackground, - SuggestionName.PieChart, - SuggestionName.PieChartDonut, - SuggestionName.BarGaugeBasic, - SuggestionName.BarGaugeLCD, - SuggestionName.Table, - SuggestionName.StateTimeline, - SuggestionName.StatusHistory, + expect(ctx.suggestions).toEqual([ + expect.objectContaining({ name: SuggestionName.LineChart }), + expect.objectContaining({ name: SuggestionName.LineChartSmooth }), + expect.objectContaining({ name: SuggestionName.AreaChartStacked }), + expect.objectContaining({ name: SuggestionName.AreaChartStackedPercent }), + expect.objectContaining({ name: SuggestionName.BarChartStacked }), + expect.objectContaining({ name: SuggestionName.BarChartStackedPercent }), + expect.objectContaining({ name: SuggestionName.Gauge }), + expect.objectContaining({ name: SuggestionName.GaugeNoThresholds }), + expect.objectContaining({ name: SuggestionName.Stat }), + expect.objectContaining({ name: SuggestionName.StatColoredBackground }), + expect.objectContaining({ name: SuggestionName.PieChart }), + expect.objectContaining({ name: SuggestionName.PieChartDonut }), + expect.objectContaining({ name: SuggestionName.BarGaugeBasic }), + expect.objectContaining({ name: SuggestionName.BarGaugeLCD }), + expect.objectContaining({ name: SuggestionName.Table }), + expect.objectContaining({ pluginId: 'state-timeline' }), + expect.objectContaining({ name: SuggestionName.StatusHistory }), ]); }); diff --git a/public/app/features/panel/suggestions/getAllSuggestions.ts b/public/app/features/panel/suggestions/getAllSuggestions.ts index 6b53f98c341..86fb0e17bde 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.ts @@ -1,6 +1,6 @@ import { PanelData, - VisualizationSuggestion, + PanelPluginVisualizationSuggestion, VisualizationSuggestionsBuilder, PanelModel, VisualizationSuggestionScore, @@ -25,7 +25,10 @@ export const panelsToCheckFirst = [ 'nodeGraph', ]; -export async function getAllSuggestions(data?: PanelData, panel?: PanelModel): Promise { +export async function getAllSuggestions( + data?: PanelData, + panel?: PanelModel +): Promise { const builder = new VisualizationSuggestionsBuilder(data, panel); for (const pluginId of panelsToCheckFirst) { diff --git a/public/app/features/panel/suggestions/utils.test.ts b/public/app/features/panel/suggestions/utils.test.ts new file mode 100644 index 00000000000..905b59930a5 --- /dev/null +++ b/public/app/features/panel/suggestions/utils.test.ts @@ -0,0 +1,33 @@ +import { createDataFrame, FieldType, getPanelDataSummary, PanelDataSummary } from '@grafana/data'; + +import { showDefaultSuggestion } from './utils'; + +describe('Suggestions utils', () => { + describe('showDefaultSuggestion', () => { + it('should return [{}] when fn returns true', () => { + const fn = (panelDataSummary: PanelDataSummary) => panelDataSummary.hasFieldType(FieldType.string); + const wrapped = showDefaultSuggestion(fn); + const result = wrapped( + getPanelDataSummary([ + createDataFrame({ + fields: [{ name: 'value', type: FieldType.string }], + }), + ]) + ); + expect(result).toEqual([{}]); + }); + + it('should return undefined when fn returns false', () => { + const fn = (panelDataSummary: PanelDataSummary) => panelDataSummary.hasFieldType(FieldType.string); + const wrapped = showDefaultSuggestion(fn); + const result = wrapped( + getPanelDataSummary([ + createDataFrame({ + fields: [{ name: 'value', type: FieldType.number }], + }), + ]) + ); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/public/app/features/panel/suggestions/utils.ts b/public/app/features/panel/suggestions/utils.ts new file mode 100644 index 00000000000..8668ec2f654 --- /dev/null +++ b/public/app/features/panel/suggestions/utils.ts @@ -0,0 +1,11 @@ +import { PanelDataSummary } from '@grafana/data'; + +/** + * @internal + * for panel plugins which want to simply indicate that they want to show or hide their default suggestion, + * this helper would wrap a method which can return a boolean instead of `[{}]` or `undefined` as a bit of + * syntactic sugar. + */ +export function showDefaultSuggestion(fn: (panelDataSummary: PanelDataSummary) => boolean | void) { + return (panelDataSummary: PanelDataSummary) => (fn(panelDataSummary) ? [{}] : undefined); +} diff --git a/public/app/plugins/panel/radialbar/module.tsx b/public/app/plugins/panel/radialbar/module.tsx index 9667f6fb714..dfbdb875865 100644 --- a/public/app/plugins/panel/radialbar/module.tsx +++ b/public/app/plugins/panel/radialbar/module.tsx @@ -8,7 +8,7 @@ import { EffectsEditor } from './EffectsEditor'; import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './GaugeMigrations'; import { RadialBarPanel } from './RadialBarPanel'; import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen'; -import { GaugeSuggestionsSupplier } from './suggestions'; +import { radialBarSuggestionsHandler } from './suggestions'; export const plugin = new PanelPlugin(RadialBarPanel) .useFieldConfig({}) @@ -112,6 +112,6 @@ export const plugin = new PanelPlugin(RadialBarPanel) defaultValue: defaultGaugePanelEffects, }); }) - .setSuggestionsSupplier(new GaugeSuggestionsSupplier()) + .setSuggestionsSupplier(radialBarSuggestionsHandler) .setMigrationHandler(gaugePanelMigrationHandler, shouldMigrateGauge) .setPanelChangeHandler(gaugePanelChangedHandler); diff --git a/public/app/plugins/panel/radialbar/suggestions.test.ts b/public/app/plugins/panel/radialbar/suggestions.test.ts new file mode 100644 index 00000000000..21a59267bbb --- /dev/null +++ b/public/app/plugins/panel/radialbar/suggestions.test.ts @@ -0,0 +1,151 @@ +import { createDataFrame, Field, FieldType, getPanelDataSummary } from '@grafana/data'; + +import { radialBarSuggestionsHandler } from './suggestions'; + +describe('RadialBarPanel Suggestions', () => { + it('does not suggest gauge if no data is present', () => { + expect(radialBarSuggestionsHandler(getPanelDataSummary([]))).toBeFalsy(); + expect(radialBarSuggestionsHandler(getPanelDataSummary(undefined))).toBeFalsy(); + expect( + radialBarSuggestionsHandler( + getPanelDataSummary([ + createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [] }, + { name: 'value', type: FieldType.number, values: [] }, + ], + }), + ]) + ) + ).toBeFalsy(); + }); + + it('does not suggest gauge if there are no numeric fields', () => { + const df = createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'status', type: FieldType.string }, + ], + }); + expect(radialBarSuggestionsHandler(getPanelDataSummary([df]))).toBeFalsy(); + }); + + it('does not suggest gauge if there are too many numeric fields', () => { + const fields: Field[] = []; + for (let i = 0; i < 20; i++) { + fields.push({ name: `numeric-${i}`, type: FieldType.number, values: [0, 100, 200, 300, 400, 500], config: {} }); + } + expect(radialBarSuggestionsHandler(getPanelDataSummary([createDataFrame({ fields })]))).toBeFalsy(); + }); + + it('suggests gauge for a single numeric field', () => { + expect( + radialBarSuggestionsHandler( + getPanelDataSummary([ + createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [0, 100, 200, 300, 400, 500] }, + { name: 'value', type: FieldType.number, values: [0, 100, 200, 300, 400, 500] }, + ], + }), + ]) + ) + ).toEqual([ + expect.objectContaining({ name: 'Gauge' }), + expect.objectContaining({ name: 'Circular gauge', options: expect.objectContaining({ shape: 'circle' }) }), + ]); + }); + + it('suggests gauge for a few numeric fields, with other fields mixed in', () => { + expect( + radialBarSuggestionsHandler( + getPanelDataSummary([ + createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [0, 100, 200, 300, 400, 500] }, + { name: 'value', type: FieldType.number, values: [0, 100, 200, 300, 400, 500] }, + { name: 'value2', type: FieldType.number, values: [0, 100, 200, 300, 400, 500] }, + { name: 'value3', type: FieldType.number, values: [0, 100, 200, 300, 400, 500] }, + { name: 'string', type: FieldType.string, values: ['foo', 'bar', null, 'bax', 'bop', 'bim'] }, + { name: 'boolean', type: FieldType.boolean, values: [true, false, true, false, true, false] }, + ], + }), + ]) + ) + ).toEqual([ + expect.objectContaining({ name: 'Gauge' }), + expect.objectContaining({ name: 'Circular gauge', options: expect.objectContaining({ shape: 'circle' }) }), + ]); + }); + + describe('aggregation', () => { + it.each([ + { + description: 'tabular data with few rows', + aggregated: false, + dataframes: [ + createDataFrame({ + fields: [ + { name: 'name', type: FieldType.string, values: ['A', 'B', 'C'] }, + { name: 'value', type: FieldType.number, values: [100, 200, 300] }, + ], + }), + ], + }, + { + description: 'tabular data with too many datapoints', + aggregated: true, + dataframes: [ + createDataFrame({ + fields: [ + { + name: 'string', + type: FieldType.string, + values: ['A', 'B', 'A', 'B', 'A', 'B', 'A', 'B', 'A', 'B', 'A'], + }, + { name: 'value', type: FieldType.number, values: [10, 20, 30, 40, 50, 60, 50, 40, 30, 20, 10] }, + ], + }), + ], + }, + { + description: 'only numeric data', + aggregated: true, + dataframes: [ + createDataFrame({ + fields: [{ name: 'value', type: FieldType.number, values: [10, 20, 30, 40, 50] }], + }), + ], + }, + { + description: 'multiple frames with tabular data', + aggregated: true, + dataframes: [ + createDataFrame({ + fields: [ + { name: 'name', type: FieldType.string, values: ['A', 'B', 'C'] }, + { name: 'value', type: FieldType.number, values: [100, 200, 300] }, + ], + }), + createDataFrame({ + fields: [ + { name: 'name', type: FieldType.string, values: ['D', 'E', 'F'] }, + { name: 'value', type: FieldType.number, values: [600, 700, 800] }, + ], + }), + ], + }, + ])('$description suggests aggregated=$aggregated', ({ dataframes, aggregated }) => { + const suggestions = radialBarSuggestionsHandler(getPanelDataSummary(dataframes)); + const expected = aggregated ? { values: false, calcs: ['lastNotNull'] } : { values: true, calcs: [] }; + if (Array.isArray(suggestions)) { + for (const suggestion of suggestions) { + expect(suggestion.options?.reduceOptions).toEqual(expected); + } + } else { + // this will fail if we're in this else case. + expect(suggestions).toBeInstanceOf(Array); + } + }); + }); +}); diff --git a/public/app/plugins/panel/radialbar/suggestions.ts b/public/app/plugins/panel/radialbar/suggestions.ts index 8471dc27cbe..35cde48ec0f 100644 --- a/public/app/plugins/panel/radialbar/suggestions.ts +++ b/public/app/plugins/panel/radialbar/suggestions.ts @@ -1,101 +1,87 @@ -import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { FieldColorModeId } from '@grafana/schema'; +import { defaultsDeep } from 'lodash'; + +import { + FieldColorModeId, + FieldType, + VisualizationSuggestion, + VisualizationSuggestionsSupplierFn, +} from '@grafana/data'; +import { t } from '@grafana/i18n'; import { GraphFieldConfig } from '@grafana/ui'; -import { SuggestionName } from 'app/types/suggestions'; import { Options } from './panelcfg.gen'; -export class GaugeSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; +export const radialBarSuggestionsHandler: VisualizationSuggestionsSupplierFn = ( + dataSummary +) => { + if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) { + return; + } - if (!dataSummary.hasData || !dataSummary.hasNumberField) { - return; - } + // for many fields / series this is probably not a good fit + if (dataSummary.fieldCountByType(FieldType.number) >= 10) { + return; + } - // for many fields / series this is probably not a good fit - if (dataSummary.numberFieldCount >= 10) { - return; - } - - const list = builder.getListAppender({ - name: SuggestionName.Gauge, - pluginId: 'gauge', - options: {}, + const withDefaults = ( + suggestion: VisualizationSuggestion + ): VisualizationSuggestion => { + // 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: {}, + defaults: isTabularData + ? { + color: { mode: FieldColorModeId.PaletteClassic }, + } + : {}, overrides: [], }, cardOptions: { previewModifier: (s) => { - if (s.options?.reduceOptions?.values) { - s.options.reduceOptions.limit = 2; + 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); + }; - if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10) { - list.append({ - name: SuggestionName.Gauge, - options: { - reduceOptions: { - values: true, - calcs: [], - }, - }, - }); - list.append({ - name: SuggestionName.GaugeCircular, - options: { - shape: 'circle', - showThresholdMarkers: false, - reduceOptions: { - values: true, - calcs: [], - }, - }, - fieldConfig: { - defaults: { - color: { mode: FieldColorModeId.PaletteClassic }, - }, - overrides: [], - }, - }); - } else { - list.append({ - name: SuggestionName.Gauge, - options: { - reduceOptions: { - values: false, - calcs: ['lastNotNull'], - }, - }, - }); - list.append({ - name: SuggestionName.GaugeCircular, - options: { - shape: 'circle', - showThresholdMarkers: false, - barWidthFactor: 0.3, - effects: { - rounded: true, - barGlow: true, - centerGlow: true, - spotlight: true, - }, - reduceOptions: { - values: false, - calcs: ['lastNotNull'], - }, - }, - fieldConfig: { - defaults: { - color: { mode: FieldColorModeId.PaletteClassic }, - }, - overrides: [], - }, - }); - } - } -} + return [ + withDefaults({ name: t('gauge.suggestions.arc', 'Gauge') }), + withDefaults({ + name: t('gauge.suggestions.circular', 'Circular gauge'), + options: { + shape: 'circle', + showThresholdMarkers: false, + barWidthFactor: 0.3, + }, + }), + ]; +}; diff --git a/public/app/plugins/panel/state-timeline/module.tsx b/public/app/plugins/panel/state-timeline/module.tsx index 61053b28090..55fd557eea0 100644 --- a/public/app/plugins/panel/state-timeline/module.tsx +++ b/public/app/plugins/panel/state-timeline/module.tsx @@ -8,6 +8,7 @@ import { import { t } from '@grafana/i18n'; import { AxisPlacement, VisibilityMode } from '@grafana/schema'; import { commonOptionsBuilder } from '@grafana/ui'; +import { showDefaultSuggestion } from 'app/features/panel/suggestions/utils'; import { InsertNullsEditor } from '../timeseries/InsertNullsEditor'; import { SpanNullsEditor } from '../timeseries/SpanNullsEditor'; @@ -16,7 +17,6 @@ import { NullEditorSettings } from '../timeseries/config'; import { StateTimelinePanel } from './StateTimelinePanel'; import { timelinePanelChangedHandler } from './migrations'; import { defaultFieldConfig, defaultOptions, FieldConfig, Options } from './panelcfg.gen'; -import { StatTimelineSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(StateTimelinePanel) .setPanelChangeHandler(timelinePanelChangedHandler) @@ -157,5 +157,31 @@ export const plugin = new PanelPlugin(StateTimelinePanel) commonOptionsBuilder.addLegendOptions(builder, false); commonOptionsBuilder.addTooltipOptions(builder); }) - .setSuggestionsSupplier(new StatTimelineSuggestionsSupplier()) + .setSuggestionsSupplier( + showDefaultSuggestion((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; + } + + // Probably better ways to filter out this by inspecting the types of string values so view this as temporary + if (ds.preferredVisualisationType === 'logs') { + return; + } + + return true; + }) + ) .setDataSupport({ annotations: true }); diff --git a/public/app/plugins/panel/state-timeline/suggestions.ts b/public/app/plugins/panel/state-timeline/suggestions.ts deleted file mode 100644 index f98c1ea7273..00000000000 --- a/public/app/plugins/panel/state-timeline/suggestions.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { SuggestionName } from 'app/types/suggestions'; - -import { FieldConfig, Options } from './panelcfg.gen'; - -export class StatTimelineSuggestionsSupplier { - 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; - } - - // 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({ - name: '', - pluginId: 'state-timeline', - options: {}, - fieldConfig: { - defaults: { - custom: {}, - }, - overrides: [], - }, - }); - - list.append({ name: SuggestionName.StateTimeline }); - } -} diff --git a/public/app/plugins/panel/timeseries/suggestions.ts b/public/app/plugins/panel/timeseries/suggestions.ts index 08c2dfc41f7..8ca7d6148ed 100644 --- a/public/app/plugins/panel/timeseries/suggestions.ts +++ b/public/app/plugins/panel/timeseries/suggestions.ts @@ -1,8 +1,8 @@ import { FieldColorModeId, VisualizationSuggestionsBuilder, - VisualizationSuggestion, DataTransformerID, + PanelPluginVisualizationSuggestion, } from '@grafana/data'; import { GraphDrawStyle, @@ -218,7 +218,7 @@ export class TimeSeriesSuggestionsSupplier { } // This will try to get a suggestion that will add a long to wide conversion -export function getPrepareTimeseriesSuggestion(panelId: number): VisualizationSuggestion | undefined { +export function getPrepareTimeseriesSuggestion(panelId: number): PanelPluginVisualizationSuggestion | undefined { const panel = getDashboardSrv().getCurrent()?.getPanelById(panelId); if (panel) { const transformations = panel.transformations ? [...panel.transformations] : []; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 305c35ceb3e..a01604136df 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Show threshold labels", "name-show-threshold-markers": "Show threshold markers", "placeholder-neutral": "auto", + "suggestions": { + "arc": "Gauge", + "circular": "Circular gauge", + "style": { + "circular": "Glowing", + "simple": "Simple" + } + }, "threshold": "Threshold {{value}}" }, "gen-ai": {