VizSuggestions: Error handling (#115428)

* error handling

* retry fetching suggestions

* add translation

* useAsyncRetry

* hasError test

* update error handling

* clean up the text panel stuff for the current version

* cleanup for loop

* some more tests for some failure cases

* fix lint issue

---------

Co-authored-by: Paul Marbach <paul.marbach@grafana.com>
This commit is contained in:
Adela Almasan
2025-12-19 20:22:26 +00:00
committed by GitHub
co-authored by Paul Marbach
parent 2fbe2f77e3
commit 3522efdf32
10 changed files with 284 additions and 150 deletions
@@ -120,7 +120,7 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
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];
@@ -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<string | null>(null);
const [firstCardRef, { width }] = useMeasure<HTMLDivElement>();
const [firstCardHash, setFirstCardHash] = useState<string | null>(null);
@@ -131,80 +135,97 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
}
return (
<div className={styles.grid}>
{isNewVizSuggestionsEnabled
? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => (
<Fragment key={vizType?.id || `unknown-viz-type-${groupIndex}`}>
<div className={styles.vizTypeHeader}>
<Text variant="body" weight="medium">
{vizType?.info && <img className={styles.vizTypeLogo} src={vizType.info.logos.small} alt="" />}
{vizType?.name || t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')}
</Text>
</div>
{vizTypeSuggestions?.map((suggestion, index) => {
const isCardSelected = suggestionHash === suggestion.hash;
return (
<div
key={suggestion.hash}
className={styles.cardContainer}
tabIndex={0}
role="button"
aria-pressed={isCardSelected}
onKeyDown={(ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected);
}
}}
ref={index === 0 ? firstCardRef : undefined}
>
{isCardSelected && (
<Button
// rather than allow direct focus, we handle ketboard events in the card.
tabIndex={-1}
variant="primary"
size={'md'}
className={styles.applySuggestionButton}
aria-label={t(
'panel.visualization-suggestions.apply-suggestion-aria-label',
'Apply {{suggestionName}} visualization',
{ suggestionName: suggestion.name }
)}
onClick={() =>
onChange({
pluginId: suggestion.pluginId,
withModKey: false,
})
<>
{hasLoadingErrors && (
<Alert severity="warning" title={''}>
<div className={styles.alertContent}>
<Trans i18nKey="panel.visualization-suggestions.error-loading-some-suggestions.message">
Some suggestions could not be loaded
</Trans>
<Button variant="secondary" size="sm" onClick={retry}>
<Trans i18nKey="panel.visualization-suggestions.error-loading-suggestions.try-again-button">
Try again
</Trans>
</Button>
</div>
</Alert>
)}
<div className={styles.grid}>
{isNewVizSuggestionsEnabled
? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => (
<Fragment key={vizType?.id || `unknown-viz-type-${groupIndex}`}>
<div className={styles.vizTypeHeader}>
<Text variant="body" weight="medium">
{vizType?.info && <img className={styles.vizTypeLogo} src={vizType.info.logos.small} alt="" />}
{vizType?.name ||
t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')}
</Text>
</div>
{vizTypeSuggestions?.map((suggestion, index) => {
const isCardSelected = suggestionHash === suggestion.hash;
return (
<div
key={suggestion.hash}
className={styles.cardContainer}
tabIndex={0}
role="button"
aria-pressed={isCardSelected}
onKeyDown={(ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected);
}
>
{t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')}
</Button>
)}
<VisualizationSuggestionCard
data={data}
suggestion={suggestion}
width={width}
isSelected={isCardSelected}
onClick={() => applySuggestion(suggestion, true)}
/>
</div>
);
})}
</Fragment>
))
: suggestions?.map((suggestion, index) => (
<div key={suggestion.hash} className={styles.cardContainer} ref={index === 0 ? firstCardRef : undefined}>
<VisualizationSuggestionCard
key={index}
data={data}
suggestion={suggestion}
width={width}
tabIndex={index}
onClick={() => applySuggestion(suggestion)}
/>
</div>
))}
</div>
}}
ref={index === 0 ? firstCardRef : undefined}
>
{isCardSelected && (
<Button
// rather than allow direct focus, we handle ketboard events in the card.
tabIndex={-1}
variant="primary"
size={'md'}
className={styles.applySuggestionButton}
aria-label={t(
'panel.visualization-suggestions.apply-suggestion-aria-label',
'Apply {{suggestionName}} visualization',
{ suggestionName: suggestion.name }
)}
onClick={() =>
onChange({
pluginId: suggestion.pluginId,
withModKey: false,
})
}
>
{t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')}
</Button>
)}
<VisualizationSuggestionCard
data={data}
suggestion={suggestion}
width={width}
isSelected={isCardSelected}
onClick={() => applySuggestion(suggestion, true)}
/>
</div>
);
})}
</Fragment>
))
: suggestions?.map((suggestion, index) => (
<div key={suggestion.hash} className={styles.cardContainer} ref={index === 0 ? firstCardRef : undefined}>
<VisualizationSuggestionCard
key={index}
data={data}
suggestion={suggestion}
width={width}
tabIndex={index}
onClick={() => applySuggestion(suggestion)}
/>
</div>
))}
</div>
</>
);
}
@@ -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',
@@ -16,4 +16,5 @@ export const panelsToCheckFirst = [
'heatmap',
'histogram',
'geomap',
'text',
];
@@ -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++) {
@@ -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<PanelPlugin[]> {
// 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<PluginLoadResult> {
// 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<PanelPluginVisualizationSuggestion[]> {
export async function getAllSuggestions(data?: PanelData): Promise<SuggestionsResult> {
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 };
}
@@ -62,3 +62,9 @@ export function syncGetPanelPlugin(id: string): PanelPlugin | undefined {
function getPanelPlugin(meta: PanelPluginMeta): Promise<PanelPlugin> {
return pluginImporter.importPanel(meta);
}
export function clearPanelPluginCache(): void {
for (const key of Object.keys(promiseCache)) {
delete promiseCache[key];
}
}
@@ -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<Options,
},
// If there is no data, suggest table anyway, but use icon instead of real preview
// TODO: delete this in once "new" suggestions are fully rolled out
imgSrc: dataSummary.fieldCount === 0 ? icnTablePanelSvg : undefined,
imgSrc: dataSummary.fieldCount === 0 && !config.featureToggles.newVizSuggestions ? icnTablePanelSvg : undefined,
},
},
];
+8 -1
View File
@@ -1,8 +1,10 @@
import { PanelPlugin } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { TextPanel } from './TextPanel';
import { TextPanelEditor } from './TextPanelEditor';
import icnTextPanelSvg from './img/icn-text-panel.svg';
import { CodeLanguage, defaultCodeOptions, defaultOptions, Options, TextMode } from './panelcfg.gen';
import { textPanelMigrationHandler } from './textPanelMigrationHandler';
@@ -59,4 +61,9 @@ export const plugin = new PanelPlugin<Options>(TextPanel)
defaultValue: defaultOptions.content,
});
})
.setMigrationHandler(textPanelMigrationHandler);
.setMigrationHandler(textPanelMigrationHandler)
.setSuggestionsSupplier((ds) =>
ds.fieldCount === 0 && !config.featureToggles.newVizSuggestions
? [{ cardOptions: { imgSrc: icnTextPanelSvg } }]
: []
);
+1 -1
View File
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Text",
"id": "text",
"suggestions": true,
"skipDataQuery": true,
"info": {
+6 -1
View File
@@ -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"