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:
@@ -715,6 +715,8 @@ export {
|
||||
export {
|
||||
type VisualizationSuggestion,
|
||||
type VisualizationSuggestionsSupplier,
|
||||
type VisualizationSuggestionsSupplierFn,
|
||||
type PanelPluginVisualizationSuggestion,
|
||||
VisualizationSuggestionScore,
|
||||
VisualizationSuggestionsBuilder,
|
||||
VisualizationSuggestionsListAppender,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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] : [];
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user