Commit ed019210 by Ryan McKinley Committed by GitHub

refactor: Merge PanelChrome and DataPanel, do query execution in PanelQueryRunner (#16632)

Moves query execution logic to PanelQueryRunner and structures PanelChrome so it subscribes to the query results rather than necessarily controlling their execution.
parent 36d64fec
import { ComponentClass } from 'react';
import { LoadingState, SeriesData } from './data';
import { TimeRange } from './time';
import { ScopedVars, DataRequestInfo, DataQueryError } from './datasource';
import { ScopedVars, DataRequestInfo, DataQueryError, LegacyResponseData } from './datasource';
export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
......@@ -10,6 +10,9 @@ export interface PanelData {
series: SeriesData[];
request?: DataRequestInfo;
error?: DataQueryError;
// Data format expected by Angular panels
legacy?: LegacyResponseData[];
}
export interface PanelProps<T = any> {
......
......@@ -19,6 +19,7 @@ import { PanelResizer } from './PanelResizer';
import { PanelModel, DashboardModel } from '../state';
import { PanelPlugin } from 'app/types';
import { AngularPanelPlugin, ReactPanelPlugin } from '@grafana/ui/src/types/panel';
import { AutoSizer } from 'react-virtualized';
export interface Props {
panel: PanelModel;
......@@ -153,13 +154,24 @@ export class DashboardPanel extends PureComponent<Props, State> {
const { plugin } = this.state;
return (
<PanelChrome
plugin={plugin}
panel={panel}
dashboard={dashboard}
isFullscreen={isFullscreen}
isEditing={isEditing}
/>
<AutoSizer>
{({ width, height }) => {
if (width === 0) {
return null;
}
return (
<PanelChrome
plugin={plugin}
panel={panel}
dashboard={dashboard}
isFullscreen={isFullscreen}
isEditing={isEditing}
width={width}
height={height}
/>
);
}}
</AutoSizer>
);
}
......
// Library
import React, { Component } from 'react';
// Services
import { DatasourceSrv, getDatasourceSrv } from 'app/features/plugins/datasource_srv';
// Utils
import kbn from 'app/core/utils/kbn';
// Types
import {
DataQueryError,
LoadingState,
SeriesData,
TimeRange,
ScopedVars,
toSeriesData,
guessFieldTypes,
DataQuery,
PanelData,
DataRequestInfo,
} from '@grafana/ui';
interface RenderProps {
data: PanelData;
}
export interface Props {
datasource: string | null;
queries: DataQuery[];
panelId: number;
dashboardId?: number;
isVisible?: boolean;
timeRange?: TimeRange;
widthPixels: number;
refreshCounter: number;
minInterval?: string;
maxDataPoints?: number;
scopedVars?: ScopedVars;
children: (r: RenderProps) => JSX.Element;
onDataResponse?: (data?: SeriesData[]) => void;
onError: (message: string, error: DataQueryError) => void;
}
export interface State {
isFirstLoad: boolean;
data: PanelData;
}
/**
* All panels will be passed tables that have our best guess at colum type set
*
* This is also used by PanelChrome for snapshot support
*/
export function getProcessedSeriesData(results?: any[]): SeriesData[] {
if (!results) {
return [];
}
const series: SeriesData[] = [];
for (const r of results) {
if (r) {
series.push(guessFieldTypes(toSeriesData(r)));
}
}
return series;
}
export class DataPanel extends Component<Props, State> {
static defaultProps = {
isVisible: true,
dashboardId: 1,
};
dataSourceSrv: DatasourceSrv = getDatasourceSrv();
isUnmounted = false;
constructor(props: Props) {
super(props);
this.state = {
isFirstLoad: true,
data: {
state: LoadingState.NotStarted,
series: [],
},
};
}
componentDidMount() {
this.issueQueries();
}
componentWillUnmount() {
this.isUnmounted = true;
}
async componentDidUpdate(prevProps: Props) {
if (!this.hasPropsChanged(prevProps)) {
return;
}
this.issueQueries();
}
hasPropsChanged(prevProps: Props) {
return this.props.refreshCounter !== prevProps.refreshCounter;
}
private issueQueries = async () => {
const {
isVisible,
queries,
datasource,
panelId,
dashboardId,
timeRange,
widthPixels,
maxDataPoints,
scopedVars,
onDataResponse,
onError,
} = this.props;
if (!isVisible) {
return;
}
if (!queries.length) {
this.setState({
data: {
state: LoadingState.Done,
series: [],
},
});
return;
}
this.setState({
data: {
...this.state.data,
loading: LoadingState.Loading,
},
});
try {
const ds = await this.dataSourceSrv.get(datasource, scopedVars);
const minInterval = this.props.minInterval || ds.interval;
const intervalRes = kbn.calculateInterval(timeRange, widthPixels, minInterval);
// make shallow copy of scoped vars,
// and add built in variables interval and interval_ms
const scopedVarsWithInterval = Object.assign({}, scopedVars, {
__interval: { text: intervalRes.interval, value: intervalRes.interval },
__interval_ms: { text: intervalRes.intervalMs.toString(), value: intervalRes.intervalMs },
});
const request: DataRequestInfo = {
timezone: 'browser',
panelId: panelId,
dashboardId: dashboardId,
range: timeRange,
rangeRaw: timeRange.raw,
interval: intervalRes.interval,
intervalMs: intervalRes.intervalMs,
targets: queries,
maxDataPoints: maxDataPoints || widthPixels,
scopedVars: scopedVarsWithInterval,
cacheTimeout: null,
startTime: Date.now(),
};
const resp = await ds.query(request);
request.endTime = Date.now();
if (this.isUnmounted) {
return;
}
// Make sure the data is SeriesData[]
const series = getProcessedSeriesData(resp.data);
if (onDataResponse) {
onDataResponse(series);
}
this.setState({
isFirstLoad: false,
data: {
state: LoadingState.Done,
series,
request,
},
});
} catch (err) {
console.log('DataPanel error', err);
let message = 'Query error';
if (err.message) {
message = err.message;
} else if (err.data && err.data.message) {
message = err.data.message;
} else if (err.data && err.data.error) {
message = err.data.error;
} else if (err.status) {
message = `Query error: ${err.status} ${err.statusText}`;
}
onError(message, err);
this.setState({
isFirstLoad: false,
data: {
...this.state.data,
loading: LoadingState.Error,
},
});
}
};
render() {
const { queries } = this.props;
const { isFirstLoad, data } = this.state;
const { state } = data;
// do not render component until we have first data
if (isFirstLoad && (state === LoadingState.Loading || state === LoadingState.NotStarted)) {
return this.renderLoadingState();
}
if (!queries.length) {
return (
<div className="panel-empty">
<p>Add a query to get some data!</p>
</div>
);
}
return (
<>
{state === LoadingState.Loading && this.renderLoadingState()}
{this.props.children({ data })}
</>
);
}
private renderLoadingState(): JSX.Element {
return (
<div className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</div>
);
}
}
......@@ -11,10 +11,8 @@ describe('PanelChrome', () => {
bbb: { value: 'BBB', text: 'upperB' },
},
},
dashboard: {},
plugin: {},
isFullscreen: false,
});
} as any);
});
it('Should replace a panel variable', () => {
......
// Libraries
import React, { PureComponent } from 'react';
import { AutoSizer } from 'react-virtualized';
// Services
import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
// Components
import { PanelHeader } from './PanelHeader/PanelHeader';
import { DataPanel } from './DataPanel';
import ErrorBoundary from '../../../core/components/ErrorBoundary/ErrorBoundary';
import ErrorBoundary from 'app/core/components/ErrorBoundary/ErrorBoundary';
// Utils
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
......@@ -19,12 +17,13 @@ import config from 'app/core/config';
// Types
import { DashboardModel, PanelModel } from '../state';
import { PanelPlugin } from 'app/types';
import { TimeRange, LoadingState, DataQueryError, SeriesData, toLegacyResponseData, PanelData } from '@grafana/ui';
import { TimeRange, LoadingState, PanelData, toLegacyResponseData } from '@grafana/ui';
import { ScopedVars } from '@grafana/ui';
import templateSrv from 'app/features/templating/template_srv';
import { getProcessedSeriesData } from './DataPanel';
import { PanelQueryRunner, getProcessedSeriesData } from '../state/PanelQueryRunner';
import { Unsubscribable } from 'rxjs';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
......@@ -34,53 +33,152 @@ export interface Props {
plugin: PanelPlugin;
isFullscreen: boolean;
isEditing: boolean;
width: number;
height: number;
}
export interface State {
refreshCounter: number;
isFirstLoad: boolean;
renderCounter: number;
timeInfo?: string;
timeRange?: TimeRange;
errorMessage: string | null;
// Current state of all events
data: PanelData;
}
export class PanelChrome extends PureComponent<Props, State> {
timeSrv: TimeSrv = getTimeSrv();
queryRunner = new PanelQueryRunner();
querySubscription: Unsubscribable;
constructor(props) {
constructor(props: Props) {
super(props);
this.state = {
refreshCounter: 0,
isFirstLoad: true,
renderCounter: 0,
errorMessage: null,
data: {
state: LoadingState.NotStarted,
series: [],
},
};
// Listen for changes to the query results
this.querySubscription = this.queryRunner.subscribe(this.panelDataObserver);
}
componentDidMount() {
this.props.panel.events.on('refresh', this.onRefresh);
this.props.panel.events.on('render', this.onRender);
this.props.dashboard.panelInitialized(this.props.panel);
const { panel, dashboard } = this.props;
panel.events.on('refresh', this.onRefresh);
panel.events.on('render', this.onRender);
dashboard.panelInitialized(this.props.panel);
// Move snapshot data into the query response
if (this.hasPanelSnapshot) {
this.setState({
data: {
state: LoadingState.Done,
series: getProcessedSeriesData(panel.snapshotData),
},
isFirstLoad: false,
});
} else if (!this.wantsQueryExecution) {
this.setState({ isFirstLoad: false });
}
}
componentWillUnmount() {
this.props.panel.events.off('refresh', this.onRefresh);
}
// Updates the response with information from the stream
panelDataObserver = {
next: (data: PanelData) => {
if (data.state === LoadingState.Error) {
const { error } = data;
if (error) {
let message = 'Query error';
if (error.message) {
message = error.message;
} else if (error.data && error.data.message) {
message = error.data.message;
} else if (error.data && error.data.error) {
message = error.data.error;
} else if (error.status) {
message = `Query error: ${error.status} ${error.statusText}`;
}
if (this.state.errorMessage !== message) {
this.setState({ errorMessage: message });
}
// this event is used by old query editors
this.props.panel.events.emit('data-error', error);
}
} else {
this.clearErrorState();
}
// Save the query response into the panel
if (data.state === LoadingState.Done && this.props.dashboard.snapshot) {
this.props.panel.snapshotData = data.series;
}
this.setState({ data, isFirstLoad: false });
// Notify query editors that the results have changed
if (this.props.isEditing) {
const events = this.props.panel.events;
let legacy = data.legacy;
if (!legacy) {
legacy = data.series.map(v => toLegacyResponseData(v));
}
// Angular query editors expect TimeSeries|TableData
events.emit('data-received', legacy);
// Notify react query editors
events.emit('series-data-received', data);
}
},
};
onRefresh = () => {
console.log('onRefresh');
if (!this.isVisible) {
return;
}
const { panel } = this.props;
const { panel, width } = this.props;
const timeData = applyPanelTimeOverrides(panel, this.timeSrv.timeRange());
this.setState({
refreshCounter: this.state.refreshCounter + 1,
timeRange: timeData.timeRange,
timeInfo: timeData.timeInfo,
});
// Issue Query
if (this.wantsQueryExecution && !this.hasPanelSnapshot) {
if (width < 0) {
console.log('No width yet... wait till we know');
return;
}
this.queryRunner.run({
datasource: panel.datasource,
queries: panel.targets,
panelId: panel.id,
dashboardId: this.props.dashboard.id,
timezone: this.props.dashboard.timezone,
timeRange: timeData.timeRange,
widthPixels: width,
minInterval: undefined, // Currently not passed in DataPanel?
maxDataPoints: panel.maxDataPoints,
scopedVars: panel.scopedVars,
cacheTimeout: panel.cacheTimeout,
});
}
};
onRender = () => {
......@@ -97,35 +195,6 @@ export class PanelChrome extends PureComponent<Props, State> {
return templateSrv.replace(value, vars, format);
};
onDataResponse = (data?: SeriesData[]) => {
if (this.props.dashboard.isSnapshot()) {
this.props.panel.snapshotData = data;
}
// clear error state (if any)
this.clearErrorState();
if (this.props.isEditing) {
const events = this.props.panel.events;
if (!data) {
data = [];
}
// Angular query editors expect TimeSeries|TableData
events.emit('data-received', data.map(v => toLegacyResponseData(v)));
// Notify react query editors
events.emit('series-data-received', data);
}
};
onDataError = (message: string, error: DataQueryError) => {
if (this.state.errorMessage !== message) {
this.setState({ errorMessage: message });
}
// this event is used by old query editors
this.props.panel.events.emit('data-error', error);
};
onPanelError = (message: string) => {
if (this.state.errorMessage !== message) {
this.setState({ errorMessage: message });
......@@ -147,112 +216,82 @@ export class PanelChrome extends PureComponent<Props, State> {
return panel.snapshotData && panel.snapshotData.length;
}
get needsQueryExecution() {
return this.hasPanelSnapshot || this.props.plugin.dataFormats.length > 0;
get wantsQueryExecution() {
return this.props.plugin.dataFormats.length > 0;
}
get getDataForPanel() {
return {
state: LoadingState.Done,
series: this.hasPanelSnapshot ? getProcessedSeriesData(this.props.panel.snapshotData) : [],
};
}
renderPanelPlugin(data: PanelData, width: number, height: number): JSX.Element {
renderPanel(width: number, height: number): JSX.Element {
const { panel, plugin } = this.props;
const { timeRange, renderCounter } = this.state;
const { timeRange, renderCounter, data, isFirstLoad } = this.state;
const PanelComponent = plugin.reactPlugin.panel;
// This is only done to increase a counter that is used by backend
// image rendering (phantomjs/headless chrome) to know when to capture image
if (data.state === LoadingState.Done) {
const loading = data.state;
if (loading === LoadingState.Done) {
profiler.renderingCompleted(panel.id);
}
return (
<div className="panel-content">
<PanelComponent
data={data}
timeRange={timeRange}
options={panel.getOptions(plugin.reactPlugin.defaults)}
width={width - 2 * config.theme.panelPadding.horizontal}
height={height - PANEL_HEADER_HEIGHT - config.theme.panelPadding.vertical}
renderCounter={renderCounter}
replaceVariables={this.replaceVariables}
/>
</div>
);
}
// do not render component until we have first data
if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
return this.renderLoadingState();
}
renderPanelBody = (width: number, height: number): JSX.Element => {
const { panel } = this.props;
const { refreshCounter, timeRange } = this.state;
const { datasource, targets } = panel;
return (
<>
{this.needsQueryExecution ? (
<DataPanel
panelId={panel.id}
datasource={datasource}
queries={targets}
{loading === LoadingState.Loading && this.renderLoadingState()}
<div className="panel-content">
<PanelComponent
data={data}
timeRange={timeRange}
isVisible={this.isVisible}
widthPixels={width}
refreshCounter={refreshCounter}
scopedVars={panel.scopedVars}
onDataResponse={this.onDataResponse}
onError={this.onDataError}
>
{({ data }) => {
return this.renderPanelPlugin(data, width, height);
}}
</DataPanel>
) : (
this.renderPanelPlugin(this.getDataForPanel, width, height)
)}
options={panel.getOptions(plugin.reactPlugin.defaults)}
width={width - 2 * config.theme.panelPadding.horizontal}
height={height - PANEL_HEADER_HEIGHT - config.theme.panelPadding.vertical}
renderCounter={renderCounter}
replaceVariables={this.replaceVariables}
/>
</div>
</>
);
};
}
private renderLoadingState(): JSX.Element {
return (
<div className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</div>
);
}
render() {
const { dashboard, panel, isFullscreen } = this.props;
const { dashboard, panel, isFullscreen, width, height } = this.props;
const { errorMessage, timeInfo } = this.state;
const { transparent } = panel;
const containerClassNames = `panel-container panel-container--absolute ${transparent ? 'panel-transparent' : ''}`;
return (
<AutoSizer>
{({ width, height }) => {
if (width === 0) {
return null;
}
return (
<div className={containerClassNames}>
<PanelHeader
panel={panel}
dashboard={dashboard}
timeInfo={timeInfo}
title={panel.title}
description={panel.description}
scopedVars={panel.scopedVars}
links={panel.links}
error={errorMessage}
isFullscreen={isFullscreen}
/>
<ErrorBoundary>
{({ error, errorInfo }) => {
if (errorInfo) {
this.onPanelError(error.message || DEFAULT_PLUGIN_ERROR);
return null;
}
return this.renderPanelBody(width, height);
}}
</ErrorBoundary>
</div>
);
}}
</AutoSizer>
<div className={containerClassNames}>
<PanelHeader
panel={panel}
dashboard={dashboard}
timeInfo={timeInfo}
title={panel.title}
description={panel.description}
scopedVars={panel.scopedVars}
links={panel.links}
error={errorMessage}
isFullscreen={isFullscreen}
/>
<ErrorBoundary>
{({ error, errorInfo }) => {
if (errorInfo) {
this.onPanelError(error.message || DEFAULT_PLUGIN_ERROR);
return null;
}
return this.renderPanel(width, height);
}}
</ErrorBoundary>
</div>
);
}
}
// Library
import React from 'react';
import { DataPanel, getProcessedSeriesData } from './DataPanel';
describe('DataPanel', () => {
let dataPanel: DataPanel;
beforeEach(() => {
dataPanel = new DataPanel({
queries: [],
panelId: 1,
widthPixels: 100,
refreshCounter: 1,
datasource: 'xxx',
children: r => {
return <div>hello</div>;
},
onError: (message, error) => {},
});
});
it('starts with unloaded state', () => {
expect(dataPanel.state.isFirstLoad).toBe(true);
});
import { getProcessedSeriesData } from './PanelQueryRunner';
describe('QueryRunner', () => {
it('converts timeseries to table skipping nulls', () => {
const input1 = {
target: 'Field Name',
......
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { Subject, Unsubscribable, PartialObserver } from 'rxjs';
import {
guessFieldTypes,
toSeriesData,
PanelData,
LoadingState,
DataQuery,
TimeRange,
ScopedVars,
DataRequestInfo,
SeriesData,
DataQueryError,
toLegacyResponseData,
isSeriesData,
DataSourceApi,
} from '@grafana/ui';
import cloneDeep from 'lodash/cloneDeep';
import kbn from 'app/core/utils/kbn';
export interface QueryRunnerOptions {
ds?: DataSourceApi; // if they already have the datasource, don't look it up
datasource: string | null;
queries: DataQuery[];
panelId: number;
dashboardId?: number;
timezone?: string;
timeRange?: TimeRange;
widthPixels: number;
minInterval?: string;
maxDataPoints?: number;
scopedVars?: ScopedVars;
cacheTimeout?: string;
delayStateNotification?: number; // default 100ms.
}
export enum PanelQueryRunnerFormat {
series = 'series',
legacy = 'legacy',
}
export class PanelQueryRunner {
private subject?: Subject<PanelData>;
private sendSeries = false;
private sendLegacy = false;
private data = {
state: LoadingState.NotStarted,
series: [],
} as PanelData;
/**
* Listen for updates to the PanelData. If a query has already run for this panel,
* the results will be immediatly passed to the observer
*/
subscribe(observer: PartialObserver<PanelData>, format = PanelQueryRunnerFormat.series): Unsubscribable {
if (!this.subject) {
this.subject = new Subject(); // Delay creating a subject until someone is listening
}
if (format === PanelQueryRunnerFormat.legacy) {
this.sendLegacy = true;
} else {
this.sendSeries = true;
}
// Send the last result
if (this.data.state !== LoadingState.NotStarted) {
// TODO: make sure it has legacy if necessary
observer.next(this.data);
}
return this.subject.subscribe(observer);
}
async run(options: QueryRunnerOptions): Promise<PanelData> {
if (!this.subject) {
this.subject = new Subject();
}
const {
queries,
timezone,
datasource,
panelId,
dashboardId,
timeRange,
cacheTimeout,
widthPixels,
maxDataPoints,
scopedVars,
delayStateNotification,
} = options;
const request: DataRequestInfo = {
timezone,
panelId,
dashboardId,
range: timeRange,
rangeRaw: timeRange.raw,
interval: '',
intervalMs: 0,
targets: cloneDeep(queries),
maxDataPoints: maxDataPoints || widthPixels,
scopedVars: scopedVars || {},
cacheTimeout,
startTime: Date.now(),
};
if (!queries) {
this.data = {
state: LoadingState.Done,
series: [], // Clear the data
legacy: [],
request,
};
this.subject.next(this.data);
return this.data;
}
try {
const ds = options.ds ? options.ds : await getDatasourceSrv().get(datasource, request.scopedVars);
const minInterval = options.minInterval || ds.interval;
const norm = kbn.calculateInterval(timeRange, widthPixels, minInterval);
// 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, value: norm.intervalMs },
});
request.interval = norm.interval;
request.intervalMs = norm.intervalMs;
// Send a loading status event on slower queries
setTimeout(() => {
if (!request.endTime) {
this.data = {
...this.data,
state: LoadingState.Loading,
request,
};
this.subject.next(this.data);
}
}, delayStateNotification || 100);
const resp = await ds.query(request);
request.endTime = Date.now();
// Make sure the response is in a supported format
const series = this.sendSeries ? getProcessedSeriesData(resp.data) : [];
const legacy = this.sendLegacy
? resp.data.map(v => {
if (isSeriesData(v)) {
return toLegacyResponseData(v);
}
return v;
})
: undefined;
// The Result
this.data = {
state: LoadingState.Done,
series,
legacy,
request,
};
this.subject.next(this.data);
return this.data;
} catch (err) {
const error = err as DataQueryError;
if (!error.message) {
err.message = 'Query Error';
}
this.data = {
...this.data, // ?? Should we keep existing data, or clear it ???
state: LoadingState.Error,
error: error,
};
this.subject.next(this.data);
return this.data;
}
}
}
/**
* All panels will be passed tables that have our best guess at colum type set
*
* This is also used by PanelChrome for snapshot support
*/
export function getProcessedSeriesData(results?: any[]): SeriesData[] {
if (!results) {
return [];
}
const series: SeriesData[] = [];
for (const r of results) {
if (r) {
series.push(guessFieldTypes(toSeriesData(r)));
}
}
return series;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment