Plugin: PanelRenderer and simplified QueryRunner to be used from plugins. (#31901)

This commit is contained in:
Torkel Ödegaard 2021-03-18 21:40:27 +01:00 committed by GitHub
parent 30e5afa18c
commit cb2a63b5c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 397 additions and 3 deletions

View File

@ -20,3 +20,4 @@ export {
} from './transformations/matchers/valueMatchers/types';
export { PanelPlugin, SetFieldConfigOptionsArgs, StandardOptionConfig } from './panel/PanelPlugin';
export { createFieldConfigRegistry } from './panel/registryFactories';
export { QueryRunner, QueryRunnerOptions } from './types/queryRunner';

View File

@ -6,6 +6,7 @@ import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
export enum CoreApp {
Dashboard = 'dashboard',
Explore = 'explore',
Unknown = 'unknown',
}
export interface AppRootProps<T = KeyValue> {

View File

@ -0,0 +1,38 @@
import { Observable } from 'rxjs';
import { DataQuery, DataSourceApi } from './datasource';
import { PanelData } from './panel';
import { ScopedVars } from './ScopedVars';
import { TimeRange, TimeZone } from './time';
/**
* Describes the options used when triggering a query via the {@link QueryRunner}.
*
* @internal
*/
export interface QueryRunnerOptions {
datasource: string | DataSourceApi | null;
queries: DataQuery[];
panelId?: number;
dashboardId?: number;
timezone: TimeZone;
timeRange: TimeRange;
timeInfo?: string; // String description of time range for display
maxDataPoints: number;
minInterval: string | undefined | null;
scopedVars?: ScopedVars;
cacheTimeout?: string;
app?: string;
}
/**
* Describes the QueryRunner that can used to exectue queries in e.g. app plugins.
* QueryRunner instances can be created via the {@link @grafana/runtime#createQueryRunner | createQueryRunner}.
*
* @internal
*/
export interface QueryRunner {
get(): Observable<PanelData>;
run(options: QueryRunnerOptions): void;
cancel(): void;
destroy(): void;
}

View File

@ -0,0 +1,53 @@
import React from 'react';
import { AbsoluteTimeRange, FieldConfigSource, PanelData } from '@grafana/data';
/**
* Describes the properties that can be passed to the PanelRenderer.
*
* @typeParam P - Panel options type for the panel being rendered.
* @typeParam F - Field options type for the panel being rendered.
*
* @internal
*/
export interface PanelRendererProps<P extends object = any, F extends object = any> {
data: PanelData;
pluginId: string;
title: string;
fieldConfig?: FieldConfigSource<F>;
options?: P;
onOptionsChange: (options: P) => void;
onChangeTimeRange?: (timeRange: AbsoluteTimeRange) => void;
timeZone?: string;
width: number;
height: number;
}
/**
* Simplified type with defaults that describes the PanelRenderer.
*
* @internal
*/
export type PanelRendererType<P extends object = any, F extends object = any> = React.ComponentType<
PanelRendererProps<P, F>
>;
/**
* PanelRenderer component that will be set via the {@link setPanelRenderer} function
* when Grafana starts. The implementation being used during runtime lives in Grafana
* core.
*
* @internal
*/
export let PanelRenderer: PanelRendererType = () => {
return <div>PanelRenderer can only be used after Grafana instance has been started.</div>;
};
/**
* Used to bootstrap the PanelRenderer during application start so the PanelRenderer
* is exposed via runtime.
*
* @internal
*/
export function setPanelRenderer(renderer: PanelRendererType) {
PanelRenderer = renderer;
}

View File

@ -12,4 +12,6 @@ export { reportMetaAnalytics } from './utils/analytics';
export { logInfo, logDebug, logWarning, logError } from './utils/logging';
export { DataSourceWithBackend, HealthCheckResult, HealthStatus } from './utils/DataSourceWithBackend';
export { toDataQueryError, toDataQueryResponse, frameToMetricFindValue } from './utils/queryResponse';
export { PanelRenderer, PanelRendererProps, PanelRendererType, setPanelRenderer } from './components/PanelRenderer';
export { setQueryRunnerFactory, createQueryRunner, QueryRunnerFactory } from './services/QueryRunner';
export { DataSourcePicker, DataSourcePickerProps, DataSourcePickerState } from './components/DataSourcePicker';

View File

@ -0,0 +1,33 @@
import { QueryRunner } from '@grafana/data';
let factory: QueryRunnerFactory | undefined;
/**
* @internal
*/
export type QueryRunnerFactory = () => QueryRunner;
/**
* Used to bootstrap the {@link createQueryRunner} during application start.
*
* @internal
*/
export const setQueryRunnerFactory = (instance: QueryRunnerFactory): void => {
if (factory) {
throw new Error('Runner should only be set when Grafana is starting.');
}
factory = instance;
};
/**
* Used to create QueryRunner instances from outside the core Grafana application.
* This is helpful to be able to create a QueryRunner to execute queries in e.g. an app plugin.
*
* @internal
*/
export const createQueryRunner = (): QueryRunner => {
if (!factory) {
throw new Error('`createQueryRunner` can only be used after Grafana instance has started.');
}
return factory();
};

View File

@ -23,7 +23,7 @@ import {
} from '@grafana/data';
import { arrayMove } from 'app/core/utils/arrayMove';
import { importPluginModule } from 'app/features/plugins/plugin_loader';
import { registerEchoBackend, setEchoSrv } from '@grafana/runtime';
import { registerEchoBackend, setEchoSrv, setPanelRenderer, setQueryRunnerFactory } from '@grafana/runtime';
import { Echo } from './core/services/echo/Echo';
import { reportPerformance } from './core/services/echo/EchoSrv';
import { PerformanceBackend } from './core/services/echo/backends/PerformanceBackend';
@ -39,6 +39,8 @@ import { configureStore } from './store/configureStore';
import { AppWrapper } from './AppWrapper';
import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks';
import { AngularApp } from './angular/AngularApp';
import { PanelRenderer } from './features/panel/PanelRenderer';
import { QueryRunner } from './features/query/state/QueryRunner';
// add move to lodash for backward compatabilty with plugins
// @ts-ignore
@ -65,6 +67,7 @@ export class GrafanaApp {
initEchoSrv();
addClassIfNoOverlayScrollbar();
setLocale(config.bootData.user.locale);
setPanelRenderer(PanelRenderer);
setTimeZoneResolver(() => config.bootData.user.timezone);
// Important that extensions are initialized before store
initExtensions();
@ -75,6 +78,7 @@ export class GrafanaApp {
standardTransformersRegistry.setInit(getStandardTransformers);
variableAdapters.setInit(getDefaultVariableAdapters);
setQueryRunnerFactory(() => new QueryRunner());
setVariableQueryRunner(new VariableQueryRunner());
// intercept anchor clicks and forward it to custom history instead of relying on browser's history

View File

@ -0,0 +1,115 @@
import React, { useState, useMemo } from 'react';
import { applyFieldOverrides, FieldConfigSource, getTimeZone, PanelData, PanelPlugin } from '@grafana/data';
import { PanelRendererProps } from '@grafana/runtime';
import { config } from 'app/core/config';
import { appEvents } from 'app/core/core';
import { useAsync } from 'react-use';
import { getPanelOptionsWithDefaults, OptionDefaults } from '../dashboard/state/getPanelOptionsWithDefaults';
import { importPanelPlugin } from '../plugins/plugin_loader';
export function PanelRenderer<P extends object = any, F extends object = any>(props: PanelRendererProps<P, F>) {
const {
pluginId,
data,
timeZone = getTimeZone(),
options = {},
width,
height,
title,
onOptionsChange,
onChangeTimeRange = () => {},
fieldConfig: config = { defaults: {}, overrides: [] },
} = props;
const [fieldConfig, setFieldConfig] = useState<FieldConfigSource>(config);
const { value: plugin, error, loading } = useAsync(() => importPanelPlugin(pluginId), [pluginId]);
const defaultOptions = useOptionDefaults(plugin, options, fieldConfig);
const dataWithOverrides = useFieldOverrides(plugin, defaultOptions, data, timeZone);
if (error) {
return <div>Failed to load plugin: {error.message}</div>;
}
if (loading) {
return <div>Loading plugin panel...</div>;
}
if (!plugin || !plugin.panel) {
return <div>Seems like the plugin you are trying to load does not have a panel component.</div>;
}
if (!dataWithOverrides) {
return <div>No panel data</div>;
}
const PanelComponent = plugin.panel;
return (
<PanelComponent
id={1}
data={dataWithOverrides}
title={title}
timeRange={dataWithOverrides.timeRange}
timeZone={timeZone}
options={options}
fieldConfig={fieldConfig}
transparent={false}
width={width}
height={height}
renderCounter={0}
replaceVariables={(str: string) => str}
onOptionsChange={onOptionsChange}
onFieldConfigChange={setFieldConfig}
onChangeTimeRange={onChangeTimeRange}
eventBus={appEvents}
/>
);
}
const useOptionDefaults = <P extends object = any, F extends object = any>(
plugin: PanelPlugin | undefined,
options: P,
fieldConfig: FieldConfigSource<F>
): OptionDefaults | undefined => {
return useMemo(() => {
if (!plugin) {
return;
}
return getPanelOptionsWithDefaults({
plugin,
currentOptions: options,
currentFieldConfig: fieldConfig,
isAfterPluginChange: false,
});
}, [plugin, fieldConfig, options]);
};
const useFieldOverrides = (
plugin: PanelPlugin | undefined,
defaultOptions: OptionDefaults | undefined,
data: PanelData | undefined,
timeZone: string
): PanelData | undefined => {
const fieldConfig = defaultOptions?.fieldConfig;
const series = data?.series;
const fieldConfigRegistry = plugin?.fieldConfigRegistry;
return useMemo(() => {
if (!fieldConfigRegistry || !fieldConfig || !data) {
return;
}
return {
...data,
series: applyFieldOverrides({
data: series,
fieldConfig,
fieldConfigRegistry,
replaceVariables: (str: string) => str,
theme: config.theme,
timeZone,
}),
};
}, [fieldConfigRegistry, timeZone, fieldConfig, series]);
};

View File

@ -43,12 +43,11 @@ export interface QueryRunnerOptions<
minInterval: string | undefined | null;
scopedVars?: ScopedVars;
cacheTimeout?: string;
delayStateNotification?: number; // default 100ms.
transformations?: DataTransformerConfig[];
}
let counter = 100;
function getNextRequestId() {
export function getNextRequestId() {
return 'Q' + counter++;
}

View File

@ -0,0 +1,148 @@
import {
CoreApp,
DataQueryRequest,
DataSourceApi,
PanelData,
rangeUtil,
ScopedVars,
QueryRunnerOptions,
QueryRunner as QueryRunnerSrv,
LoadingState,
} from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { cloneDeep } from 'lodash';
import { from, Observable, ReplaySubject, Unsubscribable } from 'rxjs';
import { first } from 'rxjs/operators';
import { getNextRequestId } from './PanelQueryRunner';
import { preProcessPanelData, runRequest } from './runRequest';
export class QueryRunner implements QueryRunnerSrv {
private subject: ReplaySubject<PanelData>;
private subscription?: Unsubscribable;
private lastResult?: PanelData;
constructor() {
this.subject = new ReplaySubject(1);
}
get(): Observable<PanelData> {
return this.subject.asObservable();
}
run(options: QueryRunnerOptions): void {
const {
queries,
timezone,
datasource,
panelId,
app,
dashboardId,
timeRange,
timeInfo,
cacheTimeout,
maxDataPoints,
scopedVars,
minInterval,
} = options;
if (this.subscription) {
this.subscription.unsubscribe();
}
const request: DataQueryRequest = {
app: app ?? CoreApp.Unknown,
requestId: getNextRequestId(),
timezone,
panelId,
dashboardId,
range: timeRange,
timeInfo,
interval: '',
intervalMs: 0,
targets: cloneDeep(queries),
maxDataPoints: maxDataPoints,
scopedVars: scopedVars || {},
cacheTimeout,
startTime: Date.now(),
};
// Add deprecated property
(request as any).rangeRaw = timeRange.raw;
from(getDataSource(datasource, request.scopedVars))
.pipe(first())
.subscribe({
next: (ds) => {
// Attach the datasource name to each query
request.targets = request.targets.map((query) => {
if (!query.datasource) {
query.datasource = ds.name;
}
return query;
});
const lowerIntervalLimit = minInterval
? getTemplateSrv().replace(minInterval, request.scopedVars)
: ds.interval;
const norm = rangeUtil.calculateInterval(timeRange, maxDataPoints, lowerIntervalLimit);
// make shallow copy of scoped vars,
// and add built in variables interval and interval_ms
request.scopedVars = Object.assign({}, request.scopedVars, {
__interval: { text: norm.interval, value: norm.interval },
__interval_ms: { text: norm.intervalMs.toString(), value: norm.intervalMs },
});
request.interval = norm.interval;
request.intervalMs = norm.intervalMs;
this.subscription = runRequest(ds, request).subscribe({
next: (data) => {
this.lastResult = preProcessPanelData(data, this.lastResult);
// Store preprocessed query results for applying overrides later on in the pipeline
this.subject.next(this.lastResult);
},
});
},
error: (error) => console.error('PanelQueryRunner Error', error),
});
}
cancel(): void {
if (!this.subscription) {
return;
}
this.subscription.unsubscribe();
// If we have an old result with loading state, send it with done state
if (this.lastResult && this.lastResult.state === LoadingState.Loading) {
this.subject.next({
...this.lastResult,
state: LoadingState.Done,
});
}
}
destroy(): void {
// Tell anyone listening that we are done
if (this.subject) {
this.subject.complete();
}
if (this.subscription) {
this.subscription.unsubscribe();
}
}
}
async function getDataSource(
datasource: string | DataSourceApi | null,
scopedVars: ScopedVars
): Promise<DataSourceApi> {
if (datasource && (datasource as any).query) {
return datasource as DataSourceApi;
}
return await getDatasourceSrv().get(datasource as string, scopedVars);
}