Skip to content
Draft
94 changes: 88 additions & 6 deletions src/datasources/data-frame/DataFrameDataSource.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import TTLCache from '@isaacs/ttlcache';
import deepEqual from 'fast-deep-equal';
import { DataQueryRequest, DataSourceInstanceSettings, FieldType, TimeRange, FieldDTO, dateTime, DataFrameDTO, MetricFindValue, TestDataSourceResponse, DataSourceJsonData } from '@grafana/data';
import { BackendSrv, TemplateSrv, getBackendSrv, getTemplateSrv } from '@grafana/runtime';
import { BackendSrv, TemplateSrv, getBackendSrv, getTemplateSrv, locationService } from '@grafana/runtime';
import {
ColumnDataType,
DataFrameQuery,
Expand Down Expand Up @@ -35,12 +35,21 @@ export class DataFrameDataSource extends DataSourceBase<DataFrameQuery, DataSour

defaultQuery = defaultQuery;

async runQuery(query: DataFrameQuery, { range, scopedVars, maxDataPoints }: DataQueryRequest): Promise<DataFrameDTO> {
async runQuery(
query: DataFrameQuery,
request: DataQueryRequest
): Promise<DataFrameDTO> {
const { range, scopedVars, maxDataPoints } = request;
const processedQuery = this.processQuery(query);
processedQuery.tableId = this.templateSrv.replace(processedQuery.tableId, scopedVars);
processedQuery.columns = replaceVariables(processedQuery.columns, this.templateSrv);
const properties = await this.getTableProperties(processedQuery.tableId);

this.initializeFetchHighResolutionData(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Plotly" panel is updated to handle zoom based on the query params state.

Hence, when the dashboard is loaded for the first time, the query params need to be updated.

I've added this here because this was the only place in the data source where both query and request values are available.

processedQuery.fetchHighResolutionData,
request.panelId?.toString()
);

if (processedQuery.type === DataFrameQueryType.Properties) {
return {
refId: processedQuery.refId,
Expand All @@ -49,7 +58,13 @@ export class DataFrameDataSource extends DataSourceBase<DataFrameQuery, DataSour
};
} else {
const columns = this.getColumnTypes(processedQuery.columns, properties?.columns ?? []);
const tableData = await this.getDecimatedTableData(processedQuery, columns, range, maxDataPoints);
const tableData = await this.getDecimatedTableData(
processedQuery,
columns,
range,
maxDataPoints,
request.panelId?.toString()
);
return {
refId: processedQuery.refId,
name: properties.name,
Expand All @@ -74,11 +89,32 @@ export class DataFrameDataSource extends DataSourceBase<DataFrameQuery, DataSour
return properties;
}

async getDecimatedTableData(query: DataFrameQuery, columns: Column[], timeRange: TimeRange, intervals = 1000): Promise<TableDataRows> {
async getDecimatedTableData(
query: DataFrameQuery,
columns: Column[],
timeRange: TimeRange,
intervals = 1000,
panelId = ''
): Promise<TableDataRows> {
const filters: ColumnFilter[] = [];

if (query.applyTimeFilters) {
filters.push(...this.constructTimeFilters(columns, timeRange));
let enabledPanelIds: string[] = this.getEnabledPanelIds();
const isHighResolutionEnabled = enabledPanelIds.includes(panelId) && columns.length > 0;
if (isHighResolutionEnabled) {
/**
* If `x-axis` selection is of type "TIMESTAMP", the below filters should be applied.
* Only after data source migration, we'll get those details here as a part of `columns`. Hence just handled numerical values for now.
* filters.push(...this.constructTimeFilters(columns, timeRange));
*/

const queryParams = locationService.getSearchObject();
// Once we have a seprate control to select x-axis, we can use that value directly instead of assuming the first column as the x-axis.
const xField = columns[0].name;
const xMin = queryParams[`${xField}-min`];
const xMax = queryParams[`${xField}-max`];
if ( xMin !== '' && xMax !== '') {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the user zooms in plot, the "Plotly" panel will update the query params and trigger a whole dashboard refresh.

Those range values are used to form filters for the query decimated data API call.

filters.push(...this.constructXAxisNumberFilters(xField, Math.floor(Number(xMin)), Math.floor(Number(xMax))));
}
}

if (query.filterNulls) {
Expand Down Expand Up @@ -124,6 +160,45 @@ export class DataFrameDataSource extends DataSourceBase<DataFrameQuery, DataSour
return deepEqual(migratedQuery, query) ? (query as ValidDataFrameQuery) : migratedQuery;
}

initializeFetchHighResolutionData(
enableHighResolutionZoom: boolean,
panelId = ''
): void {
if (panelId === '' || this.isInEditPanelMode()) {
return;
}

let enabledPanelIds: string[] = this.getEnabledPanelIds();
if (enableHighResolutionZoom && !enabledPanelIds.includes(panelId)) {
enabledPanelIds.push(panelId);
}

locationService.partial({
[`fetchHighResolutionData`]: enabledPanelIds.join(','),
}, true);
}

getEnabledPanelIds(): string[] {
const queryParams = locationService.getSearchObject();
const fetchHighResolutionDataOnZoom = queryParams['fetchHighResolutionData'];
let enabledPanelIds: string[] = [];

if (
fetchHighResolutionDataOnZoom !== undefined
&& typeof fetchHighResolutionDataOnZoom === 'string'
&& fetchHighResolutionDataOnZoom !== ''
) {
enabledPanelIds = fetchHighResolutionDataOnZoom.split(',');
}

return enabledPanelIds;
}

isInEditPanelMode(): boolean {
const queryParams = locationService.getSearchObject();
return queryParams['editPanel'] !== undefined;
}

async metricFindQuery(tableQuery: DataFrameQuery): Promise<MetricFindValue[]> {
const tableProperties = await this.getTableProperties(tableQuery.tableId);
return tableProperties.columns.map(col => ({ text: col.name, value: col.name }));
Expand Down Expand Up @@ -179,6 +254,13 @@ export class DataFrameDataSource extends DataSourceBase<DataFrameQuery, DataSour
];
}

private constructXAxisNumberFilters(xField: string, xMin: number, xMax: number): ColumnFilter[] {
return [
{ column: xField, operation: 'GREATER_THAN_EQUALS', value: xMin.toString() },
{ column: xField, operation: 'LESS_THAN_EQUALS', value: xMax.toString() },
];
}

private constructNullFilters(columns: Column[]): ColumnFilter[] {
return columns.flatMap(({ name, columnType, dataType }) => {
const filters: ColumnFilter[] = [];
Expand Down
79 changes: 72 additions & 7 deletions src/datasources/data-frame/components/DataFrameQueryEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React, { useState } from 'react';
import { useAsync } from 'react-use';
import React, { useEffect, useMemo, useState } from 'react';
import { useAsync, useLocation } from 'react-use';
import { SelectableValue, toOption } from '@grafana/data';
import { InlineField, InlineSwitch, MultiSelect, Select, AsyncSelect, RadioButtonGroup } from '@grafana/ui';
import { decimationMethods } from '../constants';
import _ from 'lodash';
import { getTemplateSrv } from '@grafana/runtime';
import { getTemplateSrv, locationService } from '@grafana/runtime';
import { isValidId } from '../utils';
import { FloatingError, parseErrorMessage } from '../../../core/errors';
import { DataFrameQueryEditorCommon, Props } from './DataFrameQueryEditorCommon';
Expand All @@ -14,7 +14,10 @@ import { DataFrameQueryType } from '../types';
export const DataFrameQueryEditor = (props: Props) => {
const [errorMsg, setErrorMsg] = useState<string | undefined>('');
const handleError = (error: Error) => setErrorMsg(parseErrorMessage(error));
const common = new DataFrameQueryEditorCommon(props, handleError);
const common = useMemo(
() => new DataFrameQueryEditorCommon(props, handleError)
, [props]
);
const tableProperties = useAsync(() => common.datasource.getTableProperties(common.query.tableId).catch(handleError), [common.query.tableId]);

const handleColumnChange = (items: Array<SelectableValue<string>>) => {
Expand All @@ -27,6 +30,68 @@ export const DataFrameQueryEditor = (props: Props) => {
return columnOptions;
}

// When the user toggles the "Fetch high resolution data on zoom" switch, we update the URL query parameters
const updateFetchHighResolutionDataStateInQueryParams = (fetchHighResolutionData: boolean) => {
const editPanelId = getPanelId();
let fetchHighResolutionDataEnabledPanelIds: string[] = getEnabledPanelIds();

if (fetchHighResolutionData && !fetchHighResolutionDataEnabledPanelIds.includes(editPanelId)) {
fetchHighResolutionDataEnabledPanelIds.push(editPanelId);
}

if (!fetchHighResolutionData && fetchHighResolutionDataEnabledPanelIds.includes(editPanelId)) {
fetchHighResolutionDataEnabledPanelIds = fetchHighResolutionDataEnabledPanelIds.filter((panelId) => panelId !== editPanelId);
}

locationService.partial({
[`fetchHighResolutionData`]: fetchHighResolutionDataEnabledPanelIds.join(','),
}, true);
}

// When URL query parameters change, we check if the current panel is enabled for fetching high resolution data and update the state in all the queries available in that panel - To sync the state across queries.
const location = useLocation();
useEffect(() => {
const editPanelId = getPanelId();
const fetchHighResolutionDataEnabledPanelIds: string[] = getEnabledPanelIds();

const updatedFetchHighResolutionDataState = fetchHighResolutionDataEnabledPanelIds.includes(editPanelId ?? '');
if (updatedFetchHighResolutionDataState !== common.query.fetchHighResolutionData) {
common.handleQueryChange({ ...common.query, fetchHighResolutionData: updatedFetchHighResolutionDataState }, true);
}
}, [location.search, common]);

// Utility functions
const getEnabledPanelIds = (): string[] => {
const queryParams = locationService.getSearchObject();
const fetchHighResolutionDataOnZoom = queryParams['fetchHighResolutionData'];
let enabledPanelIds: string[] = [];

if (
fetchHighResolutionDataOnZoom !== undefined
&& typeof fetchHighResolutionDataOnZoom === 'string'
&& fetchHighResolutionDataOnZoom !== ''
) {
enabledPanelIds = fetchHighResolutionDataOnZoom.split(',');
}

return enabledPanelIds;
}

const getPanelId = (): string => {
const queryParams = locationService.getSearchObject();
const editPanelId = queryParams['editPanel'];

if (
editPanelId !== undefined
&& (typeof editPanelId === 'string' || typeof editPanelId === 'number')
&& editPanelId !== ''
) {
return editPanelId.toString();
}
return '';
}


return (
<div style={{ position: 'relative' }}>
<InlineField label="Query type" tooltip={tooltips.queryType}>
Expand Down Expand Up @@ -74,10 +139,10 @@ export const DataFrameQueryEditor = (props: Props) => {
onChange={event => common.handleQueryChange({ ...common.query, filterNulls: event.currentTarget.checked }, true)}
></InlineSwitch>
</InlineField>
<InlineField label="Use time range" tooltip={tooltips.useTimeRange}>
<InlineField label="Fetch high resolution data on zoom" tooltip={tooltips.useTimeRange}>
<InlineSwitch
value={common.query.applyTimeFilters}
onChange={event => common.handleQueryChange({ ...common.query, applyTimeFilters: event.currentTarget.checked }, true)}
value={common.query.fetchHighResolutionData}
onChange={event => updateFetchHighResolutionDataStateInQueryParams(event.currentTarget.checked)}
></InlineSwitch>
</InlineField>
</>
Expand Down
4 changes: 2 additions & 2 deletions src/datasources/data-frame/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface DataFrameQuery extends DataQuery {
columns?: string[];
decimationMethod?: string;
filterNulls?: boolean;
applyTimeFilters?: boolean;
fetchHighResolutionData?: boolean;
}

export const defaultQuery: Omit<ValidDataFrameQuery, 'refId'> = {
Expand All @@ -21,7 +21,7 @@ export const defaultQuery: Omit<ValidDataFrameQuery, 'refId'> = {
columns: [],
decimationMethod: 'LOSSY',
filterNulls: false,
applyTimeFilters: false
fetchHighResolutionData: false
};

export type ValidDataFrameQuery = DataFrameQuery & Required<Omit<DataFrameQuery, keyof DataQuery>>;
Expand Down
22 changes: 19 additions & 3 deletions src/panels/plotly/PlotlyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '@grafana/data';
import { AxisLabels, PanelOptions } from './types';
import { useTheme2, ContextMenu, MenuItemsGroup, linkModelToContextMenuItems } from '@grafana/ui';
import { getTemplateSrv, PanelDataErrorView } from '@grafana/runtime';
import { getTemplateSrv, PanelDataErrorView, locationService } from '@grafana/runtime';
import { getFieldsByName, notEmpty, Plot, renderMenuItems, useTraceColors } from './utils';
import { AxisType, Legend, PlotData, PlotType, toImage, Icons, PlotlyHTMLElement } from 'plotly.js-basic-dist-min';
import { saveAs } from 'file-saver';
Expand All @@ -28,7 +28,7 @@ interface MenuState {
interface Props extends PanelProps<PanelOptions> {}

export const PlotlyPanel: React.FC<Props> = (props) => {
const { data, width, height, options } = props;
const { data, width, height, options, id } = props;
const [menu, setMenu] = useState<MenuState>({ x: 0, y: 0, show: false, items: [] });
const theme = useTheme2();

Expand Down Expand Up @@ -153,7 +153,23 @@ export const PlotlyPanel: React.FC<Props> = (props) => {
props.onOptionsChange({...options, xAxis: { ...options.xAxis, min: from.valueOf(), max: to.valueOf() } });
}
} else {
props.onOptionsChange({...options, xAxis: { ...options.xAxis, min: xAxisMin, max: xAxisMax } });
const queryParams = locationService.getSearchObject();
const fetchHighResolutionDataOnZoom = queryParams['fetchHighResolutionData'];

if (
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When fetchHighResolutionDataOnZoom is enabled in a panel, and if the user zooms in plot, the "Plotly" panel will update the query params and trigger a whole dashboard refresh.

We need to refresh the dashboard as a whole because there aren't any out-of-the-box APIs available to handle this in any other optimised way.

fetchHighResolutionDataOnZoom !== undefined
&& typeof fetchHighResolutionDataOnZoom === 'string'
&& fetchHighResolutionDataOnZoom !== ''
&& fetchHighResolutionDataOnZoom.split(',').includes(id.toString())
) {
locationService.partial({
[`${options.xAxis.field}-min`]: xAxisMin,
[`${options.xAxis.field}-max`]: xAxisMax
}, true);
locationService.reload();
} else {
props.onOptionsChange({...options, xAxis: { ...options.xAxis, min: xAxisMin, max: xAxisMax } });
}
}
};

Expand Down
Loading