From cb2a63b5c6caf0cee3222c72e2911c7ead0fbe53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 18 Mar 2021 21:40:27 +0100 Subject: [PATCH] Plugin: PanelRenderer and simplified QueryRunner to be used from plugins. (#31901) --- packages/grafana-data/src/index.ts | 1 + packages/grafana-data/src/types/app.ts | 1 + .../grafana-data/src/types/queryRunner.ts | 38 +++++ .../src/components/PanelRenderer.tsx | 53 +++++++ packages/grafana-runtime/src/index.ts | 2 + .../src/services/QueryRunner.ts | 33 ++++ public/app/app.ts | 6 +- public/app/features/panel/PanelRenderer.tsx | 115 ++++++++++++++ .../features/query/state/PanelQueryRunner.ts | 3 +- .../app/features/query/state/QueryRunner.ts | 148 ++++++++++++++++++ 10 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 packages/grafana-data/src/types/queryRunner.ts create mode 100644 packages/grafana-runtime/src/components/PanelRenderer.tsx create mode 100644 packages/grafana-runtime/src/services/QueryRunner.ts create mode 100644 public/app/features/panel/PanelRenderer.tsx create mode 100644 public/app/features/query/state/QueryRunner.ts diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 40f642a1eaa..e767dd04964 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -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'; diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index ed76f8be9e6..4f0c9ff4cec 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -6,6 +6,7 @@ import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin'; export enum CoreApp { Dashboard = 'dashboard', Explore = 'explore', + Unknown = 'unknown', } export interface AppRootProps { diff --git a/packages/grafana-data/src/types/queryRunner.ts b/packages/grafana-data/src/types/queryRunner.ts new file mode 100644 index 00000000000..b2d0496e870 --- /dev/null +++ b/packages/grafana-data/src/types/queryRunner.ts @@ -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; + run(options: QueryRunnerOptions): void; + cancel(): void; + destroy(): void; +} diff --git a/packages/grafana-runtime/src/components/PanelRenderer.tsx b/packages/grafana-runtime/src/components/PanelRenderer.tsx new file mode 100644 index 00000000000..eb6713f973e --- /dev/null +++ b/packages/grafana-runtime/src/components/PanelRenderer.tsx @@ -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

{ + data: PanelData; + pluginId: string; + title: string; + fieldConfig?: FieldConfigSource; + 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

= React.ComponentType< + PanelRendererProps +>; + +/** + * 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

PanelRenderer can only be used after Grafana instance has been started.
; +}; + +/** + * Used to bootstrap the PanelRenderer during application start so the PanelRenderer + * is exposed via runtime. + * + * @internal + */ +export function setPanelRenderer(renderer: PanelRendererType) { + PanelRenderer = renderer; +} diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 2b111dd7a93..15bd9e4f1bd 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -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'; diff --git a/packages/grafana-runtime/src/services/QueryRunner.ts b/packages/grafana-runtime/src/services/QueryRunner.ts new file mode 100644 index 00000000000..8ce5b100e7c --- /dev/null +++ b/packages/grafana-runtime/src/services/QueryRunner.ts @@ -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(); +}; diff --git a/public/app/app.ts b/public/app/app.ts index d0a2a3f7665..232981c5b33 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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 diff --git a/public/app/features/panel/PanelRenderer.tsx b/public/app/features/panel/PanelRenderer.tsx new file mode 100644 index 00000000000..c6b32a0a6bc --- /dev/null +++ b/public/app/features/panel/PanelRenderer.tsx @@ -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

(props: PanelRendererProps) { + const { + pluginId, + data, + timeZone = getTimeZone(), + options = {}, + width, + height, + title, + onOptionsChange, + onChangeTimeRange = () => {}, + fieldConfig: config = { defaults: {}, overrides: [] }, + } = props; + + const [fieldConfig, setFieldConfig] = useState(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

Failed to load plugin: {error.message}
; + } + + if (loading) { + return
Loading plugin panel...
; + } + + if (!plugin || !plugin.panel) { + return
Seems like the plugin you are trying to load does not have a panel component.
; + } + + if (!dataWithOverrides) { + return
No panel data
; + } + + const PanelComponent = plugin.panel; + + return ( + str} + onOptionsChange={onOptionsChange} + onFieldConfigChange={setFieldConfig} + onChangeTimeRange={onChangeTimeRange} + eventBus={appEvents} + /> + ); +} + +const useOptionDefaults =

( + plugin: PanelPlugin | undefined, + options: P, + fieldConfig: FieldConfigSource +): 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]); +}; diff --git a/public/app/features/query/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts index 8bc4e5d6701..f68196514e6 100644 --- a/public/app/features/query/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -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++; } diff --git a/public/app/features/query/state/QueryRunner.ts b/public/app/features/query/state/QueryRunner.ts new file mode 100644 index 00000000000..179420d930a --- /dev/null +++ b/public/app/features/query/state/QueryRunner.ts @@ -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; + private subscription?: Unsubscribable; + private lastResult?: PanelData; + + constructor() { + this.subject = new ReplaySubject(1); + } + + get(): Observable { + 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 { + if (datasource && (datasource as any).query) { + return datasource as DataSourceApi; + } + return await getDatasourceSrv().get(datasource as string, scopedVars); +}