mirror of https://github.com/grafana/grafana
Plugin: PanelRenderer and simplified QueryRunner to be used from plugins. (#31901)
parent
30e5afa18c
commit
cb2a63b5c6
@ -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; |
||||
} |
@ -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; |
||||
} |
@ -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(); |
||||
}; |
@ -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]); |
||||
}; |
@ -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); |
||||
} |
Loading…
Reference in new issue