diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 85813a99f6d..e656a39e6a1 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -120,7 +120,7 @@ export class PanelEditor extends SceneObjectBase { dataObject.subscribeToState(async () => { const { data } = dataObject.state; if (hasData(data) && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) { - const suggestions = await getAllSuggestions(data); + const { suggestions } = await getAllSuggestions(data); if (suggestions.length > 0) { const defaultFirstSuggestion = suggestions[0]; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index e93e74f358d..d1763ad835f 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; -import { useAsync, useMeasure } from 'react-use'; +import { useAsyncRetry, useMeasure } from 'react-use'; import { GrafanaTheme2, @@ -28,19 +28,23 @@ export interface Props { panel?: PanelModel; } +const useSuggestions = (data: PanelData | undefined) => { + const [hasFetched, setHasFetched] = useState(false); + const { value, loading, error, retry } = useAsyncRetry(async () => { + await new Promise((resolve) => setTimeout(resolve, hasFetched ? 75 : 0)); + setHasFetched(true); + return await getAllSuggestions(data); + }, [hasFetched, data]); + return { value, loading, error, retry }; +}; + export function VisualizationSuggestions({ onChange, data, panel }: Props) { const styles = useStyles2(getStyles); - const { - value: suggestions, - loading, - error, - } = useAsync(async () => { - if (!hasData(data)) { - return []; - } - return await getAllSuggestions(data); - }, [data]); + const { value: result, loading, error, retry } = useSuggestions(data); + + const suggestions = result?.suggestions; + const hasLoadingErrors = result?.hasErrors ?? false; const [suggestionHash, setSuggestionHash] = useState(null); const [firstCardRef, { width }] = useMeasure(); const [firstCardHash, setFirstCardHash] = useState(null); @@ -131,80 +135,97 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) { } return ( -
- {isNewVizSuggestionsEnabled - ? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => ( - -
- - {vizType?.info && } - {vizType?.name || t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')} - -
- {vizTypeSuggestions?.map((suggestion, index) => { - const isCardSelected = suggestionHash === suggestion.hash; - return ( -
{ - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault(); - applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected); - } - }} - ref={index === 0 ? firstCardRef : undefined} - > - {isCardSelected && ( - +
+ + )} +
+ {isNewVizSuggestionsEnabled + ? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => ( + +
+ + {vizType?.info && } + {vizType?.name || + t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')} + +
+ {vizTypeSuggestions?.map((suggestion, index) => { + const isCardSelected = suggestionHash === suggestion.hash; + return ( +
{ + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected); } - > - {t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')} - - )} - applySuggestion(suggestion, true)} - /> -
- ); - })} -
- )) - : suggestions?.map((suggestion, index) => ( -
- applySuggestion(suggestion)} - /> -
- ))} -
+ }} + ref={index === 0 ? firstCardRef : undefined} + > + {isCardSelected && ( + + )} + applySuggestion(suggestion, true)} + /> +
+ ); + })} + + )) + : suggestions?.map((suggestion, index) => ( +
+ applySuggestion(suggestion)} + /> +
+ ))} + + ); } @@ -217,6 +238,11 @@ const getStyles = (theme: GrafanaTheme2) => { width: '100%', marginTop: theme.spacing(6), }), + alertContent: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }), filterRow: css({ display: 'flex', flexDirection: 'row', diff --git a/public/app/features/panel/suggestions/consts.ts b/public/app/features/panel/suggestions/consts.ts index 76e012a7425..decdf8ac7b4 100644 --- a/public/app/features/panel/suggestions/consts.ts +++ b/public/app/features/panel/suggestions/consts.ts @@ -16,4 +16,5 @@ export const panelsToCheckFirst = [ 'heatmap', 'histogram', 'geomap', + 'text', ]; diff --git a/public/app/features/panel/suggestions/getAllSuggestions.test.ts b/public/app/features/panel/suggestions/getAllSuggestions.test.ts index e76a7df15e3..8657824cc27 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.test.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.test.ts @@ -1,4 +1,5 @@ import { + AppEvents, DataFrame, FieldType, getDefaultTimeRange, @@ -18,10 +19,20 @@ import { StackingMode, VizOrientation, } from '@grafana/schema'; +import { appEvents } from 'app/core/app_events'; import { config } from 'app/core/config'; +import { clearPanelPluginCache } from 'app/features/plugins/importPanelPlugin'; +import { pluginImporter } from 'app/features/plugins/importer/pluginImporter'; import { panelsToCheckFirst } from './consts'; -import { getAllSuggestions, sortSuggestions } from './getAllSuggestions'; +import { getAllSuggestions, loadPlugins, sortSuggestions } from './getAllSuggestions'; + +jest.mock('app/core/app_events', () => ({ + appEvents: { + subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })), + publish: jest.fn(), + }, +})); config.featureToggles.externalVizSuggestions = true; @@ -52,28 +63,6 @@ for (const pluginId of panelsToCheckFirst) { }; } -config.panels.text = { - id: 'text', - module: 'core:plugin/text', - sort: idx++, - name: 'Text', - type: PluginType.panel, - baseUrl: 'public/app/plugins/panel', - skipDataQuery: true, - suggestions: false, - info: { - version: '1.0.0', - updated: '2025-01-01', - links: [], - screenshots: [], - author: { - name: 'Grafana Labs', - }, - description: 'Text panel', - logos: { small: 'small/logo', large: 'large/logo' }, - }, -}; - jest.mock('../state/util', () => { const originalModule = jest.requireActual('../state/util'); return { @@ -103,7 +92,8 @@ class ScenarioContext { timeRange: getDefaultTimeRange(), }; - this.suggestions = await getAllSuggestions(panelData); + const result = await getAllSuggestions(panelData); + this.suggestions = result.suggestions; } names() { @@ -554,6 +544,81 @@ describe('sortSuggestions', () => { }); }); +describe('Visualization suggestions error handling', () => { + it('returns result with hasErrors flag', async () => { + const result = await getAllSuggestions({ + series: [ + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2] }, + { name: 'Max', type: FieldType.number, values: [1, 10] }, + ], + }), + ], + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + }); + + expect(result).toHaveProperty('suggestions'); + expect(result).toHaveProperty('hasErrors'); + expect(result.hasErrors).toBe(false); + }); +}); + +// this needs to happen before any +describe('loadPlugins', () => { + beforeEach(() => { + clearPanelPluginCache(); + }); + + afterEach(() => { + if (jest.isMockFunction(pluginImporter.importPanel)) { + jest.mocked(pluginImporter.importPanel).mockRestore(); + } + }); + + it('should swallow errors when failing to load core plugins', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + const _importPanel = pluginImporter.importPanel; + jest.spyOn(pluginImporter, 'importPanel').mockImplementation(async (meta) => { + if (meta.id === 'timeseries') { + throw new Error('Failed to load core panel plugin'); + } + return await _importPanel(meta); + }); + + const panelIds = ['timeseries', 'table']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins).toEqual([expect.objectContaining({ meta: expect.objectContaining({ id: 'table' }) })]); + expect(hasErrors).toBe(true); + expect(appEvents.publish).not.toHaveBeenCalled(); + }); + + it('should swallow errors when failing to load external plugins', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + const panelIds = ['non-existent-panel']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins).toEqual([]); + expect(hasErrors).toBe(false); + expect(appEvents.publish).toHaveBeenCalledWith({ + type: AppEvents.alertError.name, + payload: [expect.stringContaining('Failed to load panel plugin: non-existent-panel.')], + }); + }); + + it('should load panel plugins with suggestions', async () => { + const panelIds = ['timeseries', 'table']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins.map((p) => p.meta.id)).toEqual(expect.arrayContaining(['timeseries', 'table'])); + expect(hasErrors).toBe(false); + }); +}); + function repeatFrame(count: number, frame: DataFrame): DataFrame[] { const frames: DataFrame[] = []; for (let i = 0; i < count; i++) { diff --git a/public/app/features/panel/suggestions/getAllSuggestions.ts b/public/app/features/panel/suggestions/getAllSuggestions.ts index ea94add39ab..d0bb73dfb83 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.ts @@ -1,4 +1,5 @@ import { + AppEvents, getPanelDataSummary, PanelData, PanelDataSummary, @@ -7,41 +8,67 @@ import { PreferredVisualisationType, VisualizationSuggestionScore, } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; +import { appEvents } from 'app/core/app_events'; import { importPanelPlugin, isBuiltInPlugin } from 'app/features/plugins/importPanelPlugin'; import { getAllPanelPluginMeta } from '../state/util'; import { panelsToCheckFirst } from './consts'; -/** - * gather and cache the plugins which provide visualization suggestions so they can be invoked to build suggestions - */ -async function getPanelsWithSuggestions(): Promise { - // list of plugins to load is determined by the feature flag - const pluginIds: string[] = config.featureToggles.externalVizSuggestions +interface PluginLoadResult { + plugins: PanelPlugin[]; + hasErrors: boolean; +} + +function getPanelPluginIds(): string[] { + return config.featureToggles.externalVizSuggestions ? getAllPanelPluginMeta() .filter((panel) => panel.suggestions) .map((m) => m.id) : panelsToCheckFirst; +} +/** + * gather and cache the plugins which provide visualization suggestions so they can be invoked to build suggestions + */ +export async function loadPlugins(pluginIds: string[]): Promise { // import the plugins in parallel using Promise.allSettled const plugins: PanelPlugin[] = []; - const settledPromises = await Promise.allSettled(pluginIds.map((id) => importPanelPlugin(id))); + let hasErrors = false; + const settledPromises = await Promise.allSettled( + pluginIds.map(async (pluginId) => { + return await importPanelPlugin(pluginId); + }) + ); + for (let i = 0; i < settledPromises.length; i++) { const settled = settledPromises[i]; - if (settled.status === 'fulfilled') { plugins.push(settled.value); + } else { + const pluginId = pluginIds[i]; + console.error(`Failed to load ${pluginId} for visualization suggestions:`, settled.reason); + + if (isBuiltInPlugin(pluginId)) { + hasErrors = true; + } else { + appEvents.publish({ + type: AppEvents.alertError.name, + payload: [ + t( + 'panel.visualization-suggestions.error-loading-suggestions.plugin-failed', + 'Failed to load panel plugin: {{ pluginId }}.', + { pluginId } + ), + ], + }); + } } - // TODO: do we want to somehow log if there were errors loading some of the plugins? } - if (plugins.length === 0) { - throw new Error('No panel plugins with visualization suggestions found'); - } - - return plugins; + return { plugins, hasErrors }; } /** @@ -89,41 +116,37 @@ export function sortSuggestions(suggestions: PanelPluginVisualizationSuggestion[ }); } +export interface SuggestionsResult { + suggestions: PanelPluginVisualizationSuggestion[]; + hasErrors: boolean; +} + /** * given PanelData, return a sorted list of Suggestions from all plugins which support it. * @param {PanelData} data queried and transformed data for the panel - * @returns {PanelPluginVisualizationSuggestion[]} sorted list of suggestions + * @returns {SuggestionsResult} sorted list of suggestions and error status */ -export async function getAllSuggestions(data?: PanelData): Promise { +export async function getAllSuggestions(data?: PanelData): Promise { const dataSummary = getPanelDataSummary(data?.series); const list: PanelPluginVisualizationSuggestion[] = []; - for (const plugin of await getPanelsWithSuggestions()) { - const suggestions = plugin.getSuggestions(dataSummary); - if (suggestions) { - list.push(...suggestions); - } - } + const pluginIds: string[] = getPanelPluginIds(); + const { plugins, hasErrors: pluginLoadErrors } = await loadPlugins(pluginIds); - if (dataSummary.fieldCount === 0) { - for (const plugin of Object.values(config.panels)) { - if (!plugin.skipDataQuery || plugin.hideFromList) { - continue; + let pluginSuggestionsError = false; + for (const plugin of plugins) { + try { + const suggestions = plugin.getSuggestions(dataSummary); + if (suggestions) { + list.push(...suggestions); } - - list.push({ - name: plugin.name, - pluginId: plugin.id, - description: plugin.info.description, - hash: 'plugin-empty-' + plugin.id, - cardOptions: { - imgSrc: plugin.info.logos.small, - }, - }); + } catch (e) { + console.warn(`error when loading suggestions from plugin "${plugin.meta.id}"`, e); + pluginSuggestionsError = true; } } sortSuggestions(list, dataSummary); - return list; + return { suggestions: list, hasErrors: pluginLoadErrors || pluginSuggestionsError }; } diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index e541b8f7893..ef8d955f562 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -62,3 +62,9 @@ export function syncGetPanelPlugin(id: string): PanelPlugin | undefined { function getPanelPlugin(meta: PanelPluginMeta): Promise { return pluginImporter.importPanel(meta); } + +export function clearPanelPluginCache(): void { + for (const key of Object.keys(promiseCache)) { + delete promiseCache[key]; + } +} diff --git a/public/app/plugins/panel/table/suggestions.ts b/public/app/plugins/panel/table/suggestions.ts index 8e0d6e44953..260e73b43eb 100644 --- a/public/app/plugins/panel/table/suggestions.ts +++ b/public/app/plugins/panel/table/suggestions.ts @@ -1,4 +1,5 @@ import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data'; +import { config } from 'app/core/config'; import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg'; import { Options, FieldConfig } from './panelcfg.gen'; @@ -29,7 +30,7 @@ export const tableSuggestionsSupplier: VisualizationSuggestionsSupplier(TextPanel) defaultValue: defaultOptions.content, }); }) - .setMigrationHandler(textPanelMigrationHandler); + .setMigrationHandler(textPanelMigrationHandler) + .setSuggestionsSupplier((ds) => + ds.fieldCount === 0 && !config.featureToggles.newVizSuggestions + ? [{ cardOptions: { imgSrc: icnTextPanelSvg } }] + : [] + ); diff --git a/public/app/plugins/panel/text/plugin.json b/public/app/plugins/panel/text/plugin.json index ce437e97bf5..a1acce42c6e 100644 --- a/public/app/plugins/panel/text/plugin.json +++ b/public/app/plugins/panel/text/plugin.json @@ -2,7 +2,7 @@ "type": "panel", "name": "Text", "id": "text", - + "suggestions": true, "skipDataQuery": true, "info": { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 9678b91e619..a4e39d534a3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11216,9 +11216,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "Apply {{suggestionName}} visualization", + "error-loading-some-suggestions": { + "message": "Some suggestions could not be loaded" + }, "error-loading-suggestions": { "message": "An error occurred when loading visualization suggestions.", - "title": "Error" + "plugin-failed": "Failed to load panel plugin: {{ pluginId }}.", + "title": "Error", + "try-again-button": "Try again" }, "unknown-viz-type": "Unknown visualization type", "use-this-suggestion": "Use this suggestion"