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
This commit is contained in:
Paul Marbach
2025-11-18 18:49:29 +01:00
committed by GitHub
parent a636e1dc8e
commit 92ac33736b
18 changed files with 456 additions and 206 deletions
+2
View File
@@ -715,6 +715,8 @@ export {
export {
type VisualizationSuggestion,
type VisualizationSuggestionsSupplier,
type VisualizationSuggestionsSupplierFn,
type PanelPluginVisualizationSuggestion,
VisualizationSuggestionScore,
VisualizationSuggestionsBuilder,
VisualizationSuggestionsListAppender,
+36 -5
View File
@@ -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<TOptions, TFieldConfigOptions>): this;
setSuggestionsSupplier(
supplier: VisualizationSuggestionsSupplier | VisualizationSuggestionsSupplierFn<TOptions, TFieldConfigOptions>
): this {
this.suggestionsSupplier =
typeof supplier === 'function'
? {
getSuggestionsForData: (builder) => {
const appender = builder.getListAppender<TOptions, TFieldConfigOptions>({
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;
}
+55 -19
View File
@@ -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<TOptions = any, TFieldConfig = any> {
export interface VisualizationSuggestion<TOptions extends unknown = {}, TFieldConfig extends {} = {}> {
/** Name of suggestion */
name: string;
name?: string;
/** Description */
description?: string;
/** Panel plugin id */
pluginId: string;
/** Panel plugin options */
options?: Partial<TOptions>;
/** Panel plugin field options */
fieldConfig?: FieldConfigSource<Partial<TFieldConfig>>;
/** 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<TOptions, TFieldConfig>) => 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<TOptions extends unknown = {}, TFieldConfig extends {} = {}>
extends VisualizationSuggestion<TOptions, TFieldConfig> {
/** 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<TOptions, TFieldConfig>(defaults: VisualizationSuggestion<TOptions, TFieldConfig>) {
getListAppender<TOptions extends unknown, TFieldConfig extends {} = {}>(
defaults: PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>
) {
return new VisualizationSuggestionsListAppender<TOptions, TFieldConfig>(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<TOptions extends unknown, TFieldConfig extends {} = {}> = (
panelDataSummary: PanelDataSummary
) => Array<VisualizationSuggestion<TOptions, TFieldConfig>> | 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<TOptions, TFieldConfig> {
export class VisualizationSuggestionsListAppender<TOptions extends unknown, TFieldConfig extends {} = {}> {
constructor(
private list: VisualizationSuggestion[],
private defaults: VisualizationSuggestion<TOptions, TFieldConfig>
private defaults: Partial<PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>> = {}
) {}
append(overrides: Partial<VisualizationSuggestion<TOptions, TFieldConfig>>) {
this.list.push(defaultsDeep(overrides, this.defaults));
append(suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) {
this.list.push(defaultsDeep(suggestion, this.defaults));
}
appendAll(suggestions: Array<VisualizationSuggestion<TOptions, TFieldConfig>>) {
this.list.push(...suggestions.map((o) => defaultsDeep(o, this.defaults)));
}
}
@@ -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[];
}
/**
@@ -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;
}
@@ -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;
}
@@ -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 || [];
}
@@ -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<VisualizationSuggestion<{ reduceOptions?: ReduceDataOptions }, GraphFieldConfig>> = [];
suggestions: Array<PanelPluginVisualizationSuggestion<{ reduceOptions?: ReduceDataOptions }, GraphFieldConfig>> = [];
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 }),
]);
});
@@ -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<VisualizationSuggestion[]> {
export async function getAllSuggestions(
data?: PanelData,
panel?: PanelModel
): Promise<PanelPluginVisualizationSuggestion[]> {
const builder = new VisualizationSuggestionsBuilder(data, panel);
for (const pluginId of panelsToCheckFirst) {
@@ -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();
});
});
});
@@ -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);
}
@@ -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<Options>(RadialBarPanel)
.useFieldConfig({})
@@ -112,6 +112,6 @@ export const plugin = new PanelPlugin<Options>(RadialBarPanel)
defaultValue: defaultGaugePanelEffects,
});
})
.setSuggestionsSupplier(new GaugeSuggestionsSupplier())
.setSuggestionsSupplier(radialBarSuggestionsHandler)
.setMigrationHandler(gaugePanelMigrationHandler, shouldMigrateGauge)
.setPanelChangeHandler(gaugePanelChangedHandler);
@@ -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);
}
});
});
});
@@ -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<Options, GraphFieldConfig> = (
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<Options, GraphFieldConfig>({
name: SuggestionName.Gauge,
pluginId: 'gauge',
options: {},
const withDefaults = (
suggestion: VisualizationSuggestion<Options, GraphFieldConfig>
): 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: {},
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<Options, GraphFieldConfig>);
};
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,
},
}),
];
};
@@ -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<Options, FieldConfig>(StateTimelinePanel)
.setPanelChangeHandler(timelinePanelChangedHandler)
@@ -157,5 +157,31 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(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 });
@@ -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<Options, FieldConfig>({
name: '',
pluginId: 'state-timeline',
options: {},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
});
list.append({ name: SuggestionName.StateTimeline });
}
}
@@ -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] : [];
+8
View File
@@ -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": {