diff --git a/packages/grafana-data/src/dataframe/processDataFrame.ts b/packages/grafana-data/src/dataframe/processDataFrame.ts index 02f482c7cc9..6c2db82b2f3 100644 --- a/packages/grafana-data/src/dataframe/processDataFrame.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.ts @@ -262,7 +262,10 @@ export const isTableData = (data: any): data is DataFrame => data && data.hasOwn export const isDataFrame = (data: any): data is DataFrame => data && data.hasOwnProperty('fields'); -export const toDataFrame = (data: any): DataFrame => { +/** + * Inspect any object and return the results as a DataFrame + */ +export function toDataFrame(data: any): DataFrame { if ('fields' in data) { // DataFrameDTO does not have length if ('length' in data) { @@ -292,7 +295,7 @@ export const toDataFrame = (data: any): DataFrame => { console.warn('Can not convert', data); throw new Error('Unsupported data format'); -}; +} export const toLegacyResponseData = (frame: DataFrame): TimeSeries | TableData => { const { fields } = frame; diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index fba507e2a4b..225a42af618 100644 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -351,8 +351,15 @@ class GraphElement { .appendTo(this.elem); } - if (this.ctrl.dataWarning) { - $(`
${this.ctrl.dataWarning.title}
`).appendTo(this.elem); + const { dataWarning } = this.ctrl; + if (dataWarning) { + const msg = $(`
${dataWarning.title}
`); + if (dataWarning.action) { + $(``) + .click(dataWarning.action) + .appendTo(msg); + } + msg.appendTo(this.elem); } this.thresholdManager.draw(plot); diff --git a/public/app/plugins/panel/graph/graph_tooltip.ts b/public/app/plugins/panel/graph/graph_tooltip.ts index e3319e5d6de..5a8931b3b9e 100644 --- a/public/app/plugins/panel/graph/graph_tooltip.ts +++ b/public/app/plugins/panel/graph/graph_tooltip.ts @@ -150,7 +150,7 @@ export default function GraphTooltip(this: any, elem: any, dashboard: any, scope }; elem.mouseleave(() => { - if (panel.tooltip.shared) { + if (panel.tooltip?.shared) { const plot = elem.data().plot; if (plot) { $tooltip.detach(); diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index 4197092b929..bfc13eec892 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -20,6 +20,11 @@ import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/l import { auto } from 'angular'; import { AnnotationsSrv } from 'app/features/annotations/all'; import { CoreEvents } from 'app/types'; +import { DataWarning } from './types'; +import { getLocationSrv } from '@grafana/runtime'; +import { getDataTimeRange } from './utils'; +import { changePanelPlugin } from 'app/features/dashboard/state/actions'; +import { dispatch } from 'app/store/store'; class GraphCtrl extends MetricsPanelCtrl { static template = template; @@ -33,7 +38,7 @@ class GraphCtrl extends MetricsPanelCtrl { alertState: any; annotationsPromise: any; - dataWarning: any; + dataWarning?: DataWarning; colors: any = []; subTabIndex: number; processor: DataProcessor; @@ -218,27 +223,7 @@ class GraphCtrl extends MetricsPanelCtrl { this.linkVariableSuggestions = getDataLinksVariableSuggestions(data); - this.dataWarning = null; - const datapointsCount = this.seriesList.reduce((prev, series) => { - return prev + series.datapoints.length; - }, 0); - - if (datapointsCount === 0) { - this.dataWarning = { - title: 'No data', - tip: 'No data returned from query', - }; - } else { - for (const series of this.seriesList) { - if (series.isOutsideRange) { - this.dataWarning = { - title: 'Data outside time range', - tip: 'Can be caused by timezone mismatch or missing time filter in query', - }; - break; - } - } - } + this.dataWarning = this.getDataWarning(); this.annotationsPromise.then( (result: { alertState: any; annotations: any }) => { @@ -261,6 +246,66 @@ class GraphCtrl extends MetricsPanelCtrl { ); } + getDataWarning(): DataWarning { + const datapointsCount = this.seriesList.reduce((prev, series) => { + return prev + series.datapoints.length; + }, 0); + + if (datapointsCount === 0) { + if (this.dataList) { + for (const frame of this.dataList) { + if (frame.length && frame.fields?.length) { + return { + title: 'Unable to graph data', + tip: 'Data exists, but is not timeseries', + actionText: 'Switch to table view', + action: () => { + console.log('Change from graph to table'); + dispatch(changePanelPlugin(this.panel, 'table')); + }, + }; + } + } + } + + return { + title: 'No data', + tip: 'No data returned from query', + }; + } + + // Look for data points outside time range + for (const series of this.seriesList) { + if (!series.isOutsideRange) { + continue; + } + + const dataWarning: DataWarning = { + title: 'Data outside time range', + tip: 'Can be caused by timezone mismatch or missing time filter in query', + }; + + const range = getDataTimeRange(this.dataList); + + if (range) { + dataWarning.actionText = 'Zoom to data'; + dataWarning.action = () => { + getLocationSrv().update({ + partial: true, + query: { + from: range.from, + to: range.to, + }, + }); + }; + } + + return dataWarning; + } + + return null; + } + onRender() { if (!this.seriesList) { return; diff --git a/public/app/plugins/panel/graph/types.ts b/public/app/plugins/panel/graph/types.ts new file mode 100644 index 00000000000..5daa308f189 --- /dev/null +++ b/public/app/plugins/panel/graph/types.ts @@ -0,0 +1,6 @@ +export interface DataWarning { + title: string; + tip: string; + action?: () => void; + actionText?: string; +} diff --git a/public/app/plugins/panel/graph/utils.test.ts b/public/app/plugins/panel/graph/utils.test.ts new file mode 100644 index 00000000000..daa1f03b01b --- /dev/null +++ b/public/app/plugins/panel/graph/utils.test.ts @@ -0,0 +1,17 @@ +import { toDataFrame, FieldType } from '@grafana/data'; +import { getDataTimeRange } from './utils'; + +describe('DataFrame utility functions', () => { + const frame = toDataFrame({ + fields: [ + { name: 'fist', type: FieldType.time, values: [2, 3, 5] }, + { name: 'second', type: FieldType.time, values: [7, 8, 9] }, + { name: 'third', type: FieldType.number, values: [2000, 3000, 1000] }, + ], + }); + it('Should find time range', () => { + const range = getDataTimeRange([frame]); + expect(range!.from).toEqual(2); + expect(range!.to).toEqual(9); + }); +}); diff --git a/public/app/plugins/panel/graph/utils.ts b/public/app/plugins/panel/graph/utils.ts new file mode 100644 index 00000000000..4464fbcf650 --- /dev/null +++ b/public/app/plugins/panel/graph/utils.ts @@ -0,0 +1,24 @@ +import { DataFrame, ReducerID, reduceField, AbsoluteTimeRange, FieldType } from '@grafana/data'; + +/** + * Find the min and max time that covers all data + */ +export function getDataTimeRange(frames: DataFrame[]): AbsoluteTimeRange | undefined { + const range: AbsoluteTimeRange = { + from: Number.MAX_SAFE_INTEGER, + to: Number.MIN_SAFE_INTEGER, + }; + let found = false; + const reducers = [ReducerID.min, ReducerID.max]; + for (const frame of frames) { + for (const field of frame.fields) { + if (field.type === FieldType.time) { + const calcs = reduceField({ field, reducers }); + range.from = Math.min(range.from, calcs[ReducerID.min]); + range.to = Math.max(range.to, calcs[ReducerID.max]); + found = true; + } + } + } + return found ? range : undefined; +}