mirror of
https://github.com/grafana/grafana.git
synced 2025-01-09 23:53:25 -06:00
Plugin: PanelRenderer and simplified QueryRunner to be used from plugins. (#31901)
This commit is contained in:
parent
30e5afa18c
commit
cb2a63b5c6
@ -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';
|
||||
|
@ -6,6 +6,7 @@ import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
|
||||
export enum CoreApp {
|
||||
Dashboard = 'dashboard',
|
||||
Explore = 'explore',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
export interface AppRootProps<T = KeyValue> {
|
||||
|
38
packages/grafana-data/src/types/queryRunner.ts
Normal file
38
packages/grafana-data/src/types/queryRunner.ts
Normal 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;
|
||||
}
|
53
packages/grafana-runtime/src/components/PanelRenderer.tsx
Normal file
53
packages/grafana-runtime/src/components/PanelRenderer.tsx
Normal 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;
|
||||
}
|
@ -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';
|
||||
|
33
packages/grafana-runtime/src/services/QueryRunner.ts
Normal file
33
packages/grafana-runtime/src/services/QueryRunner.ts
Normal 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();
|
||||
};
|
@ -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
|
||||
|
115
public/app/features/panel/PanelRenderer.tsx
Normal file
115
public/app/features/panel/PanelRenderer.tsx
Normal 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]);
|
||||
};
|
@ -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++;
|
||||
}
|
||||
|
||||
|
148
public/app/features/query/state/QueryRunner.ts
Normal file
148
public/app/features/query/state/QueryRunner.ts
Normal 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);
|
||||
}
|
Loading…
Reference in New Issue
Block a user