From 524f111ab391b9d8590a1b6bd7e477113dc7e75d Mon Sep 17 00:00:00 2001 From: Ben Donnelly Date: Fri, 7 Jul 2023 10:28:44 +0100 Subject: [PATCH] Explore: Allow the use of plugin panels (#66982) * Explore: Allow the use of plugin panels Allow plugins to define a visualisation to use in explore that comes from a plugin. * Explore: Allow the use of plugin panels Rename ExplorePanel to CustomContainer * Explore: Allow the use of plugin panels Changed CustomContainer to take all frames for plugin id. Add field preferredVisualisationPluginId to frame metadata. Updated decorators to check for plugin and fallback to preferredVisualisationType if plugin cannot be found. * Explore: Allow the use of plugin panels Handle case where there are no custom frames * Explore: Allow the use of plugin panels Add test cases --- packages/grafana-data/src/types/data.ts | 7 +++ .../app/features/explore/CustomContainer.tsx | 45 +++++++++++++++++ public/app/features/explore/Explore.test.tsx | 1 + public/app/features/explore/Explore.tsx | 29 ++++++++++- .../explore/ExploreQueryInspector.test.tsx | 1 + public/app/features/explore/__mocks__/data.ts | 1 + .../AddToDashboard/addToDashboard.test.ts | 23 +++++++++ .../AddToDashboard/addToDashboard.ts | 4 ++ public/app/features/explore/state/query.ts | 2 + public/app/features/explore/state/utils.ts | 1 + .../features/explore/utils/decorators.test.ts | 48 +++++++++++++++++++ .../app/features/explore/utils/decorators.ts | 19 ++++++++ .../app/features/plugins/importPanelPlugin.ts | 10 +++- public/app/types/explore.ts | 2 + 14 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 public/app/features/explore/CustomContainer.tsx diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index 067d4af8f92..cfdc9e82530 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -59,6 +59,13 @@ export interface QueryResultMeta { /** Currently used to show results in Explore only in preferred visualisation option */ preferredVisualisationType?: PreferredVisualisationType; + /** Set the panel plugin id to use to render the data when using Explore. If the plugin cannot be found + * will fall back to {@link preferredVisualisationType}. + * + * @alpha + */ + preferredVisualisationPluginId?: string; + /** The path for live stream updates for this frame */ channel?: string; diff --git a/public/app/features/explore/CustomContainer.tsx b/public/app/features/explore/CustomContainer.tsx new file mode 100644 index 00000000000..42629d97e15 --- /dev/null +++ b/public/app/features/explore/CustomContainer.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { AbsoluteTimeRange, DataFrame, dateTime, LoadingState } from '@grafana/data'; +import { PanelRenderer } from '@grafana/runtime'; +import { PanelChrome } from '@grafana/ui'; + +import { getPanelPluginMeta } from '../plugins/importPanelPlugin'; + +export interface Props { + width: number; + height: number; + timeZone: string; + pluginId: string; + frames: DataFrame[]; + absoluteRange: AbsoluteTimeRange; + state: LoadingState; +} + +export function CustomContainer({ width, height, timeZone, state, pluginId, frames, absoluteRange }: Props) { + const timeRange = { + from: dateTime(absoluteRange.from), + to: dateTime(absoluteRange.to), + raw: { + from: dateTime(absoluteRange.from), + to: dateTime(absoluteRange.to), + }, + }; + + const plugin = getPanelPluginMeta(pluginId); + + return ( + + {(innerWidth, innerHeight) => ( + + )} + + ); +} diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 0edebb7b230..d8432179815 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -86,6 +86,7 @@ const dummyProps: Props = { showLogs: true, showTable: true, showTrace: true, + showCustom: true, showNodeGraph: true, showFlameGraph: true, splitOpen: jest.fn(), diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 12b8482527b..fe3d3ca8170 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { get } from 'lodash'; +import { get, groupBy } from 'lodash'; import memoizeOne from 'memoize-one'; import React, { createRef } from 'react'; import { connect, ConnectedProps } from 'react-redux'; @@ -37,6 +37,7 @@ import { AbsoluteTimeEvent } from 'app/types/events'; import { getTimeZone } from '../profile/state/selectors'; +import { CustomContainer } from './CustomContainer'; import ExploreQueryInspector from './ExploreQueryInspector'; import { ExploreToolbar } from './ExploreToolbar'; import { FlameGraphExploreContainer } from './FlameGraph/FlameGraphExploreContainer'; @@ -283,6 +284,27 @@ export class Explore extends React.PureComponent { return ; } + renderCustom(width: number) { + const { timeZone, queryResponse, absoluteRange } = this.props; + + const groupedByPlugin = groupBy(queryResponse?.customFrames, 'meta.preferredVisualisationPluginId'); + + return Object.entries(groupedByPlugin).map(([pluginId, frames], index) => { + return ( + + ); + }); + } + renderGraphPanel(width: number) { const { graphResult, absoluteRange, timeZone, queryResponse, showFlameGraph } = this.props; @@ -423,6 +445,7 @@ export class Explore extends React.PureComponent { showRawPrometheus, showLogs, showTrace, + showCustom, showNodeGraph, showFlameGraph, timeZone, @@ -444,6 +467,7 @@ export class Explore extends React.PureComponent { queryResponse.tableFrames, queryResponse.rawPrometheusFrames, queryResponse.traceFrames, + queryResponse.customFrames, ].every((e) => e.length === 0); return ( @@ -496,6 +520,7 @@ export class Explore extends React.PureComponent { {config.featureToggles.logsSampleInExplore && showLogsSample && ( {this.renderLogsSamplePanel()} )} + {showCustom && {this.renderCustom(width)}} {showNoData && {this.renderNoData()}} )} @@ -546,6 +571,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { showMetrics, showTable, showTrace, + showCustom, absoluteRange, queryResponse, showNodeGraph, @@ -574,6 +600,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { showMetrics, showTable, showTrace, + showCustom, showNodeGraph, showRawPrometheus, showFlameGraph, diff --git a/public/app/features/explore/ExploreQueryInspector.test.tsx b/public/app/features/explore/ExploreQueryInspector.test.tsx index c07c2898484..950905a4d46 100644 --- a/public/app/features/explore/ExploreQueryInspector.test.tsx +++ b/public/app/features/explore/ExploreQueryInspector.test.tsx @@ -48,6 +48,7 @@ const setup = (propOverrides = {}) => { logsFrames: [], tableFrames: [], traceFrames: [], + customFrames: [], nodeGraphFrames: [], flameGraphFrames: [], rawPrometheusFrames: [], diff --git a/public/app/features/explore/__mocks__/data.ts b/public/app/features/explore/__mocks__/data.ts index aaeef2bb6da..46f9355c104 100644 --- a/public/app/features/explore/__mocks__/data.ts +++ b/public/app/features/explore/__mocks__/data.ts @@ -13,6 +13,7 @@ export const mockExplorePanelData = (props?: MockProps): Observable { ); } ); + + it('Sets visualization to plugin panel ID if there are custom panel frames', async () => { + const queries = [{ refId: 'A' }]; + const queryResponse: ExplorePanelData = { + ...createEmptyQueryResponse(), + ['customFrames']: [ + new MutableDataFrame({ + refId: 'A', + fields: [], + meta: { preferredVisualisationPluginId: 'someCustomPluginId' }, + }), + ], + }; + + await setDashboardInLocalStorage({ queries, queryResponse }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + dashboard: expect.objectContaining({ + panels: expect.arrayContaining([expect.objectContaining({ type: 'someCustomPluginId' })]), + }), + }) + ); + }); }); }); }); diff --git a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts index 9720d5c2c76..3bee074b11c 100644 --- a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts +++ b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts @@ -80,6 +80,10 @@ function getPanelType(queries: DataQuery[], queryResponse: ExplorePanelData) { if (queryResponse.traceFrames.some(hasQueryRefId)) { return 'traces'; } + if (queryResponse.customFrames.some(hasQueryRefId)) { + // we will always have a custom frame and meta, it should never default to 'table' (but all paths must return a string) + return queryResponse.customFrames.find(hasQueryRefId)?.meta?.preferredVisualisationPluginId ?? 'table'; + } } // falling back to table diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index f2a5ccdc184..79ede4ef485 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -1182,6 +1182,7 @@ export const processQueryResponse = ( nodeGraphFrames, flameGraphFrames, rawPrometheusFrames, + customFrames, } = response; if (error) { @@ -1224,6 +1225,7 @@ export const processQueryResponse = ( showNodeGraph: !!nodeGraphFrames.length, showRawPrometheus: !!rawPrometheusFrames.length, showFlameGraph: !!flameGraphFrames.length, + showCustom: !!customFrames?.length, clearedAtIndex: state.isLive ? state.clearedAtIndex : null, }; }; diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index 53c2f356191..fb486fed953 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -82,6 +82,7 @@ export const createEmptyQueryResponse = (): ExplorePanelData => ({ traceFrames: [], nodeGraphFrames: [], flameGraphFrames: [], + customFrames: [], tableFrames: [], rawPrometheusFrames: [], rawPrometheusResult: null, diff --git a/public/app/features/explore/utils/decorators.test.ts b/public/app/features/explore/utils/decorators.test.ts index cca227e2816..20296b97df8 100644 --- a/public/app/features/explore/utils/decorators.test.ts +++ b/public/app/features/explore/utils/decorators.test.ts @@ -17,6 +17,16 @@ jest.mock('@grafana/data', () => ({ dateTimeFormatTimeAgo: () => 'fromNow() jest mocked', })); +jest.mock('../../plugins/importPanelPlugin', () => { + const actual = jest.requireActual('../../plugins/importPanelPlugin'); + return { + ...actual, + hasPanelPlugin: (id: string) => { + return id === 'someCustomPanelPlugin'; + }, + }; +}); + const getTestContext = () => { const timeSeries = toDataFrame({ name: 'A-series', @@ -84,6 +94,7 @@ const createExplorePanelData = (args: Partial): ExplorePanelDa tableResult: null, traceFrames: [], nodeGraphFrames: [], + customFrames: [], flameGraphFrames: [], rawPrometheusFrames: [], rawPrometheusResult: null, @@ -111,6 +122,7 @@ describe('decorateWithGraphLogsTraceTableAndFlameGraph', () => { tableFrames: [table, emptyTable], logsFrames: [logs], traceFrames: [], + customFrames: [], nodeGraphFrames: [], flameGraphFrames: [flameGraph], graphResult: null, @@ -139,6 +151,7 @@ describe('decorateWithGraphLogsTraceTableAndFlameGraph', () => { logsFrames: [], traceFrames: [], nodeGraphFrames: [], + customFrames: [], flameGraphFrames: [], graphResult: null, tableResult: null, @@ -169,6 +182,7 @@ describe('decorateWithGraphLogsTraceTableAndFlameGraph', () => { logsFrames: [logs], traceFrames: [], nodeGraphFrames: [], + customFrames: [], flameGraphFrames: [], graphResult: null, tableResult: null, @@ -314,3 +328,37 @@ describe('decorateWithLogsResult', () => { expect(decorateWithLogsResult()(panelData).logsResult).not.toBeNull(); }); }); + +describe('decorateWithCustomFrames', () => { + it('returns empty array if no custom frames', () => { + const { table, logs, timeSeries, emptyTable, flameGraph } = getTestContext(); + const series = [table, logs, timeSeries, emptyTable, flameGraph]; + const timeRange = getDefaultTimeRange(); + const panelData: PanelData = { + series, + state: LoadingState.Done, + timeRange, + }; + + expect(decorateWithFrameTypeMetadata(panelData).customFrames).toEqual([]); + }); + it('returns data if we have custom frames', () => { + const { table, logs, timeSeries, emptyTable, flameGraph } = getTestContext(); + const customFrame = toDataFrame({ + name: 'custom-panel', + refId: 'A', + fields: [], + meta: { preferredVisualisationType: 'table', preferredVisualisationPluginId: 'someCustomPanelPlugin' }, + }); + + const series = [table, logs, timeSeries, emptyTable, flameGraph, customFrame]; + const timeRange = getDefaultTimeRange(); + const panelData: PanelData = { + series, + state: LoadingState.Done, + timeRange, + }; + + expect(decorateWithFrameTypeMetadata(panelData).customFrames).toEqual([customFrame]); + }); +}); diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index bca06ac5894..99ac1dbcacb 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -20,6 +20,7 @@ import { CorrelationData } from '../../correlations/useCorrelations'; import { attachCorrelationsToDataFrames } from '../../correlations/utils'; import { dataFrameToLogsModel } from '../../logs/logsModel'; import { sortLogsResult } from '../../logs/utils'; +import { hasPanelPlugin } from '../../plugins/importPanelPlugin'; /** * When processing response first we try to determine what kind of dataframes we got as one query can return multiple @@ -34,8 +35,13 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData const traceFrames: DataFrame[] = []; const nodeGraphFrames: DataFrame[] = []; const flameGraphFrames: DataFrame[] = []; + const customFrames: DataFrame[] = []; for (const frame of data.series) { + if (canFindPanel(frame)) { + customFrames.push(frame); + continue; + } switch (frame.meta?.preferredVisualisationType) { case 'logs': logsFrames.push(frame); @@ -76,6 +82,7 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData logsFrames, traceFrames, nodeGraphFrames, + customFrames, flameGraphFrames, rawPrometheusFrames, graphResult: null, @@ -270,3 +277,15 @@ function isTimeSeries(frame: DataFrame): boolean { Object.keys(grouped).length === 2 && grouped[FieldType.time]?.length === 1 && grouped[FieldType.number] ); } + +/** + * Can we find a panel that matches the type defined on the frame + * + * @param frame + */ +function canFindPanel(frame: DataFrame): boolean { + if (!!frame.meta?.preferredVisualisationPluginId) { + return hasPanelPlugin(frame.meta?.preferredVisualisationPluginId); + } + return false; +} diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index e6a708789c2..00238238d86 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -14,7 +14,7 @@ export function importPanelPlugin(id: string): Promise { return loaded; } - const meta = config.panels[id] || Object.values(config.panels).find((p) => p.alias === id); + const meta = getPanelPluginMeta(id); if (!meta) { throw new Error(`Plugin ${id} not found`); @@ -28,6 +28,14 @@ export function importPanelPlugin(id: string): Promise { return promiseCache[id]; } +export function hasPanelPlugin(id: string): boolean { + return !!getPanelPluginMeta(id); +} + +export function getPanelPluginMeta(id: string): PanelPluginMeta { + return config.panels[id] || Object.values(config.panels).find((p) => p.alias === id); +} + export function importPanelPluginFromMeta(meta: PanelPluginMeta): Promise { return getPanelPlugin(meta); } diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 29899e70840..1314f38779d 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -169,6 +169,7 @@ export interface ExploreItemState { showTrace?: boolean; showNodeGraph?: boolean; showFlameGraph?: boolean; + showCustom?: boolean; /** * History of all queries @@ -230,6 +231,7 @@ export interface ExplorePanelData extends PanelData { tableFrames: DataFrame[]; logsFrames: DataFrame[]; traceFrames: DataFrame[]; + customFrames: DataFrame[]; nodeGraphFrames: DataFrame[]; rawPrometheusFrames: DataFrame[]; flameGraphFrames: DataFrame[];