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
This commit is contained in:
Ben Donnelly 2023-07-07 10:28:44 +01:00 committed by GitHub
parent b947222bf5
commit 524f111ab3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 191 additions and 2 deletions

View File

@ -59,6 +59,13 @@ export interface QueryResultMeta {
/** Currently used to show results in Explore only in preferred visualisation option */ /** Currently used to show results in Explore only in preferred visualisation option */
preferredVisualisationType?: PreferredVisualisationType; 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 */ /** The path for live stream updates for this frame */
channel?: string; channel?: string;

View File

@ -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 (
<PanelChrome title={plugin.name} width={width} height={height} loadingState={state}>
{(innerWidth, innerHeight) => (
<PanelRenderer
data={{ series: frames, state: state, timeRange }}
pluginId={pluginId}
title=""
width={innerWidth}
height={innerHeight}
timeZone={timeZone}
/>
)}
</PanelChrome>
);
}

View File

@ -86,6 +86,7 @@ const dummyProps: Props = {
showLogs: true, showLogs: true,
showTable: true, showTable: true,
showTrace: true, showTrace: true,
showCustom: true,
showNodeGraph: true, showNodeGraph: true,
showFlameGraph: true, showFlameGraph: true,
splitOpen: jest.fn(), splitOpen: jest.fn(),

View File

@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { get } from 'lodash'; import { get, groupBy } from 'lodash';
import memoizeOne from 'memoize-one'; import memoizeOne from 'memoize-one';
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
@ -37,6 +37,7 @@ import { AbsoluteTimeEvent } from 'app/types/events';
import { getTimeZone } from '../profile/state/selectors'; import { getTimeZone } from '../profile/state/selectors';
import { CustomContainer } from './CustomContainer';
import ExploreQueryInspector from './ExploreQueryInspector'; import ExploreQueryInspector from './ExploreQueryInspector';
import { ExploreToolbar } from './ExploreToolbar'; import { ExploreToolbar } from './ExploreToolbar';
import { FlameGraphExploreContainer } from './FlameGraph/FlameGraphExploreContainer'; import { FlameGraphExploreContainer } from './FlameGraph/FlameGraphExploreContainer';
@ -283,6 +284,27 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
return <NoData />; return <NoData />;
} }
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 (
<CustomContainer
key={index}
timeZone={timeZone}
pluginId={pluginId}
frames={frames}
state={queryResponse.state}
absoluteRange={absoluteRange}
height={400}
width={width}
/>
);
});
}
renderGraphPanel(width: number) { renderGraphPanel(width: number) {
const { graphResult, absoluteRange, timeZone, queryResponse, showFlameGraph } = this.props; const { graphResult, absoluteRange, timeZone, queryResponse, showFlameGraph } = this.props;
@ -423,6 +445,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
showRawPrometheus, showRawPrometheus,
showLogs, showLogs,
showTrace, showTrace,
showCustom,
showNodeGraph, showNodeGraph,
showFlameGraph, showFlameGraph,
timeZone, timeZone,
@ -444,6 +467,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
queryResponse.tableFrames, queryResponse.tableFrames,
queryResponse.rawPrometheusFrames, queryResponse.rawPrometheusFrames,
queryResponse.traceFrames, queryResponse.traceFrames,
queryResponse.customFrames,
].every((e) => e.length === 0); ].every((e) => e.length === 0);
return ( return (
@ -496,6 +520,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
{config.featureToggles.logsSampleInExplore && showLogsSample && ( {config.featureToggles.logsSampleInExplore && showLogsSample && (
<ErrorBoundaryAlert>{this.renderLogsSamplePanel()}</ErrorBoundaryAlert> <ErrorBoundaryAlert>{this.renderLogsSamplePanel()}</ErrorBoundaryAlert>
)} )}
{showCustom && <ErrorBoundaryAlert>{this.renderCustom(width)}</ErrorBoundaryAlert>}
{showNoData && <ErrorBoundaryAlert>{this.renderNoData()}</ErrorBoundaryAlert>} {showNoData && <ErrorBoundaryAlert>{this.renderNoData()}</ErrorBoundaryAlert>}
</> </>
)} )}
@ -546,6 +571,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showMetrics, showMetrics,
showTable, showTable,
showTrace, showTrace,
showCustom,
absoluteRange, absoluteRange,
queryResponse, queryResponse,
showNodeGraph, showNodeGraph,
@ -574,6 +600,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showMetrics, showMetrics,
showTable, showTable,
showTrace, showTrace,
showCustom,
showNodeGraph, showNodeGraph,
showRawPrometheus, showRawPrometheus,
showFlameGraph, showFlameGraph,

View File

@ -48,6 +48,7 @@ const setup = (propOverrides = {}) => {
logsFrames: [], logsFrames: [],
tableFrames: [], tableFrames: [],
traceFrames: [], traceFrames: [],
customFrames: [],
nodeGraphFrames: [], nodeGraphFrames: [],
flameGraphFrames: [], flameGraphFrames: [],
rawPrometheusFrames: [], rawPrometheusFrames: [],

View File

@ -13,6 +13,7 @@ export const mockExplorePanelData = (props?: MockProps): Observable<ExplorePanel
flameGraphFrames: [], flameGraphFrames: [],
graphFrames: [], graphFrames: [],
graphResult: [], graphResult: [],
customFrames: [],
logsFrames: [], logsFrames: [],
logsResult: { logsResult: {
hasUniqueLabels: false, hasUniqueLabels: false,

View File

@ -137,6 +137,29 @@ describe('addPanelToDashboard', () => {
); );
} }
); );
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' })]),
}),
})
);
});
}); });
}); });
}); });

View File

@ -80,6 +80,10 @@ function getPanelType(queries: DataQuery[], queryResponse: ExplorePanelData) {
if (queryResponse.traceFrames.some(hasQueryRefId)) { if (queryResponse.traceFrames.some(hasQueryRefId)) {
return 'traces'; 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 // falling back to table

View File

@ -1182,6 +1182,7 @@ export const processQueryResponse = (
nodeGraphFrames, nodeGraphFrames,
flameGraphFrames, flameGraphFrames,
rawPrometheusFrames, rawPrometheusFrames,
customFrames,
} = response; } = response;
if (error) { if (error) {
@ -1224,6 +1225,7 @@ export const processQueryResponse = (
showNodeGraph: !!nodeGraphFrames.length, showNodeGraph: !!nodeGraphFrames.length,
showRawPrometheus: !!rawPrometheusFrames.length, showRawPrometheus: !!rawPrometheusFrames.length,
showFlameGraph: !!flameGraphFrames.length, showFlameGraph: !!flameGraphFrames.length,
showCustom: !!customFrames?.length,
clearedAtIndex: state.isLive ? state.clearedAtIndex : null, clearedAtIndex: state.isLive ? state.clearedAtIndex : null,
}; };
}; };

View File

@ -82,6 +82,7 @@ export const createEmptyQueryResponse = (): ExplorePanelData => ({
traceFrames: [], traceFrames: [],
nodeGraphFrames: [], nodeGraphFrames: [],
flameGraphFrames: [], flameGraphFrames: [],
customFrames: [],
tableFrames: [], tableFrames: [],
rawPrometheusFrames: [], rawPrometheusFrames: [],
rawPrometheusResult: null, rawPrometheusResult: null,

View File

@ -17,6 +17,16 @@ jest.mock('@grafana/data', () => ({
dateTimeFormatTimeAgo: () => 'fromNow() jest mocked', 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 getTestContext = () => {
const timeSeries = toDataFrame({ const timeSeries = toDataFrame({
name: 'A-series', name: 'A-series',
@ -84,6 +94,7 @@ const createExplorePanelData = (args: Partial<ExplorePanelData>): ExplorePanelDa
tableResult: null, tableResult: null,
traceFrames: [], traceFrames: [],
nodeGraphFrames: [], nodeGraphFrames: [],
customFrames: [],
flameGraphFrames: [], flameGraphFrames: [],
rawPrometheusFrames: [], rawPrometheusFrames: [],
rawPrometheusResult: null, rawPrometheusResult: null,
@ -111,6 +122,7 @@ describe('decorateWithGraphLogsTraceTableAndFlameGraph', () => {
tableFrames: [table, emptyTable], tableFrames: [table, emptyTable],
logsFrames: [logs], logsFrames: [logs],
traceFrames: [], traceFrames: [],
customFrames: [],
nodeGraphFrames: [], nodeGraphFrames: [],
flameGraphFrames: [flameGraph], flameGraphFrames: [flameGraph],
graphResult: null, graphResult: null,
@ -139,6 +151,7 @@ describe('decorateWithGraphLogsTraceTableAndFlameGraph', () => {
logsFrames: [], logsFrames: [],
traceFrames: [], traceFrames: [],
nodeGraphFrames: [], nodeGraphFrames: [],
customFrames: [],
flameGraphFrames: [], flameGraphFrames: [],
graphResult: null, graphResult: null,
tableResult: null, tableResult: null,
@ -169,6 +182,7 @@ describe('decorateWithGraphLogsTraceTableAndFlameGraph', () => {
logsFrames: [logs], logsFrames: [logs],
traceFrames: [], traceFrames: [],
nodeGraphFrames: [], nodeGraphFrames: [],
customFrames: [],
flameGraphFrames: [], flameGraphFrames: [],
graphResult: null, graphResult: null,
tableResult: null, tableResult: null,
@ -314,3 +328,37 @@ describe('decorateWithLogsResult', () => {
expect(decorateWithLogsResult()(panelData).logsResult).not.toBeNull(); 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]);
});
});

View File

@ -20,6 +20,7 @@ import { CorrelationData } from '../../correlations/useCorrelations';
import { attachCorrelationsToDataFrames } from '../../correlations/utils'; import { attachCorrelationsToDataFrames } from '../../correlations/utils';
import { dataFrameToLogsModel } from '../../logs/logsModel'; import { dataFrameToLogsModel } from '../../logs/logsModel';
import { sortLogsResult } from '../../logs/utils'; 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 * 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 traceFrames: DataFrame[] = [];
const nodeGraphFrames: DataFrame[] = []; const nodeGraphFrames: DataFrame[] = [];
const flameGraphFrames: DataFrame[] = []; const flameGraphFrames: DataFrame[] = [];
const customFrames: DataFrame[] = [];
for (const frame of data.series) { for (const frame of data.series) {
if (canFindPanel(frame)) {
customFrames.push(frame);
continue;
}
switch (frame.meta?.preferredVisualisationType) { switch (frame.meta?.preferredVisualisationType) {
case 'logs': case 'logs':
logsFrames.push(frame); logsFrames.push(frame);
@ -76,6 +82,7 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData
logsFrames, logsFrames,
traceFrames, traceFrames,
nodeGraphFrames, nodeGraphFrames,
customFrames,
flameGraphFrames, flameGraphFrames,
rawPrometheusFrames, rawPrometheusFrames,
graphResult: null, graphResult: null,
@ -270,3 +277,15 @@ function isTimeSeries(frame: DataFrame): boolean {
Object.keys(grouped).length === 2 && grouped[FieldType.time]?.length === 1 && grouped[FieldType.number] 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;
}

View File

@ -14,7 +14,7 @@ export function importPanelPlugin(id: string): Promise<PanelPlugin> {
return loaded; return loaded;
} }
const meta = config.panels[id] || Object.values(config.panels).find((p) => p.alias === id); const meta = getPanelPluginMeta(id);
if (!meta) { if (!meta) {
throw new Error(`Plugin ${id} not found`); throw new Error(`Plugin ${id} not found`);
@ -28,6 +28,14 @@ export function importPanelPlugin(id: string): Promise<PanelPlugin> {
return promiseCache[id]; 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<PanelPlugin> { export function importPanelPluginFromMeta(meta: PanelPluginMeta): Promise<PanelPlugin> {
return getPanelPlugin(meta); return getPanelPlugin(meta);
} }

View File

@ -169,6 +169,7 @@ export interface ExploreItemState {
showTrace?: boolean; showTrace?: boolean;
showNodeGraph?: boolean; showNodeGraph?: boolean;
showFlameGraph?: boolean; showFlameGraph?: boolean;
showCustom?: boolean;
/** /**
* History of all queries * History of all queries
@ -230,6 +231,7 @@ export interface ExplorePanelData extends PanelData {
tableFrames: DataFrame[]; tableFrames: DataFrame[];
logsFrames: DataFrame[]; logsFrames: DataFrame[];
traceFrames: DataFrame[]; traceFrames: DataFrame[];
customFrames: DataFrame[];
nodeGraphFrames: DataFrame[]; nodeGraphFrames: DataFrame[];
rawPrometheusFrames: DataFrame[]; rawPrometheusFrames: DataFrame[];
flameGraphFrames: DataFrame[]; flameGraphFrames: DataFrame[];