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 { ComponentClass } from 'react';
import { LoadingState, SeriesData } from './data'; import { LoadingState, SeriesData } from './data';
import { TimeRange } from './time'; 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; export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
...@@ -10,6 +10,9 @@ export interface PanelData { ...@@ -10,6 +10,9 @@ export interface PanelData {
series: SeriesData[]; series: SeriesData[];
request?: DataRequestInfo; request?: DataRequestInfo;
error?: DataQueryError; error?: DataQueryError;
// Data format expected by Angular panels
legacy?: LegacyResponseData[];
} }
export interface PanelProps<T = any> { export interface PanelProps<T = any> {
......
...@@ -19,6 +19,7 @@ import { PanelResizer } from './PanelResizer'; ...@@ -19,6 +19,7 @@ import { PanelResizer } from './PanelResizer';
import { PanelModel, DashboardModel } from '../state'; import { PanelModel, DashboardModel } from '../state';
import { PanelPlugin } from 'app/types'; import { PanelPlugin } from 'app/types';
import { AngularPanelPlugin, ReactPanelPlugin } from '@grafana/ui/src/types/panel'; import { AngularPanelPlugin, ReactPanelPlugin } from '@grafana/ui/src/types/panel';
import { AutoSizer } from 'react-virtualized';
export interface Props { export interface Props {
panel: PanelModel; panel: PanelModel;
...@@ -153,13 +154,24 @@ export class DashboardPanel extends PureComponent<Props, State> { ...@@ -153,13 +154,24 @@ export class DashboardPanel extends PureComponent<Props, State> {
const { plugin } = this.state; const { plugin } = this.state;
return ( return (
<PanelChrome <AutoSizer>
plugin={plugin} {({ width, height }) => {
panel={panel} if (width === 0) {
dashboard={dashboard} return null;
isFullscreen={isFullscreen} }
isEditing={isEditing} 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', () => { ...@@ -11,10 +11,8 @@ describe('PanelChrome', () => {
bbb: { value: 'BBB', text: 'upperB' }, bbb: { value: 'BBB', text: 'upperB' },
}, },
}, },
dashboard: {},
plugin: {},
isFullscreen: false, isFullscreen: false,
}); } as any);
}); });
it('Should replace a panel variable', () => { it('Should replace a panel variable', () => {
......
// Library import { getProcessedSeriesData } from './PanelQueryRunner';
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);
});
describe('QueryRunner', () => {
it('converts timeseries to table skipping nulls', () => { it('converts timeseries to table skipping nulls', () => {
const input1 = { const input1 = {
target: 'Field Name', 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