Merge pull request #14007 from grafana/stackdriver-template-query-editor

Stackdriver template query editor
This commit is contained in:
Daniel Lee 2018-11-20 13:41:18 +01:00 committed by GitHub
commit 4aeea56342
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 822 additions and 130 deletions

View File

@ -158,9 +158,9 @@ Example Result: `compute.googleapis.com/instance/cpu/usage_time - server1-prod`
It is also possible to resolve the name of the Monitored Resource Type.
| Alias Pattern Format | Description | Example Result |
| ------------------------ | ------------------------------------------------| ---------------- |
| `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` |
| Alias Pattern Format | Description | Example Result |
| -------------------- | ----------------------------------------------- | -------------- |
| `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` |
Example Alias By: `{{resource.type}} - {{metric.type}}`
@ -177,7 +177,17 @@ types of template variables.
### Query Variable
Writing variable queries is not supported yet.
Variable of the type *Query* allows you to query Stackdriver for various types of data. The Stackdriver data source plugin provides the following `Query Types`.
| Name | Description |
| ------------------- | ------------------------------------------------------------------------------------------------- |
| *Metric Types* | Returns a list of metric type names that are available for the specified service. |
| *Labels Keys* | Returns a list of keys for `metric label` and `resource label` in the specified metric. |
| *Labels Values* | Returns a list of values for the label in the specified metric. |
| *Resource Types* | Returns a list of resource types for the the specified metric. |
| *Aggregations* | Returns a list of aggregations (cross series reducers) for the the specified metric. |
| *Aligners* | Returns a list of aligners (per series aligners) for the the specified metric. |
| *Alignment periods* | Returns a list of all alignment periods that are available in Stackdriver query editor in Grafana |
### Using variables in queries

View File

@ -14,3 +14,4 @@ exports[`PickerOption renders correctly 1`] = `
</div>
</div>
`;

View File

@ -0,0 +1,36 @@
import coreModule from 'app/core/core_module';
import { importPluginModule } from './plugin_loader';
import React from 'react';
import ReactDOM from 'react-dom';
import DefaultVariableQueryEditor from '../templating/DefaultVariableQueryEditor';
async function loadComponent(module) {
const component = await importPluginModule(module);
if (component && component.VariableQueryEditor) {
return component.VariableQueryEditor;
} else {
return DefaultVariableQueryEditor;
}
}
/** @ngInject */
function variableQueryEditorLoader(templateSrv) {
return {
restrict: 'E',
link: async (scope, elem) => {
const Component = await loadComponent(scope.currentDatasource.meta.module);
const props = {
datasource: scope.currentDatasource,
query: scope.current.query,
onChange: scope.onQueryChange,
templateSrv,
};
ReactDOM.render(<Component {...props} />, elem[0]);
scope.$on('$destroy', () => {
ReactDOM.unmountComponentAtNode(elem[0]);
});
},
};
}
coreModule.directive('variableQueryEditorLoader', variableQueryEditorLoader);

View File

@ -4,3 +4,4 @@ import './import_list/import_list';
import './ds_edit_ctrl';
import './datasource_srv';
import './plugin_component';
import './VariableQueryComponentLoader';

View File

@ -0,0 +1,34 @@
import React, { PureComponent } from 'react';
import { VariableQueryProps } from 'app/types/plugins';
export default class DefaultVariableQueryEditor extends PureComponent<VariableQueryProps, any> {
constructor(props) {
super(props);
this.state = { value: props.query };
}
handleChange(event) {
this.setState({ value: event.target.value });
}
handleBlur(event) {
this.props.onChange(event.target.value, event.target.value);
}
render() {
return (
<div className="gf-form">
<span className="gf-form-label width-10">Query</span>
<input
type="text"
className="gf-form-input"
value={this.state.value}
onChange={e => this.handleChange(e)}
onBlur={e => this.handleBlur(e)}
placeholder="metric name or tags query"
required
/>
</div>
);
}
}

View File

@ -72,6 +72,7 @@ export class VariableEditorCtrl {
if (
$scope.current.type === 'query' &&
_.isString($scope.current.query) &&
$scope.current.query.match(new RegExp('\\$' + $scope.current.name + '(/| |$)'))
) {
appEvents.emit('alert-warning', [
@ -106,11 +107,20 @@ export class VariableEditorCtrl {
});
};
$scope.onQueryChange = (query, definition) => {
$scope.current.query = query;
$scope.current.definition = definition;
$scope.runQuery();
};
$scope.edit = variable => {
$scope.current = variable;
$scope.currentIsNew = false;
$scope.mode = 'edit';
$scope.validate();
datasourceSrv.get($scope.current.datasource).then(ds => {
$scope.currentDatasource = ds;
});
};
$scope.duplicate = variable => {
@ -171,6 +181,13 @@ export class VariableEditorCtrl {
$scope.showMoreOptions = () => {
$scope.optionsLimit += 20;
};
$scope.datasourceChanged = async () => {
datasourceSrv.get($scope.current.datasource).then(ds => {
$scope.current.query = '';
$scope.currentDatasource = ds;
});
};
}
}

View File

@ -17,14 +17,16 @@
</a>
<div class="grafana-info-box">
<h5>What do variables do?</h5>
<p>Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor names
in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of
the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard.
<p>Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor
names
in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the
top of
the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard.
Check out the
<a class="external-link" href="http://docs.grafana.org/reference/templating/" target="_blank">
Templating documentation
</a> for more information.
Check out the
<a class="external-link" href="http://docs.grafana.org/reference/templating/" target="_blank">
Templating documentation
</a> for more information.
</div>
</div>
</div>
@ -32,7 +34,7 @@
<div ng-if="variables.length">
<div class="page-action-bar">
<div class="page-action-bar__spacer"></div>
<a type="button" class="btn btn-success" ng-click="setMode('new');"><i class="fa fa-plus" ></i> New</a>
<a type="button" class="btn btn-success" ng-click="setMode('new');"><i class="fa fa-plus"></i> New</a>
</div>
<table class="filter-table filter-table--hover">
@ -51,7 +53,7 @@
</span>
</td>
<td style="max-width: 200px;" ng-click="edit(variable)" class="pointer max-width">
{{variable.query}}
{{variable.definition ? variable.definition : variable.query}}
</td>
<td style="width: 1%"><i ng-click="_.move(variables,$index,$index-1)" ng-hide="$first" class="pointer fa fa-arrow-up"></i></td>
<td style="width: 1%"><i ng-click="_.move(variables,$index,$index+1)" ng-hide="$last" class="pointer fa fa-arrow-down"></i></td>
@ -77,7 +79,8 @@
<div class="gf-form-inline">
<div class="gf-form max-width-19">
<span class="gf-form-label width-6">Name</span>
<input type="text" class="gf-form-input" name="name" placeholder="name" ng-model='current.name' required ng-pattern="namePattern"></input>
<input type="text" class="gf-form-input" name="name" placeholder="name" ng-model='current.name' required
ng-pattern="namePattern"></input>
</div>
<div class="gf-form max-width-19">
<span class="gf-form-label width-6">
@ -87,13 +90,15 @@
</info-popover>
</span>
<div class="gf-form-select-wrapper max-width-17">
<select class="gf-form-input" ng-model="current.type" ng-options="k as v.name for (k, v) in variableTypes" ng-change="typeChanged()"></select>
<select class="gf-form-input" ng-model="current.type" ng-options="k as v.name for (k, v) in variableTypes"
ng-change="typeChanged()"></select>
</div>
</div>
</div>
<div class="gf-form" ng-show="ctrl.form.name.$error.pattern">
<span class="gf-form-label gf-form-label--error">Template names cannot begin with '__', that's reserved for Grafana's global variables</span>
<span class="gf-form-label gf-form-label--error">Template names cannot begin with '__', that's reserved for
Grafana's global variables</span>
</div>
<div class="gf-form-inline">
@ -115,7 +120,8 @@
<div class="gf-form">
<span class="gf-form-label width-9">Values</span>
<input type="text" class="gf-form-input" ng-model='current.query' placeholder="1m,10m,1h,6h,1d,7d" ng-model-onblur ng-change="runQuery()" required></input>
<input type="text" class="gf-form-input" ng-model='current.query' placeholder="1m,10m,1h,6h,1d,7d" ng-model-onblur
ng-change="runQuery()" required></input>
</div>
<div class="gf-form-inline">
@ -127,14 +133,16 @@
Step count <tip>How many times should the current time range be divided to calculate the value</tip>
</span>
<div class="gf-form-select-wrapper max-width-10" ng-show="current.auto">
<select class="gf-form-input" ng-model="current.auto_count" ng-options="f for f in [1,2,3,4,5,10,20,30,40,50,100,200,300,400,500]" ng-change="runQuery()"></select>
<select class="gf-form-input" ng-model="current.auto_count" ng-options="f for f in [1,2,3,4,5,10,20,30,40,50,100,200,300,400,500]"
ng-change="runQuery()"></select>
</div>
</div>
<div class="gf-form">
<span class="gf-form-label" ng-show="current.auto">
Min interval <tip>The calculated value will not go below this threshold</tip>
</span>
<input type="text" class="gf-form-input max-width-10" ng-show="current.auto" ng-model="current.auto_min" ng-change="runQuery()" placeholder="10s"></input>
<input type="text" class="gf-form-input max-width-10" ng-show="current.auto" ng-model="current.auto_min" ng-change="runQuery()"
placeholder="10s"></input>
</div>
</div>
</div>
@ -143,7 +151,8 @@
<h5 class="section-heading">Custom Options</h5>
<div class="gf-form">
<span class="gf-form-label width-14">Values separated by comma</span>
<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue" required></input>
<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue"
required></input>
</div>
</div>
@ -168,15 +177,17 @@
<div class="gf-form-inline">
<div class="gf-form max-width-21">
<span class="gf-form-label width-7">Data source</span>
<span class="gf-form-label width-10">Data source</span>
<div class="gf-form-select-wrapper max-width-14">
<select class="gf-form-input" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources" required>
<select class="gf-form-input" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources"
ng-change="datasourceChanged()" required>
<option value="" ng-if="false"></option>
</select>
</div>
</div>
<div class="gf-form max-width-22">
<span class="gf-form-label width-7">
<span class="gf-form-label width-10">
Refresh
<info-popover mode="right-normal">
When to update the values of this variable.
@ -187,28 +198,32 @@
</div>
</div>
</div>
<rebuild-on-change property="currentDatasource">
<variable-query-editor-loader>
</variable-query-editor-loader>
</rebuild-on-change>
<div class="gf-form">
<span class="gf-form-label width-7">Query</span>
<input type="text" class="gf-form-input" ng-model='current.query' placeholder="metric name or tags query" ng-model-onblur ng-change="runQuery()" required></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-7">
<span class="gf-form-label width-10">
Regex
<info-popover mode="right-normal">
Optional, if you want to extract part of a series name or metric node segment.
</info-popover>
</span>
<input type="text" class="gf-form-input" ng-model='current.regex' placeholder="/.*-(.*)-.*/" ng-model-onblur ng-change="runQuery()"></input>
<input type="text" class="gf-form-input" ng-model='current.regex' placeholder="/.*-(.*)-.*/" ng-model-onblur
ng-change="runQuery()"></input>
</div>
<div class="gf-form max-width-21">
<span class="gf-form-label width-7">
<span class="gf-form-label width-10">
Sort
<info-popover mode="right-normal">
How to sort the values of this variable.
</info-popover>
</span>
<div class="gf-form-select-wrapper max-width-14">
<select class="gf-form-input" ng-model="current.sort" ng-options="f.value as f.text for f in sortOptions" ng-change="runQuery()"></select>
<select class="gf-form-input" ng-model="current.sort" ng-options="f.value as f.text for f in sortOptions"
ng-change="runQuery()"></select>
</div>
</div>
</div>
@ -219,7 +234,8 @@
<div class="gf-form">
<label class="gf-form-label width-12">Type</label>
<div class="gf-form-select-wrapper max-width-18">
<select class="gf-form-input" ng-model="current.query" ng-options="f.value as f.text for f in datasourceTypes" ng-change="runQuery()"></select>
<select class="gf-form-input" ng-model="current.query" ng-options="f.value as f.text for f in datasourceTypes"
ng-change="runQuery()"></select>
</div>
</div>
@ -234,7 +250,8 @@
</info-popover>
</label>
<input type="text" class="gf-form-input max-width-18" ng-model='current.regex' placeholder="/.*-(.*)-.*/" ng-model-onblur ng-change="runQuery()"></input>
<input type="text" class="gf-form-input max-width-18" ng-model='current.regex' placeholder="/.*-(.*)-.*/"
ng-model-onblur ng-change="runQuery()"></input>
</div>
</div>
@ -243,7 +260,8 @@
<div class="gf-form max-width-21">
<span class="gf-form-label width-8">Data source</span>
<div class="gf-form-select-wrapper max-width-14">
<select class="gf-form-input" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources" required ng-change="validate()">
<select class="gf-form-input" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources"
required ng-change="validate()">
<option value="" ng-if="false"></option>
</select>
</div>
@ -253,18 +271,11 @@
<div class="section gf-form-group" ng-show="variableTypes[current.type].supportsMulti">
<h5 class="section-heading">Selection Options</h5>
<div class="section">
<gf-form-switch class="gf-form"
label="Multi-value"
label-class="width-10"
tooltip="Enables multiple values to be selected at the same time"
checked="current.multi"
on-change="runQuery()">
<gf-form-switch class="gf-form" label="Multi-value" label-class="width-10" tooltip="Enables multiple values to be selected at the same time"
checked="current.multi" on-change="runQuery()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label="Include All option"
label-class="width-10"
checked="current.includeAll"
on-change="runQuery()">
<gf-form-switch class="gf-form" label="Include All option" label-class="width-10" checked="current.includeAll"
on-change="runQuery()">
</gf-form-switch>
</div>
<div class="gf-form" ng-if="current.includeAll">
@ -279,11 +290,13 @@
</gf-form-switch>
<div class="gf-form last" ng-if="current.useTags">
<span class="gf-form-label width-10">Tags query</span>
<input type="text" class="gf-form-input" ng-model='current.tagsQuery' placeholder="metric name or tags query" ng-model-onblur></input>
<input type="text" class="gf-form-input" ng-model='current.tagsQuery' placeholder="metric name or tags query"
ng-model-onblur></input>
</div>
<div class="gf-form" ng-if="current.useTags">
<li class="gf-form-label width-10">Tag values query</li>
<input type="text" class="gf-form-input" ng-model='current.tagValuesQuery' placeholder="apps.$tag.*" ng-model-onblur></input>
<input type="text" class="gf-form-input" ng-model='current.tagValuesQuery' placeholder="apps.$tag.*"
ng-model-onblur></input>
</div>
</div>
@ -291,11 +304,11 @@
<h5>Preview of values</h5>
<div class="gf-form-inline">
<div class="gf-form" ng-repeat="option in current.options | limitTo: optionsLimit">
<span class="gf-form-label">{{option.text}}</span>
</div>
<div class="gf-form" ng-if= "current.options.length > optionsLimit">
<a class="gf-form-label btn-secondary" ng-click="showMoreOptions()">Show more</a>
</div>
<span class="gf-form-label">{{option.text}}</span>
</div>
<div class="gf-form" ng-if="current.options.length > optionsLimit">
<a class="gf-form-label btn-secondary" ng-click="showMoreOptions()">Show more</a>
</div>
</div>
</div>
@ -309,5 +322,4 @@
</div>
</form>
</div>
</div>

View File

@ -23,6 +23,7 @@ export class QueryVariable implements Variable {
tagValuesQuery: string;
tags: any[];
skipUrlSync: boolean;
definition: string;
defaults = {
type: 'query',
@ -44,6 +45,7 @@ export class QueryVariable implements Variable {
tagsQuery: '',
tagValuesQuery: '',
skipUrlSync: false,
definition: '',
};
/** @ngInject */

View File

@ -1,3 +1,4 @@
import _ from 'lodash';
import { assignModelProperties } from 'app/core/utils/model_utils';
/*
@ -28,6 +29,7 @@ export { assignModelProperties };
export function containsVariable(...args: any[]) {
const variableName = args[args.length - 1];
args[0] = _.isString(args[0]) ? args[0] : Object['values'](args[0]).join(' ');
const variableString = args.slice(0, -1).join(' ');
const matches = variableString.match(variableRegex);
const isMatchingVariable =

View File

@ -0,0 +1,129 @@
import isString from 'lodash/isString';
import { alignmentPeriods } from './constants';
import { MetricFindQueryTypes } from './types';
import {
getMetricTypesByService,
getAlignmentOptionsByMetric,
getAggregationOptionsByMetric,
extractServicesFromMetricDescriptors,
getLabelKeys,
} from './functions';
export default class StackdriverMetricFindQuery {
constructor(private datasource) {}
async execute(query: any) {
try {
switch (query.selectedQueryType) {
case MetricFindQueryTypes.Services:
return this.handleServiceQuery();
case MetricFindQueryTypes.MetricTypes:
return this.handleMetricTypesQuery(query);
case MetricFindQueryTypes.LabelKeys:
return this.handleLabelKeysQuery(query);
case MetricFindQueryTypes.LabelValues:
return this.handleLabelValuesQuery(query);
case MetricFindQueryTypes.ResourceTypes:
return this.handleResourceTypeQuery(query);
case MetricFindQueryTypes.Aligners:
return this.handleAlignersQuery(query);
case MetricFindQueryTypes.AlignmentPeriods:
return this.handleAlignmentPeriodQuery();
case MetricFindQueryTypes.Aggregations:
return this.handleAggregationQuery(query);
default:
return [];
}
} catch (error) {
console.error(`Could not run StackdriverMetricFindQuery ${query}`, error);
return [];
}
}
async handleServiceQuery() {
const metricDescriptors = await this.datasource.getMetricTypes(this.datasource.projectName);
const services = extractServicesFromMetricDescriptors(metricDescriptors);
return services.map(s => ({
text: s.serviceShortName,
value: s.service,
expandable: true,
}));
}
async handleMetricTypesQuery({ selectedService }) {
if (!selectedService) {
return [];
}
const metricDescriptors = await this.datasource.getMetricTypes(this.datasource.projectName);
return getMetricTypesByService(metricDescriptors, this.datasource.templateSrv.replace(selectedService)).map(s => ({
text: s.displayName,
value: s.type,
expandable: true,
}));
}
async handleLabelKeysQuery({ selectedMetricType }) {
if (!selectedMetricType) {
return [];
}
const labelKeys = await getLabelKeys(this.datasource, selectedMetricType);
return labelKeys.map(this.toFindQueryResult);
}
async handleLabelValuesQuery({ selectedMetricType, labelKey }) {
if (!selectedMetricType) {
return [];
}
const refId = 'handleLabelValuesQuery';
const response = await this.datasource.getLabels(selectedMetricType, refId);
const interpolatedKey = this.datasource.templateSrv.replace(labelKey);
const [name] = interpolatedKey.split('.').reverse();
let values = [];
if (response.meta && response.meta.metricLabels && response.meta.metricLabels.hasOwnProperty(name)) {
values = response.meta.metricLabels[name];
} else if (response.meta && response.meta.resourceLabels && response.meta.resourceLabels.hasOwnProperty(name)) {
values = response.meta.resourceLabels[name];
}
return values.map(this.toFindQueryResult);
}
async handleResourceTypeQuery({ selectedMetricType }) {
if (!selectedMetricType) {
return [];
}
const refId = 'handleResourceTypeQueryQueryType';
const response = await this.datasource.getLabels(selectedMetricType, refId);
return response.meta.resourceTypes ? response.meta.resourceTypes.map(this.toFindQueryResult) : [];
}
async handleAlignersQuery({ selectedMetricType }) {
if (!selectedMetricType) {
return [];
}
const metricDescriptors = await this.datasource.getMetricTypes(this.datasource.projectName);
const { valueType, metricKind } = metricDescriptors.find(
m => m.type === this.datasource.templateSrv.replace(selectedMetricType)
);
return getAlignmentOptionsByMetric(valueType, metricKind).map(this.toFindQueryResult);
}
async handleAggregationQuery({ selectedMetricType }) {
if (!selectedMetricType) {
return [];
}
const metricDescriptors = await this.datasource.getMetricTypes(this.datasource.projectName);
const { valueType, metricKind } = metricDescriptors.find(
m => m.type === this.datasource.templateSrv.replace(selectedMetricType)
);
return getAggregationOptionsByMetric(valueType, metricKind).map(this.toFindQueryResult);
}
handleAlignmentPeriodQuery() {
return alignmentPeriods.map(this.toFindQueryResult);
}
toFindQueryResult(x) {
return isString(x) ? { text: x, expandable: true } : { ...x, expandable: true };
}
}

View File

@ -0,0 +1,28 @@
import React, { SFC } from 'react';
interface Props {
onValueChange: (e) => void;
options: any[];
value: string;
label: string;
}
const SimpleSelect: SFC<Props> = props => {
const { label, onValueChange, value, options } = props;
return (
<div className="gf-form max-width-21">
<span className="gf-form-label width-10 query-keyword">{label}</span>
<div className="gf-form-select-wrapper max-width-12">
<select className="gf-form-input" required onChange={onValueChange} value={value}>
{options.map(({ value, name }, i) => (
<option key={i} value={value}>
{name}
</option>
))}
</select>
</div>
</div>
);
};
export default SimpleSelect;

View File

@ -0,0 +1,47 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { StackdriverVariableQueryEditor } from './VariableQueryEditor';
import { VariableQueryProps } from 'app/types/plugins';
import { MetricFindQueryTypes } from '../types';
jest.mock('../functions', () => ({
getMetricTypes: () => ({ metricTypes: [], selectedMetricType: '' }),
extractServicesFromMetricDescriptors: () => [],
}));
const props: VariableQueryProps = {
onChange: (query, definition) => {},
query: {},
datasource: {
getMetricTypes: async p => [],
},
templateSrv: { replace: s => s, variables: [] },
};
describe('VariableQueryEditor', () => {
it('renders correctly', () => {
const tree = renderer.create(<StackdriverVariableQueryEditor {...props} />).toJSON();
expect(tree).toMatchSnapshot();
});
describe('and a new variable is created', () => {
it('should trigger a query using the first query type in the array', done => {
props.onChange = (query, definition) => {
expect(definition).toBe('Stackdriver - Services');
done();
};
renderer.create(<StackdriverVariableQueryEditor {...props} />).toJSON();
});
});
describe('and an existing variable is edited', () => {
it('should trigger new query using the saved query type', done => {
props.query = { selectedQueryType: MetricFindQueryTypes.LabelKeys };
props.onChange = (query, definition) => {
expect(definition).toBe('Stackdriver - Label Keys');
done();
};
renderer.create(<StackdriverVariableQueryEditor {...props} />).toJSON();
});
});
});

View File

@ -0,0 +1,196 @@
import React, { PureComponent } from 'react';
import { VariableQueryProps } from 'app/types/plugins';
import SimpleSelect from './SimpleSelect';
import { getMetricTypes, getLabelKeys, extractServicesFromMetricDescriptors } from '../functions';
import { MetricFindQueryTypes, VariableQueryData } from '../types';
export class StackdriverVariableQueryEditor extends PureComponent<VariableQueryProps, VariableQueryData> {
queryTypes: Array<{ value: string; name: string }> = [
{ value: MetricFindQueryTypes.Services, name: 'Services' },
{ value: MetricFindQueryTypes.MetricTypes, name: 'Metric Types' },
{ value: MetricFindQueryTypes.LabelKeys, name: 'Label Keys' },
{ value: MetricFindQueryTypes.LabelValues, name: 'Label Values' },
{ value: MetricFindQueryTypes.ResourceTypes, name: 'Resource Types' },
{ value: MetricFindQueryTypes.Aggregations, name: 'Aggregations' },
{ value: MetricFindQueryTypes.Aligners, name: 'Aligners' },
{ value: MetricFindQueryTypes.AlignmentPeriods, name: 'Alignment Periods' },
];
defaults: VariableQueryData = {
selectedQueryType: this.queryTypes[0].value,
metricDescriptors: [],
selectedService: '',
selectedMetricType: '',
labels: [],
labelKey: '',
metricTypes: [],
services: [],
};
constructor(props: VariableQueryProps) {
super(props);
this.state = Object.assign(this.defaults, this.props.query);
}
async componentDidMount() {
const metricDescriptors = await this.props.datasource.getMetricTypes(this.props.datasource.projectName);
const services = extractServicesFromMetricDescriptors(metricDescriptors).map(m => ({
value: m.service,
name: m.serviceShortName,
}));
let selectedService = '';
if (services.some(s => s.value === this.props.templateSrv.replace(this.state.selectedService))) {
selectedService = this.state.selectedService;
} else if (services && services.length > 0) {
selectedService = services[0].value;
}
const { metricTypes, selectedMetricType } = getMetricTypes(
metricDescriptors,
this.state.selectedMetricType,
this.props.templateSrv.replace(this.state.selectedMetricType),
this.props.templateSrv.replace(selectedService)
);
const state: any = {
services,
selectedService,
metricTypes,
selectedMetricType,
metricDescriptors,
...await this.getLabels(selectedMetricType),
};
this.setState(state);
}
async handleQueryTypeChange(event) {
const state: any = {
selectedQueryType: event.target.value,
...await this.getLabels(this.state.selectedMetricType, event.target.value),
};
this.setState(state);
}
async onServiceChange(event) {
const { metricTypes, selectedMetricType } = getMetricTypes(
this.state.metricDescriptors,
this.state.selectedMetricType,
this.props.templateSrv.replace(this.state.selectedMetricType),
this.props.templateSrv.replace(event.target.value)
);
const state: any = {
selectedService: event.target.value,
metricTypes,
selectedMetricType,
...await this.getLabels(selectedMetricType),
};
this.setState(state);
}
async onMetricTypeChange(event) {
const state: any = { selectedMetricType: event.target.value, ...await this.getLabels(event.target.value) };
this.setState(state);
}
onLabelKeyChange(event) {
this.setState({ labelKey: event.target.value });
}
componentDidUpdate() {
const { metricDescriptors, labels, metricTypes, services, ...queryModel } = this.state;
const query = this.queryTypes.find(q => q.value === this.state.selectedQueryType);
this.props.onChange(queryModel, `Stackdriver - ${query.name}`);
}
async getLabels(selectedMetricType, selectedQueryType = this.state.selectedQueryType) {
let result = { labels: this.state.labels, labelKey: this.state.labelKey };
if (selectedMetricType && selectedQueryType === MetricFindQueryTypes.LabelValues) {
const labels = await getLabelKeys(this.props.datasource, selectedMetricType);
const labelKey = labels.some(l => l === this.props.templateSrv.replace(this.state.labelKey))
? this.state.labelKey
: labels[0];
result = { labels, labelKey };
}
return result;
}
insertTemplateVariables(options) {
const templateVariables = this.props.templateSrv.variables.map(v => ({ name: `$${v.name}`, value: `$${v.name}` }));
return [...templateVariables, ...options];
}
renderQueryTypeSwitch(queryType) {
switch (queryType) {
case MetricFindQueryTypes.MetricTypes:
return (
<SimpleSelect
value={this.state.selectedService}
options={this.insertTemplateVariables(this.state.services)}
onValueChange={e => this.onServiceChange(e)}
label="Service"
/>
);
case MetricFindQueryTypes.LabelKeys:
case MetricFindQueryTypes.LabelValues:
case MetricFindQueryTypes.ResourceTypes:
return (
<React.Fragment>
<SimpleSelect
value={this.state.selectedService}
options={this.insertTemplateVariables(this.state.services)}
onValueChange={e => this.onServiceChange(e)}
label="Service"
/>
<SimpleSelect
value={this.state.selectedMetricType}
options={this.insertTemplateVariables(this.state.metricTypes)}
onValueChange={e => this.onMetricTypeChange(e)}
label="Metric Type"
/>
{queryType === MetricFindQueryTypes.LabelValues && (
<SimpleSelect
value={this.state.labelKey}
options={this.insertTemplateVariables(this.state.labels.map(l => ({ value: l, name: l })))}
onValueChange={e => this.onLabelKeyChange(e)}
label="Label Key"
/>
)}
</React.Fragment>
);
case MetricFindQueryTypes.Aligners:
case MetricFindQueryTypes.Aggregations:
return (
<React.Fragment>
<SimpleSelect
value={this.state.selectedService}
options={this.insertTemplateVariables(this.state.services)}
onValueChange={e => this.onServiceChange(e)}
label="Service"
/>
<SimpleSelect
value={this.state.selectedMetricType}
options={this.insertTemplateVariables(this.state.metricTypes)}
onValueChange={e => this.onMetricTypeChange(e)}
label="Metric Type"
/>
</React.Fragment>
);
default:
return '';
}
}
render() {
return (
<React.Fragment>
<SimpleSelect
value={this.state.selectedQueryType}
options={this.queryTypes}
onValueChange={e => this.handleQueryTypeChange(e)}
label="Query Type"
/>
{this.renderQueryTypeSwitch(this.state.selectedQueryType)}
</React.Fragment>
);
}
}

View File

@ -0,0 +1,67 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VariableQueryEditor renders correctly 1`] = `
Array [
<div
className="gf-form max-width-21"
>
<span
className="gf-form-label width-10 query-keyword"
>
Query Type
</span>
<div
className="gf-form-select-wrapper max-width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
required={true}
value="services"
>
<option
value="services"
>
Services
</option>
<option
value="metricTypes"
>
Metric Types
</option>
<option
value="labelKeys"
>
Label Keys
</option>
<option
value="labelValues"
>
Label Values
</option>
<option
value="resourceTypes"
>
Resource Types
</option>
<option
value="aggregations"
>
Aggregations
</option>
<option
value="aligners"
>
Aligners
</option>
<option
value="alignmentPeriods"
>
Alignment Periods
</option>
</select>
</div>
</div>,
"",
]
`;

View File

@ -1,6 +1,7 @@
import { stackdriverUnitMappings } from './constants';
import appEvents from 'app/core/app_events';
import _ from 'lodash';
import StackdriverMetricFindQuery from './StackdriverMetricFindQuery';
export default class StackdriverDatasource {
id: number;
@ -9,6 +10,7 @@ export default class StackdriverDatasource {
projectName: string;
authenticationType: string;
queryPromise: Promise<any>;
metricTypes: any[];
/** @ngInject */
constructor(instanceSettings, private backendSrv, private templateSrv, private timeSrv) {
@ -18,6 +20,7 @@ export default class StackdriverDatasource {
this.id = instanceSettings.id;
this.projectName = instanceSettings.jsonData.defaultProject || '';
this.authenticationType = instanceSettings.jsonData.authenticationType || 'jwt';
this.metricTypes = [];
}
async getTimeSeries(options) {
@ -67,7 +70,7 @@ export default class StackdriverDatasource {
}
async getLabels(metricType, refId) {
return await this.getTimeSeries({
const response = await this.getTimeSeries({
targets: [
{
refId: refId,
@ -81,6 +84,8 @@ export default class StackdriverDatasource {
],
range: this.timeSrv.timeRange(),
});
return response.results[refId];
}
interpolateGroupBys(groupBys: string[], scopedVars): string[] {
@ -177,8 +182,9 @@ export default class StackdriverDatasource {
return results;
}
metricFindQuery(query) {
throw new Error('Template variables support is not yet imlemented');
async metricFindQuery(query) {
const stackdriverMetricFindQuery = new StackdriverMetricFindQuery(this);
return stackdriverMetricFindQuery.execute(query);
}
async testDatasource() {
@ -258,19 +264,21 @@ export default class StackdriverDatasource {
async getMetricTypes(projectName: string) {
try {
const metricsApiPath = `v3/projects/${projectName}/metricDescriptors`;
const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`);
if (this.metricTypes.length === 0) {
const metricsApiPath = `v3/projects/${projectName}/metricDescriptors`;
const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`);
const metrics = data.metricDescriptors.map(m => {
const [service] = m.type.split('/');
const [serviceShortName] = service.split('.');
m.service = service;
m.serviceShortName = serviceShortName;
m.displayName = m.displayName || m.type;
return m;
});
this.metricTypes = data.metricDescriptors.map(m => {
const [service] = m.type.split('/');
const [serviceShortName] = service.split('.');
m.service = service;
m.serviceShortName = serviceShortName;
m.displayName = m.displayName || m.type;
return m;
});
}
return metrics;
return this.metricTypes;
} catch (error) {
appEvents.emit('ds-request-error', this.formatStackdriverError(error));
return [];

View File

@ -0,0 +1,48 @@
import uniqBy from 'lodash/uniqBy';
import { alignOptions, aggOptions } from './constants';
export const extractServicesFromMetricDescriptors = metricDescriptors => uniqBy(metricDescriptors, 'service');
export const getMetricTypesByService = (metricDescriptors, service) =>
metricDescriptors.filter(m => m.service === service);
export const getMetricTypes = (metricDescriptors, metricType, interpolatedMetricType, selectedService) => {
const metricTypes = getMetricTypesByService(metricDescriptors, selectedService).map(m => ({
value: m.type,
name: m.displayName,
}));
const metricTypeExistInArray = metricTypes.some(m => m.value === interpolatedMetricType);
const selectedMetricType = metricTypeExistInArray ? metricType : metricTypes[0].value;
return {
metricTypes,
selectedMetricType,
};
};
export const getAlignmentOptionsByMetric = (metricValueType, metricKind) => {
return !metricValueType
? []
: alignOptions.filter(i => {
return i.valueTypes.indexOf(metricValueType) !== -1 && i.metricKinds.indexOf(metricKind) !== -1;
});
};
export const getAggregationOptionsByMetric = (valueType, metricKind) => {
return !metricKind
? []
: aggOptions.filter(i => {
return i.valueTypes.indexOf(valueType) !== -1 && i.metricKinds.indexOf(metricKind) !== -1;
});
};
export const getLabelKeys = async (datasource, selectedMetricType) => {
const refId = 'handleLabelKeysQuery';
const response = await datasource.getLabels(selectedMetricType, refId);
const labelKeys = response.meta
? [
...Object.keys(response.meta.resourceLabels).map(l => `resource.label.${l}`),
...Object.keys(response.meta.metricLabels).map(l => `metric.label.${l}`),
]
: [];
return labelKeys;
};

View File

@ -2,10 +2,12 @@ import StackdriverDatasource from './datasource';
import { StackdriverQueryCtrl } from './query_ctrl';
import { StackdriverConfigCtrl } from './config_ctrl';
import { StackdriverAnnotationsQueryCtrl } from './annotations_query_ctrl';
import { StackdriverVariableQueryEditor } from './components/VariableQueryEditor';
export {
StackdriverDatasource as Datasource,
StackdriverQueryCtrl as QueryCtrl,
StackdriverConfigCtrl as ConfigCtrl,
StackdriverAnnotationsQueryCtrl as AnnotationsQueryCtrl,
StackdriverVariableQueryEditor as VariableQueryEditor,
};

View File

@ -2,8 +2,8 @@
<div class="gf-form">
<label class="gf-form-label query-keyword width-9">Aggregation</label>
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
<select class="gf-form-input width-12" ng-model="ctrl.target.aggregation.crossSeriesReducer" ng-options="f.value as f.text for f in ctrl.aggOptions"
ng-change="refresh()"></select>
<gf-form-dropdown model="ctrl.target.aggregation.crossSeriesReducer" get-options="ctrl.aggOptions" class="gf-form width-12"
disabled type="text" allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="refresh()"></gf-form-dropdown>
</div>
</div>
<div class="gf-form gf-form--grow">
@ -20,8 +20,8 @@
<div class="gf-form offset-width-9">
<label class="gf-form-label query-keyword width-12">Aligner</label>
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
<select class="gf-form-input width-14" ng-model="ctrl.target.aggregation.perSeriesAligner" ng-options="f.value as f.text for f in ctrl.alignOptions"
ng-change="refresh()"></select>
<gf-form-dropdown model="ctrl.target.aggregation.perSeriesAligner" get-options="ctrl.alignOptions" class="gf-form width-12"
disabled type="text" allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="refresh()"></gf-form-dropdown>
</div>
<div class="gf-form gf-form--grow">
@ -33,8 +33,8 @@
<div class="gf-form">
<label class="gf-form-label query-keyword width-9">Alignment Period</label>
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
<select class="gf-form-input width-12" ng-model="ctrl.target.aggregation.alignmentPeriod" ng-options="f.value as f.text for f in ctrl.alignmentPeriods"
ng-change="refresh()"></select>
<gf-form-dropdown model="ctrl.target.aggregation.alignmentPeriod" get-options="ctrl.alignmentPeriods" class="gf-form width-12"
disabled type="text" allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="refresh()"></gf-form-dropdown>
</div>
</div>

View File

@ -14,7 +14,7 @@
</div>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9">Project</span>
<span class="gf-form-label width-9 query-keyword">Project</span>
<input class="gf-form-input" disabled type="text" ng-model='ctrl.target.defaultProject' css-class="min-width-12" />
</div>
<div class="gf-form">
@ -70,4 +70,4 @@
<div class="gf-form" ng-show="ctrl.lastQueryError">
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
</div>
</query-editor-row>
</query-editor-row>

View File

@ -1,37 +1,52 @@
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9">Service</span>
<gf-form-dropdown model="ctrl.service" get-options="ctrl.services" class="min-width-20" disabled type="text"
allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="ctrl.onServiceChange(ctrl.service)"></gf-form-dropdown>
<span class="gf-form-label width-9 query-keyword">Service</span>
<select
class="gf-form-input width-12"
ng-model="ctrl.service"
ng-options="f.value as f.text for f in ctrl.services"
ng-change="ctrl.onServiceChange(ctrl.service)"
></select>
</div>
<div class="gf-form">
<span class="gf-form-label width-9">Metric</span>
<gf-form-dropdown model="ctrl.metricType" get-options="ctrl.metrics" class="min-width-20" disabled type="text"
allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="ctrl.onMetricTypeChange()"></gf-form-dropdown>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
<span class="gf-form-label width-9 query-keyword">Metric</span>
<gf-form-dropdown
model="ctrl.metricType"
get-options="ctrl.metrics"
class="min-width-20"
disabled
type="text"
allow-custom="true"
lookup-text="true"
css-class="min-width-12"
on-change="ctrl.onMetricTypeChange()"
></gf-form-dropdown>
</div>
<div class="gf-form gf-form--grow"><div class="gf-form-label gf-form-label--grow"></div></div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Filter</span>
<div class="gf-form" ng-repeat="segment in ctrl.filterSegments.filterSegments">
<metric-segment segment="segment" get-options="ctrl.getFilters(segment, $index)" on-change="ctrl.filterSegmentUpdated(segment, $index)"></metric-segment>
<metric-segment
segment="segment"
get-options="ctrl.getFilters(segment, $index)"
on-change="ctrl.filterSegmentUpdated(segment, $index)"
></metric-segment>
</div>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
<div class="gf-form gf-form--grow"><div class="gf-form-label gf-form-label--grow"></div></div>
</div>
<div class="gf-form-inline" ng-hide="ctrl.$scope.hideGroupBys">
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Group By</span>
<div class="gf-form" ng-repeat="segment in ctrl.groupBySegments">
<metric-segment segment="segment" get-options="ctrl.getGroupBys(segment)" on-change="ctrl.groupByChanged(segment, $index)"></metric-segment>
<metric-segment
segment="segment"
get-options="ctrl.getGroupBys(segment)"
on-change="ctrl.groupByChanged(segment, $index)"
></metric-segment>
</div>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
<div class="gf-form gf-form--grow"><div class="gf-form-label gf-form-label--grow"></div></div>
</div>

View File

@ -1,6 +1,7 @@
import coreModule from 'app/core/core_module';
import _ from 'lodash';
import * as options from './constants';
import { getAlignmentOptionsByMetric, getAggregationOptionsByMetric } from './functions';
import kbn from 'app/core/utils/kbn';
export class StackdriverAggregation {
@ -25,7 +26,7 @@ export class StackdriverAggregationCtrl {
target: any;
/** @ngInject */
constructor(private $scope) {
constructor(private $scope, private templateSrv) {
this.$scope.ctrl = this;
this.target = $scope.target;
this.alignmentPeriods = options.alignmentPeriods;
@ -41,28 +42,16 @@ export class StackdriverAggregationCtrl {
}
setAlignOptions() {
this.alignOptions = !this.target.valueType
? []
: options.alignOptions.filter(i => {
return (
i.valueTypes.indexOf(this.target.valueType) !== -1 && i.metricKinds.indexOf(this.target.metricKind) !== -1
);
});
if (!this.alignOptions.find(o => o.value === this.target.aggregation.perSeriesAligner)) {
this.alignOptions = getAlignmentOptionsByMetric(this.target.valueType, this.target.metricKind);
if (!this.alignOptions.find(o => o.value === this.templateSrv.replace(this.target.aggregation.perSeriesAligner))) {
this.target.aggregation.perSeriesAligner = this.alignOptions.length > 0 ? this.alignOptions[0].value : '';
}
}
setAggOptions() {
this.aggOptions = !this.target.metricKind
? []
: options.aggOptions.filter(i => {
return (
i.valueTypes.indexOf(this.target.valueType) !== -1 && i.metricKinds.indexOf(this.target.metricKind) !== -1
);
});
this.aggOptions = getAggregationOptionsByMetric(this.target.valueType, this.target.metricKind);
if (!this.aggOptions.find(o => o.value === this.target.aggregation.crossSeriesReducer)) {
if (!this.aggOptions.find(o => o.value === this.templateSrv.replace(this.target.aggregation.crossSeriesReducer))) {
this.deselectAggregationOption('REDUCE_NONE');
}
@ -73,8 +62,12 @@ export class StackdriverAggregationCtrl {
}
formatAlignmentText() {
const selectedAlignment = this.alignOptions.find(ap => ap.value === this.target.aggregation.perSeriesAligner);
return `${kbn.secondsToHms(this.$scope.alignmentPeriod)} interval (${selectedAlignment.text})`;
const selectedAlignment = this.alignOptions.find(
ap => ap.value === this.templateSrv.replace(this.target.aggregation.perSeriesAligner)
);
return `${kbn.secondsToHms(this.$scope.alignmentPeriod)} interval (${
selectedAlignment ? selectedAlignment.text : ''
})`;
}
deselectAggregationOption(notValidOptionValue: string) {

View File

@ -62,7 +62,6 @@ export class StackdriverQueryCtrl extends QueryCtrl {
constructor($scope, $injector) {
super($scope, $injector);
_.defaultsDeep(this.target, this.defaults);
this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope);
this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope);
}

View File

@ -139,7 +139,7 @@ export class StackdriverFilterCtrl {
result = metrics.filter(m => m.service === this.target.service);
}
if (result.find(m => m.value === this.target.metricType)) {
if (result.find(m => m.value === this.templateSrv.replace(this.target.metricType))) {
this.metricType = this.target.metricType;
} else if (result.length > 0) {
this.metricType = this.target.metricType = result[0].value;
@ -150,10 +150,10 @@ export class StackdriverFilterCtrl {
async getLabels() {
this.loadLabelsPromise = new Promise(async resolve => {
try {
const data = await this.datasource.getLabels(this.target.metricType, this.target.refId);
this.metricLabels = data.results[this.target.refId].meta.metricLabels;
this.resourceLabels = data.results[this.target.refId].meta.resourceLabels;
this.resourceTypes = data.results[this.target.refId].meta.resourceTypes;
const { meta } = await this.datasource.getLabels(this.target.metricType, this.target.refId);
this.metricLabels = meta.metricLabels;
this.resourceLabels = meta.resourceLabels;
this.resourceTypes = meta.resourceTypes;
resolve();
} catch (error) {
if (error.data && error.data.message) {
@ -187,7 +187,9 @@ export class StackdriverFilterCtrl {
setMetricType() {
this.target.metricType = this.metricType;
const { valueType, metricKind, unit } = this.metricDescriptors.find(m => m.type === this.target.metricType);
const { valueType, metricKind, unit } = this.metricDescriptors.find(
m => m.type === this.templateSrv.replace(this.metricType)
);
this.target.unit = unit;
this.target.valueType = valueType;
this.target.metricKind = metricKind;

View File

@ -6,10 +6,19 @@ describe('StackdriverAggregationCtrl', () => {
describe('when new query result is returned from the server', () => {
describe('and result is double and gauge and no group by is used', () => {
beforeEach(async () => {
ctrl = new StackdriverAggregationCtrl({
$on: () => {},
target: { valueType: 'DOUBLE', metricKind: 'GAUGE', aggregation: { crossSeriesReducer: '', groupBys: [] } },
});
ctrl = new StackdriverAggregationCtrl(
{
$on: () => {},
target: {
valueType: 'DOUBLE',
metricKind: 'GAUGE',
aggregation: { crossSeriesReducer: '', groupBys: [] },
},
},
{
replace: s => s,
}
);
});
it('should populate all aggregate options except two', () => {
@ -31,14 +40,19 @@ describe('StackdriverAggregationCtrl', () => {
describe('and result is double and gauge and a group by is used', () => {
beforeEach(async () => {
ctrl = new StackdriverAggregationCtrl({
$on: () => {},
target: {
valueType: 'DOUBLE',
metricKind: 'GAUGE',
aggregation: { crossSeriesReducer: 'REDUCE_NONE', groupBys: ['resource.label.projectid'] },
ctrl = new StackdriverAggregationCtrl(
{
$on: () => {},
target: {
valueType: 'DOUBLE',
metricKind: 'GAUGE',
aggregation: { crossSeriesReducer: 'REDUCE_NONE', groupBys: ['resource.label.projectid'] },
},
},
});
{
replace: s => s,
}
);
});
it('should populate all aggregate options except three', () => {

View File

@ -0,0 +1,21 @@
export enum MetricFindQueryTypes {
Services = 'services',
MetricTypes = 'metricTypes',
LabelKeys = 'labelKeys',
LabelValues = 'labelValues',
ResourceTypes = 'resourceTypes',
Aggregations = 'aggregations',
Aligners = 'aligners',
AlignmentPeriods = 'alignmentPeriods',
}
export interface VariableQueryData {
selectedQueryType: string;
metricDescriptors: any[];
selectedService: string;
selectedMetricType: string;
labels: string[];
labelKey: string;
metricTypes: Array<{ value: string; name: string }>;
services: Array<{ value: string; name: string }>;
}

View File

@ -6,6 +6,7 @@ export interface PluginExports {
QueryCtrl?: any;
ConfigCtrl?: any;
AnnotationsQueryCtrl?: any;
VariableQueryEditor?: any;
ExploreQueryField?: any;
ExploreStartPage?: any;
@ -98,3 +99,10 @@ export interface PluginsState {
hasFetched: boolean;
dashboards: PluginDashboard[];
}
export interface VariableQueryProps {
query: any;
onChange: (query: any, definition: string) => void;
datasource: any;
templateSrv: any;
}