From 09c7190bfe6b58c827d71b12224a13379ef43778 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Tue, 1 Aug 2023 10:49:52 +0200 Subject: [PATCH] Flamegraph: Add nice empty state for dashboard panel (#72583) --- .../panels_smokescreen.spec.ts | 2 +- .../panel/flamegraph/FlameGraphPanel.tsx | 8 +++ .../FlameGraph/dataTransform.test.ts | 6 +- .../components/FlameGraph/dataTransform.ts | 71 +++++++++++++++++-- .../components/FlameGraph/rendering.test.ts | 3 +- .../components/FlameGraph/testHelpers.ts | 4 +- .../plugins/panel/flamegraph/suggestions.ts | 13 ++-- 7 files changed, 89 insertions(+), 18 deletions(-) diff --git a/e2e/smoke-tests-suite/panels_smokescreen.spec.ts b/e2e/smoke-tests-suite/panels_smokescreen.spec.ts index a914014066f..139e4e9502b 100644 --- a/e2e/smoke-tests-suite/panels_smokescreen.spec.ts +++ b/e2e/smoke-tests-suite/panels_smokescreen.spec.ts @@ -28,7 +28,7 @@ e2e.scenario({ // Loop through every panel type and ensure no crash Object.entries(win.grafanaBootData.settings.panels).forEach(([_, panel]) => { // TODO: Remove Flame Graph check as part of addressing #66803 - if (!panel.hideFromList && panel.state !== 'deprecated' && panel.name !== 'Flame Graph') { + if (!panel.hideFromList && panel.state !== 'deprecated') { e2e.components.PanelEditor.toggleVizPicker().click(); e2e.components.PluginVisualization.item(panel.name).scrollIntoView().should('be.visible').click(); diff --git a/public/app/plugins/panel/flamegraph/FlameGraphPanel.tsx b/public/app/plugins/panel/flamegraph/FlameGraphPanel.tsx index 5f3212f6e69..ba1e13a2674 100644 --- a/public/app/plugins/panel/flamegraph/FlameGraphPanel.tsx +++ b/public/app/plugins/panel/flamegraph/FlameGraphPanel.tsx @@ -1,9 +1,17 @@ import React from 'react'; import { CoreApp, PanelProps } from '@grafana/data'; +import { PanelDataErrorView } from '@grafana/runtime'; +import { checkFields, getMessageCheckFieldsResult } from './components/FlameGraph/dataTransform'; import FlameGraphContainer from './components/FlameGraphContainer'; export const FlameGraphPanel = (props: PanelProps) => { + const wrongFields = checkFields(props.data.series[0]); + if (wrongFields) { + return ( + + ); + } return ; }; diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.test.ts b/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.test.ts index d9e28bb2403..397db71d77d 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.test.ts +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.test.ts @@ -1,4 +1,4 @@ -import { createDataFrame } from '@grafana/data'; +import { createDataFrame, FieldType } from '@grafana/data'; import { FlameGraphDataContainer, LevelItem, nestedSetToLevels } from './dataTransform'; @@ -13,7 +13,7 @@ describe('nestedSetToLevels', () => { fields: [ { name: 'level', values: [0, 1, 2, 3, 2, 1, 2, 3, 4] }, { name: 'value', values: [10, 5, 3, 1, 1, 4, 3, 2, 1] }, - { name: 'label', values: ['1', '2', '3', '4', '5', '6', '7', '8', '9'] }, + { name: 'label', values: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], type: FieldType.string }, { name: 'self', values: [0, 0, 0, 0, 0, 0, 0, 0, 0] }, ], }); @@ -50,7 +50,7 @@ describe('nestedSetToLevels', () => { fields: [ { name: 'level', values: [0, 1, 1, 1] }, { name: 'value', values: [10, 5, 3, 1] }, - { name: 'label', values: ['1', '2', '3', '4'] }, + { name: 'label', values: ['1', '2', '3', '4'], type: FieldType.string }, { name: 'self', values: [10, 5, 3, 1] }, ], }); diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.ts b/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.ts index a272e962863..ac100c5c2cf 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.ts +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.ts @@ -1,4 +1,12 @@ -import { createTheme, DataFrame, DisplayProcessor, Field, getDisplayProcessor, GrafanaTheme2 } from '@grafana/data'; +import { + createTheme, + DataFrame, + DisplayProcessor, + Field, + FieldType, + getDisplayProcessor, + GrafanaTheme2, +} from '@grafana/data'; import { SampleUnit } from '../types'; @@ -69,6 +77,57 @@ export function nestedSetToLevels(container: FlameGraphDataContainer): [LevelIte return [levels, uniqueLabels]; } +export function getMessageCheckFieldsResult(wrongFields: CheckFieldsResult) { + if (wrongFields.missingFields.length) { + return `Data is missing fields: ${wrongFields.missingFields.join(', ')}`; + } + + if (wrongFields.wrongTypeFields.length) { + return `Data has fields of wrong type: ${wrongFields.wrongTypeFields + .map((f) => `${f.name} has type ${f.type} but should be ${f.expectedTypes.join(' or ')}`) + .join(', ')}`; + } + + return ''; +} + +export type CheckFieldsResult = { + wrongTypeFields: Array<{ name: string; expectedTypes: FieldType[]; type: FieldType }>; + missingFields: string[]; +}; + +export function checkFields(data: DataFrame): CheckFieldsResult | undefined { + const fields: Array<[string, FieldType[]]> = [ + ['label', [FieldType.string, FieldType.enum]], + ['level', [FieldType.number]], + ['value', [FieldType.number]], + ['self', [FieldType.number]], + ]; + + const missingFields = []; + const wrongTypeFields = []; + + for (const field of fields) { + const [name, types] = field; + const frameField = data.fields.find((f) => f.name === name); + if (!frameField) { + missingFields.push(name); + continue; + } + if (!types.includes(frameField.type)) { + wrongTypeFields.push({ name, expectedTypes: types, type: frameField.type }); + } + } + + if (missingFields.length > 0 || wrongTypeFields.length > 0) { + return { + wrongTypeFields, + missingFields, + }; + } + return undefined; +} + export class FlameGraphDataContainer { data: DataFrame; labelField: Field; @@ -85,15 +144,17 @@ export class FlameGraphDataContainer { constructor(data: DataFrame, theme: GrafanaTheme2 = createTheme()) { this.data = data; + + const wrongFields = checkFields(data); + if (wrongFields) { + throw new Error(getMessageCheckFieldsResult(wrongFields)); + } + this.labelField = data.fields.find((f) => f.name === 'label')!; this.levelField = data.fields.find((f) => f.name === 'level')!; this.valueField = data.fields.find((f) => f.name === 'value')!; this.selfField = data.fields.find((f) => f.name === 'self')!; - if (!(this.labelField && this.levelField && this.valueField && this.selfField)) { - throw new Error('Malformed dataFrame: value, level and label and self fields are required.'); - } - const enumConfig = this.labelField?.config?.type?.enum; // Label can actually be an enum field so depending on that we have to access it through display processor. This is // both a backward compatibility but also to allow using a simple dataFrame without enum config. This would allow diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/rendering.test.ts b/public/app/plugins/panel/flamegraph/components/FlameGraph/rendering.test.ts index 729dfad1d89..3d889c12364 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/rendering.test.ts +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/rendering.test.ts @@ -1,4 +1,4 @@ -import { createDataFrame } from '@grafana/data'; +import { createDataFrame, FieldType } from '@grafana/data'; import { FlameGraphDataContainer, LevelItem } from './dataTransform'; import { getRectDimensionsForLevel } from './rendering'; @@ -8,6 +8,7 @@ function makeDataFrame(fields: Record>) { fields: Object.keys(fields).map((key) => ({ name: key, values: fields[key], + type: typeof fields[key][0] === 'string' ? FieldType.string : FieldType.number, })), }); } diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/testHelpers.ts b/public/app/plugins/panel/flamegraph/components/FlameGraph/testHelpers.ts index 510c3397064..fc42c5bf2ac 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/testHelpers.ts +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/testHelpers.ts @@ -1,4 +1,4 @@ -import { arrayToDataFrame } from '@grafana/data'; +import { arrayToDataFrame, FieldType } from '@grafana/data'; import { FlameGraphDataContainer, LevelItem } from './dataTransform'; @@ -77,6 +77,8 @@ export function textToDataContainer(text: string) { } const df = arrayToDataFrame(dfSorted); + const labelField = df.fields.find((f) => f.name === 'label')!; + labelField.type = FieldType.string; return new FlameGraphDataContainer(df); } diff --git a/public/app/plugins/panel/flamegraph/suggestions.ts b/public/app/plugins/panel/flamegraph/suggestions.ts index b8fbf4af50d..3421e57c16c 100644 --- a/public/app/plugins/panel/flamegraph/suggestions.ts +++ b/public/app/plugins/panel/flamegraph/suggestions.ts @@ -1,7 +1,7 @@ import { VisualizationSuggestionsBuilder } from '@grafana/data'; import { SuggestionName } from 'app/types/suggestions'; -import { FlameGraphDataContainer as FlameGraphDataContainer } from './components/FlameGraph/dataTransform'; +import { checkFields } from './components/FlameGraph/dataTransform'; export class FlameGraphSuggestionsSupplier { getListWithDefaults(builder: VisualizationSuggestionsBuilder) { @@ -16,13 +16,12 @@ export class FlameGraphSuggestionsSupplier { return; } - // Try to instantiate FlameGraphDataContainer (depending on the version), since the instantiation can fail due - // to the format of the data - meaning that a Flame Graph cannot be used to visualize those data. - // Without this check, a suggestion containing an error is shown to the user. const dataFrame = builder.data.series[0]; - try { - new FlameGraphDataContainer(dataFrame); - } catch (err) { + if (!dataFrame) { + return; + } + const wrongFields = checkFields(dataFrame); + if (wrongFields) { return; }