Merge pull request #14751 from grafana/reactify-stackdriver

Reactify stackdriver
This commit is contained in:
Daniel Lee 2019-01-15 00:08:56 +01:00 committed by GitHub
commit 16881f64dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 3121 additions and 3771 deletions

View File

@ -12,10 +12,7 @@ export class Portal extends PureComponent<Props> {
constructor(props: Props) {
super(props);
const {
className,
root = document.body
} = this.props;
const { className, root = document.body } = this.props;
if (className) {
this.node.classList.add(className);

View File

@ -4,6 +4,7 @@ import SelectOption from './SelectOption';
import { OptionProps } from 'react-select/lib/components/Option';
const model: OptionProps<any> = {
data: jest.fn(),
cx: jest.fn(),
clearValue: jest.fn(),
getStyles: jest.fn(),

View File

@ -2,7 +2,11 @@ import React, { PureComponent } from 'react';
import { GroupProps } from 'react-select/lib/components/Group';
interface ExtendedGroupProps extends GroupProps<any> {
data: any;
data: {
label: string;
expanded: boolean;
options: any[];
};
}
interface State {
@ -15,8 +19,10 @@ export default class SelectOptionGroup extends PureComponent<ExtendedGroupProps,
};
componentDidMount() {
if (this.props.selectProps) {
const value = this.props.selectProps.value[this.props.selectProps.value.length - 1];
if (this.props.data.expanded) {
this.setState({ expanded: true });
} else if (this.props.selectProps && this.props.selectProps.value) {
const { value } = this.props.selectProps.value;
if (value && this.props.options.some(option => option.value === value)) {
this.setState({ expanded: true });

View File

@ -63,6 +63,7 @@ $select-input-bg-disabled: $input-bg-disabled;
.gf-form-select-box__menu-list {
overflow-y: auto;
max-height: 300px;
max-width: 600px;
}
.tag-filter .gf-form-select-box__menu {

View File

@ -1,10 +1,13 @@
import { react2AngularDirective } from 'app/core/utils/react2angular';
import { QueryEditor as StackdriverQueryEditor } from 'app/plugins/datasource/stackdriver/components/QueryEditor';
import { AnnotationQueryEditor as StackdriverAnnotationQueryEditor } from 'app/plugins/datasource/stackdriver/components/AnnotationQueryEditor';
import { PasswordStrength } from './components/PasswordStrength';
import PageHeader from './components/PageHeader/PageHeader';
import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import { SearchResult } from './components/search/SearchResult';
import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu';
import { MetricSelect } from './components/Select/MetricSelect';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
import { ColorPicker, SeriesColorPickerPopover } from '@grafana/ui';
@ -29,4 +32,28 @@ export function registerAngularDirectives() {
'onColorChange',
'onToggleAxis',
]);
react2AngularDirective('metricSelect', MetricSelect, [
'options',
'onChange',
'value',
'isSearchable',
'className',
'placeholder',
['variables', { watchDepth: 'reference' }],
]);
react2AngularDirective('stackdriverQueryEditor', StackdriverQueryEditor, [
'target',
'onQueryChange',
'onExecuteQuery',
['events', { watchDepth: 'reference' }],
['datasource', { watchDepth: 'reference' }],
['templateSrv', { watchDepth: 'reference' }],
]);
react2AngularDirective('stackdriverAnnotationQueryEditor', StackdriverAnnotationQueryEditor, [
'target',
'onQueryChange',
'onExecuteQuery',
['datasource', { watchDepth: 'reference' }],
['templateSrv', { watchDepth: 'reference' }],
]);
}

View File

@ -0,0 +1,90 @@
import React from 'react';
import _ from 'lodash';
import { Select } from '@grafana/ui';
import { SelectOptionItem } from '@grafana/ui';
import { Variable } from 'app/types/templates';
export interface Props {
onChange: (value: string) => void;
options: SelectOptionItem[];
isSearchable: boolean;
value: string;
placeholder?: string;
className?: string;
variables?: Variable[];
}
interface State {
options: any[];
}
export class MetricSelect extends React.Component<Props, State> {
static defaultProps = {
variables: [],
options: [],
isSearchable: true,
};
constructor(props) {
super(props);
this.state = { options: [] };
}
componentDidMount() {
this.setState({ options: this.buildOptions(this.props) });
}
componentWillReceiveProps(nextProps: Props) {
if (nextProps.options.length > 0 || nextProps.variables.length) {
this.setState({ options: this.buildOptions(nextProps) });
}
}
shouldComponentUpdate(nextProps: Props) {
const nextOptions = this.buildOptions(nextProps);
return nextProps.value !== this.props.value || !_.isEqual(nextOptions, this.state.options);
}
buildOptions({ variables = [], options }) {
return variables.length > 0 ? [this.getVariablesGroup(), ...options] : options;
}
getVariablesGroup() {
return {
label: 'Template Variables',
options: this.props.variables.map(v => ({
label: `$${v.name}`,
value: `$${v.name}`,
})),
};
}
getSelectedOption() {
const { options } = this.state;
const allOptions = options.every(o => o.options) ? _.flatten(options.map(o => o.options)) : options;
return allOptions.find(option => option.value === this.props.value);
}
render() {
const { placeholder, className, isSearchable, onChange } = this.props;
const { options } = this.state;
const selectedOption = this.getSelectedOption();
return (
<Select
className={className}
isMulti={false}
isClearable={false}
backspaceRemovesValue={false}
onChange={item => onChange(item.value)}
options={options}
isSearchable={isSearchable}
maxMenuHeight={500}
placeholder={placeholder}
noOptionsMessage={() => 'No options found'}
value={selectedOption}
/>
);
}
}

View File

@ -1,31 +1,18 @@
import _ from 'lodash';
import './query_filter_ctrl';
import { TemplateSrv } from 'app/features/templating/template_srv';
export class StackdriverAnnotationsQueryCtrl {
static templateUrl = 'partials/annotations.editor.html';
annotation: any;
datasource: any;
defaultDropdownValue = 'Select Metric';
defaultServiceValue = 'All Services';
defaults = {
project: {
id: 'default',
name: 'loading project...',
},
metricType: this.defaultDropdownValue,
service: this.defaultServiceValue,
metric: '',
filters: [],
metricKind: '',
valueType: '',
};
templateSrv: TemplateSrv;
/** @ngInject */
constructor() {
constructor(templateSrv) {
this.templateSrv = templateSrv;
this.annotation.target = this.annotation.target || {};
this.annotation.target.refId = 'annotationQuery';
_.defaultsDeep(this.annotation.target, this.defaults);
this.onQueryChange = this.onQueryChange.bind(this);
}
onQueryChange(target) {
Object.assign(this.annotation.target, target);
}
}

View File

@ -0,0 +1,57 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { Aggregations, Props } from './Aggregations';
import { shallow } from 'enzyme';
import { ValueTypes, MetricKind } from '../constants';
import { TemplateSrvStub } from 'test/specs/helpers';
const props: Props = {
onChange: () => {},
templateSrv: new TemplateSrvStub(),
metricDescriptor: {
valueType: '',
metricKind: '',
},
crossSeriesReducer: '',
groupBys: [],
children: renderProps => <div />,
};
describe('Aggregations', () => {
let wrapper;
it('renders correctly', () => {
const tree = renderer.create(<Aggregations {...props} />).toJSON();
expect(tree).toMatchSnapshot();
});
describe('options', () => {
describe('when DOUBLE and DELTA is passed as props', () => {
beforeEach(() => {
const newProps = { ...props, metricDescriptor: { valueType: ValueTypes.DOUBLE, metricKind: MetricKind.GAUGE } };
wrapper = shallow(<Aggregations {...newProps} />);
});
it('', () => {
const options = wrapper.state().aggOptions[0].options;
expect(options.length).toEqual(11);
expect(options.map(o => o.value)).toEqual(
expect.not.arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
);
});
});
describe('when MONEY and CUMULATIVE is passed as props', () => {
beforeEach(() => {
const newProps = {
...props,
metricDescriptor: { valueType: ValueTypes.MONEY, metricKind: MetricKind.CUMULATIVE },
};
wrapper = shallow(<Aggregations {...newProps} />);
});
it('', () => {
const options = wrapper.state().aggOptions[0].options;
expect(options.length).toEqual(5);
expect(options.map(o => o.value)).toEqual(expect.arrayContaining(['REDUCE_NONE']));
});
});
});
});

View File

@ -0,0 +1,94 @@
import React from 'react';
import _ from 'lodash';
import { MetricSelect } from 'app/core/components/Select/MetricSelect';
import { getAggregationOptionsByMetric } from '../functions';
import { TemplateSrv } from 'app/features/templating/template_srv';
export interface Props {
onChange: (metricDescriptor) => void;
templateSrv: TemplateSrv;
metricDescriptor: {
valueType: string;
metricKind: string;
};
crossSeriesReducer: string;
groupBys: string[];
children?: (renderProps: any) => JSX.Element;
}
export interface State {
aggOptions: any[];
displayAdvancedOptions: boolean;
}
export class Aggregations extends React.Component<Props, State> {
state: State = {
aggOptions: [],
displayAdvancedOptions: false,
};
componentDidMount() {
this.setAggOptions(this.props);
}
componentWillReceiveProps(nextProps: Props) {
this.setAggOptions(nextProps);
}
setAggOptions({ metricDescriptor }: Props) {
let aggOptions = [];
if (metricDescriptor) {
aggOptions = [
{
label: 'Aggregations',
expanded: true,
options: getAggregationOptionsByMetric(metricDescriptor.valueType, metricDescriptor.metricKind).map(a => ({
...a,
label: a.text,
})),
},
];
}
this.setState({ aggOptions });
}
onToggleDisplayAdvanced = () => {
this.setState(state => ({
displayAdvancedOptions: !state.displayAdvancedOptions,
}));
};
render() {
const { displayAdvancedOptions, aggOptions } = this.state;
const { templateSrv, onChange, crossSeriesReducer } = this.props;
return (
<>
<div className="gf-form-inline">
<div className="gf-form">
<label className="gf-form-label query-keyword width-9">Aggregation</label>
<MetricSelect
onChange={onChange}
value={crossSeriesReducer}
variables={templateSrv.variables}
options={aggOptions}
placeholder="Select Aggregation"
className="width-15"
/>
</div>
<div className="gf-form gf-form--grow">
<label className="gf-form-label gf-form-label--grow">
<a onClick={this.onToggleDisplayAdvanced}>
<>
<i className={`fa fa-caret-${displayAdvancedOptions ? 'down' : 'right'}`} /> Advanced Options
</>
</a>
</label>
</div>
</div>
{this.props.children(this.state.displayAdvancedOptions)}
</>
);
}
}

View File

@ -0,0 +1,52 @@
import React, { Component } from 'react';
import { debounce } from 'lodash';
export interface Props {
onChange: (alignmentPeriod) => void;
value: string;
}
export interface State {
value: string;
}
export class AliasBy extends Component<Props, State> {
propagateOnChange: (value) => void;
constructor(props) {
super(props);
this.propagateOnChange = debounce(this.props.onChange, 500);
this.state = { value: '' };
}
componentDidMount() {
this.setState({ value: this.props.value });
}
componentWillReceiveProps(nextProps: Props) {
if (nextProps.value !== this.props.value) {
this.setState({ value: nextProps.value });
}
}
onChange = e => {
this.setState({ value: e.target.value });
this.propagateOnChange(e.target.value);
};
render() {
return (
<>
<div className="gf-form-inline">
<div className="gf-form">
<label className="gf-form-label query-keyword width-9">Alias By</label>
<input type="text" className="gf-form-input width-24" value={this.state.value} onChange={this.onChange} />
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
</>
);
}
}

View File

@ -0,0 +1,56 @@
import React, { SFC } from 'react';
import _ from 'lodash';
import kbn from 'app/core/utils/kbn';
import { MetricSelect } from 'app/core/components/Select/MetricSelect';
import { alignmentPeriods, alignOptions } from '../constants';
import { TemplateSrv } from 'app/features/templating/template_srv';
export interface Props {
onChange: (alignmentPeriod) => void;
templateSrv: TemplateSrv;
alignmentPeriod: string;
perSeriesAligner: string;
usedAlignmentPeriod: string;
}
export const AlignmentPeriods: SFC<Props> = ({
alignmentPeriod,
templateSrv,
onChange,
perSeriesAligner,
usedAlignmentPeriod,
}) => {
const alignment = alignOptions.find(ap => ap.value === templateSrv.replace(perSeriesAligner));
const formatAlignmentText = `${kbn.secondsToHms(usedAlignmentPeriod)} interval (${alignment ? alignment.text : ''})`;
return (
<>
<div className="gf-form-inline">
<div className="gf-form">
<label className="gf-form-label query-keyword width-9">Alignment Period</label>
<MetricSelect
onChange={onChange}
value={alignmentPeriod}
variables={templateSrv.variables}
options={[
{
label: 'Alignment options',
expanded: true,
options: alignmentPeriods.map(ap => ({
...ap,
label: ap.text,
})),
},
]}
placeholder="Select Alignment"
className="width-15"
/>
</div>
<div className="gf-form gf-form--grow">
{usedAlignmentPeriod && <label className="gf-form-label gf-form-label--grow">{formatAlignmentText}</label>}
</div>
</div>
</>
);
};

View File

@ -0,0 +1,33 @@
import React, { SFC } from 'react';
import _ from 'lodash';
import { MetricSelect } from 'app/core/components/Select/MetricSelect';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { SelectOptionItem } from '@grafana/ui';
export interface Props {
onChange: (perSeriesAligner) => void;
templateSrv: TemplateSrv;
alignOptions: SelectOptionItem[];
perSeriesAligner: string;
}
export const Alignments: SFC<Props> = ({ perSeriesAligner, templateSrv, onChange, alignOptions }) => {
return (
<>
<div className="gf-form-group">
<div className="gf-form offset-width-9">
<label className="gf-form-label query-keyword width-15">Aligner</label>
<MetricSelect
onChange={onChange}
value={perSeriesAligner}
variables={templateSrv.variables}
options={alignOptions}
placeholder="Select Alignment"
className="width-15"
/>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,119 @@
import React from 'react';
import _ from 'lodash';
import { TemplateSrv } from 'app/features/templating/template_srv';
import StackdriverDatasource from '../datasource';
import { Metrics } from './Metrics';
import { Filter } from './Filter';
import { AnnotationTarget } from '../types';
import { AnnotationsHelp } from './AnnotationsHelp';
export interface Props {
onQueryChange: (target: AnnotationTarget) => void;
target: AnnotationTarget;
datasource: StackdriverDatasource;
templateSrv: TemplateSrv;
}
interface State extends AnnotationTarget {
[key: string]: any;
}
const DefaultTarget: State = {
defaultProject: 'loading project...',
metricType: '',
filters: [],
metricKind: '',
valueType: '',
refId: 'annotationQuery',
title: '',
text: '',
};
export class AnnotationQueryEditor extends React.Component<Props, State> {
state: State = DefaultTarget;
componentDidMount() {
this.setState({
...this.props.target,
});
}
onMetricTypeChange = ({ valueType, metricKind, type, unit }) => {
const { onQueryChange } = this.props;
this.setState(
{
metricType: type,
unit,
valueType,
metricKind,
},
() => {
onQueryChange(this.state);
}
);
};
onChange(prop, value) {
this.setState({ [prop]: value }, () => {
this.props.onQueryChange(this.state);
});
}
render() {
const { defaultProject, metricType, filters, refId, title, text } = this.state;
const { datasource, templateSrv } = this.props;
return (
<>
<Metrics
defaultProject={defaultProject}
metricType={metricType}
templateSrv={templateSrv}
datasource={datasource}
onChange={this.onMetricTypeChange}
>
{metric => (
<>
<Filter
filtersChanged={value => this.onChange('filters', value)}
filters={filters}
refId={refId}
hideGroupBys={true}
templateSrv={templateSrv}
datasource={datasource}
metricType={metric ? metric.type : ''}
/>
</>
)}
</Metrics>
<div className="gf-form gf-form-inline">
<div className="gf-form">
<span className="gf-form-label query-keyword width-9">Title</span>
<input
type="text"
className="gf-form-input width-20"
value={title}
onChange={e => this.onChange('title', e.target.value)}
/>
</div>
<div className="gf-form">
<span className="gf-form-label query-keyword width-9">Text</span>
<input
type="text"
className="gf-form-input width-20"
value={text}
onChange={e => this.onChange('text', e.target.value)}
/>
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
<AnnotationsHelp />
</>
);
}
}

View File

@ -0,0 +1,44 @@
import React, { SFC } from 'react';
export const AnnotationsHelp: SFC = () => {
return (
<div className="gf-form grafana-info-box" style={{ padding: 0 }}>
<pre className="gf-form-pre alert alert-info" style={{ marginRight: 0 }}>
<h5>Annotation Query Format</h5>
<p>
An annotation is an event that is overlaid on top of graphs. Annotation rendering is expensive so it is
important to limit the number of rows returned.{' '}
</p>
<p>
The Title and Text fields support templating and can use data returned from the query. For example, the Title
field could have the following text:
</p>
<code>
{`${'{{metric.type}}'}`} has value: {`${'{{metric.value}}'}`}
</code>
<p>
Example Result: <code>monitoring.googleapis.com/uptime_check/http_status has this value: 502</code>
</p>
<label>Patterns:</label>
<p>
<code>{`${'{{metric.value}}'}`}</code> = value of the metric/point
</p>
<p>
<code>{`${'{{metric.type}}'}`}</code> = metric type e.g. compute.googleapis.com/instance/cpu/usage_time
</p>
<p>
<code>{`${'{{metric.name}}'}`}</code> = name part of metric e.g. instance/cpu/usage_time
</p>
<p>
<code>{`${'{{metric.service}}'}`}</code> = service part of metric e.g. compute
</p>
<p>
<code>{`${'{{metric.label.label_name}}'}`}</code> = Metric label metadata e.g. metric.label.instance_name
</p>
<p>
<code>{`${'{{resource.label.label_name}}'}`}</code> = Resource label metadata e.g. resource.label.zone
</p>
</pre>
</div>
);
};

View File

@ -0,0 +1,115 @@
import React from 'react';
import _ from 'lodash';
import appEvents from 'app/core/app_events';
import { QueryMeta } from '../types';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
import { TemplateSrv } from 'app/features/templating/template_srv';
import StackdriverDatasource from '../datasource';
import '../query_filter_ctrl';
export interface Props {
filtersChanged: (filters: string[]) => void;
groupBysChanged?: (groupBys: string[]) => void;
metricType: string;
templateSrv: TemplateSrv;
groupBys?: string[];
filters: string[];
datasource: StackdriverDatasource;
refId: string;
hideGroupBys: boolean;
}
interface State {
labelData: QueryMeta;
loading: Promise<any>;
}
const labelData = {
metricLabels: {},
resourceLabels: {},
resourceTypes: [],
};
export class Filter extends React.Component<Props, State> {
element: any;
component: AngularComponent;
async componentDidMount() {
if (!this.element) {
return;
}
const { groupBys, filters, hideGroupBys } = this.props;
const loader = getAngularLoader();
const filtersChanged = filters => {
this.props.filtersChanged(filters);
};
const groupBysChanged = groupBys => {
this.props.groupBysChanged(groupBys);
};
const scopeProps = {
loading: null,
labelData,
groupBys,
filters,
filtersChanged,
groupBysChanged,
hideGroupBys,
};
const loading = this.loadLabels(scopeProps);
scopeProps.loading = loading;
const template = `<stackdriver-filter
filters="filters"
group-bys="groupBys"
label-data="labelData"
loading="loading"
filters-changed="filtersChanged(filters)"
group-bys-changed="groupBysChanged(groupBys)"
hide-group-bys="hideGroupBys"/>`;
this.component = loader.load(this.element, scopeProps, template);
}
componentDidUpdate(prevProps: Props) {
if (!this.element) {
return;
}
const scope = this.component.getScope();
if (prevProps.metricType !== this.props.metricType) {
scope.loading = this.loadLabels(scope);
}
scope.filters = this.props.filters;
scope.groupBys = this.props.groupBys;
}
componentWillUnmount() {
if (this.component) {
this.component.destroy();
}
}
async loadLabels(scope) {
return new Promise(async resolve => {
try {
if (!this.props.metricType) {
scope.labelData = labelData;
} else {
const { meta } = await this.props.datasource.getLabels(this.props.metricType, this.props.refId);
scope.labelData = meta;
}
resolve();
} catch (error) {
appEvents.emit('alert-error', ['Error', 'Error loading metric labels for ' + this.props.metricType]);
scope.labelData = labelData;
resolve();
}
});
}
render() {
return <div ref={element => (this.element = element)} style={{ width: '100%' }} />;
}
}

View File

@ -0,0 +1,115 @@
import React from 'react';
import { Project } from './Project';
import StackdriverDatasource from '../datasource';
export interface Props {
datasource: StackdriverDatasource;
rawQuery: string;
lastQueryError: string;
}
interface State {
displayHelp: boolean;
displaRawQuery: boolean;
}
export class Help extends React.Component<Props, State> {
state: State = {
displayHelp: false,
displaRawQuery: false,
};
onHelpClicked = () => {
this.setState({ displayHelp: !this.state.displayHelp });
};
onRawQueryClicked = () => {
this.setState({ displaRawQuery: !this.state.displaRawQuery });
};
shouldComponentUpdate(nextProps) {
return nextProps.metricDescriptor !== null;
}
render() {
const { displayHelp, displaRawQuery } = this.state;
const { datasource, rawQuery, lastQueryError } = this.props;
return (
<>
<div className="gf-form-inline">
<Project datasource={datasource} />
<div className="gf-form" onClick={this.onHelpClicked}>
<label className="gf-form-label query-keyword pointer">
Show Help <i className={`fa fa-caret-${displayHelp ? 'down' : 'right'}`} />
</label>
</div>
{rawQuery && (
<div className="gf-form" onClick={this.onRawQueryClicked}>
<label className="gf-form-label query-keyword">
Raw Query <i className={`fa fa-caret-${displaRawQuery ? 'down' : 'right'}`} ng-show="ctrl.showHelp" />
</label>
</div>
)}
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
{rawQuery &&
displaRawQuery && (
<div className="gf-form">
<pre className="gf-form-pre">{rawQuery}</pre>
</div>
)}
{displayHelp && (
<div className="gf-form grafana-info-box" style={{ padding: 0 }}>
<pre className="gf-form-pre alert alert-info" style={{ marginRight: 0 }}>
<h5>Alias Patterns</h5>Format the legend keys any way you want by using alias patterns. Format the legend
keys any way you want by using alias patterns.<br /> <br />
Example:
<code>{`${'{{metricDescriptor.name}} - {{metricDescriptor.label.instance_name}}'}`}</code>
<br />
Result: &nbsp;&nbsp;<code>cpu/usage_time - server1-europe-west-1</code>
<br />
<br />
<strong>Patterns</strong>
<br />
<ul>
<li>
<code>{`${'{{metricDescriptor.type}}'}`}</code> = metric type e.g.
compute.googleapis.com/instance/cpu/usage_time
</li>
<li>
<code>{`${'{{metricDescriptor.name}}'}`}</code> = name part of metric e.g. instance/cpu/usage_time
</li>
<li>
<code>{`${'{{metricDescriptor.service}}'}`}</code> = service part of metric e.g. compute
</li>
<li>
<code>{`${'{{metricDescriptor.label.label_name}}'}`}</code> = Metric label metadata e.g.
metricDescriptor.label.instance_name
</li>
<li>
<code>{`${'{{resource.label.label_name}}'}`}</code> = Resource label metadata e.g. resource.label.zone
</li>
<li>
<code>{`${'{{bucket}}'}`}</code> = bucket boundary for distribution metrics when using a heatmap in
Grafana
</li>
</ul>
</pre>
</div>
)}
{lastQueryError && (
<div className="gf-form">
<pre className="gf-form-pre alert alert-error">{lastQueryError}</pre>
</div>
)}
</>
);
}
}

View File

@ -0,0 +1,195 @@
import React from 'react';
import _ from 'lodash';
import StackdriverDatasource from '../datasource';
import appEvents from 'app/core/app_events';
import { MetricDescriptor } from '../types';
import { MetricSelect } from 'app/core/components/Select/MetricSelect';
import { TemplateSrv } from 'app/features/templating/template_srv';
export interface Props {
onChange: (metricDescriptor: MetricDescriptor) => void;
templateSrv: TemplateSrv;
datasource: StackdriverDatasource;
defaultProject: string;
metricType: string;
children?: (renderProps: any) => JSX.Element;
}
interface State {
metricDescriptors: MetricDescriptor[];
metrics: any[];
services: any[];
service: string;
metric: string;
metricDescriptor: MetricDescriptor;
defaultProject: string;
}
export class Metrics extends React.Component<Props, State> {
state: State = {
metricDescriptors: [],
metrics: [],
services: [],
service: '',
metric: '',
metricDescriptor: null,
defaultProject: '',
};
constructor(props) {
super(props);
}
componentDidMount() {
this.setState({ defaultProject: this.props.defaultProject }, () => {
this.getCurrentProject()
.then(this.loadMetricDescriptors.bind(this))
.then(this.initializeServiceAndMetrics.bind(this));
});
}
async getCurrentProject() {
return new Promise(async (resolve, reject) => {
try {
if (!this.state.defaultProject || this.state.defaultProject === 'loading project...') {
const defaultProject = await this.props.datasource.getDefaultProject();
this.setState({ defaultProject });
}
resolve(this.state.defaultProject);
} catch (error) {
appEvents.emit('ds-request-error', error);
reject();
}
});
}
async loadMetricDescriptors() {
if (this.state.defaultProject !== 'loading project...') {
const metricDescriptors = await this.props.datasource.getMetricTypes(this.state.defaultProject);
this.setState({ metricDescriptors });
return metricDescriptors;
} else {
return [];
}
}
async initializeServiceAndMetrics() {
const { metricDescriptors } = this.state;
const services = this.getServicesList(metricDescriptors);
const metrics = this.getMetricsList(metricDescriptors);
const service = metrics.length > 0 ? metrics[0].service : '';
const metricDescriptor = this.getSelectedMetricDescriptor(this.props.metricType);
this.setState({ metricDescriptors, services, metrics, service: service, metricDescriptor });
}
getSelectedMetricDescriptor(metricType) {
return this.state.metricDescriptors.find(md => md.type === this.props.templateSrv.replace(metricType));
}
getMetricsList(metricDescriptors: MetricDescriptor[]) {
const selectedMetricDescriptor = this.getSelectedMetricDescriptor(this.props.metricType);
if (!selectedMetricDescriptor) {
return [];
}
const metricsByService = metricDescriptors.filter(m => m.service === selectedMetricDescriptor.service).map(m => ({
service: m.service,
value: m.type,
label: m.displayName,
description: m.description,
}));
return metricsByService;
}
onServiceChange = service => {
const { metricDescriptors } = this.state;
const { templateSrv, metricType } = this.props;
const metrics = metricDescriptors.filter(m => m.service === templateSrv.replace(service)).map(m => ({
service: m.service,
value: m.type,
label: m.displayName,
description: m.description,
}));
this.setState({ service, metrics });
if (metrics.length > 0 && !metrics.some(m => m.value === templateSrv.replace(metricType))) {
this.onMetricTypeChange(metrics[0].value);
}
};
onMetricTypeChange = value => {
const metricDescriptor = this.getSelectedMetricDescriptor(value);
this.setState({ metricDescriptor });
this.props.onChange({ ...metricDescriptor, type: value });
};
getServicesList(metricDescriptors: MetricDescriptor[]) {
const services = metricDescriptors.map(m => ({
value: m.service,
label: _.startCase(m.serviceShortName),
}));
return services.length > 0 ? _.uniqBy(services, s => s.value) : [];
}
getTemplateVariablesGroup() {
return {
label: 'Template Variables',
options: this.props.templateSrv.variables.map(v => ({
label: `$${v.name}`,
value: `$${v.name}`,
})),
};
}
render() {
const { services, service, metrics } = this.state;
const { metricType, templateSrv } = this.props;
return (
<>
<div className="gf-form-inline">
<div className="gf-form">
<span className="gf-form-label width-9 query-keyword">Service</span>
<MetricSelect
onChange={this.onServiceChange}
value={service}
options={services}
isSearchable={false}
placeholder="Select Services"
className="width-15"
/>
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<span className="gf-form-label width-9 query-keyword">Metric</span>
<MetricSelect
onChange={this.onMetricTypeChange}
value={metricType}
variables={templateSrv.variables}
options={[
{
label: 'Metrics',
expanded: true,
options: metrics,
},
]}
placeholder="Select Metric"
className="width-15"
/>
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
{this.props.children(this.state.metricDescriptor)}
</>
);
}
}

View File

@ -0,0 +1,31 @@
import React from 'react';
import StackdriverDatasource from '../datasource';
export interface Props {
datasource: StackdriverDatasource;
}
interface State {
projectName: string;
}
export class Project extends React.Component<Props, State> {
state: State = {
projectName: 'Loading project...',
};
async componentDidMount() {
const projectName = await this.props.datasource.getDefaultProject();
this.setState({ projectName });
}
render() {
const { projectName } = this.state;
return (
<div className="gf-form">
<span className="gf-form-label width-9 query-keyword">Project</span>
<input className="gf-form-input width-15" disabled type="text" value={projectName} />
</div>
);
}
}

View File

@ -0,0 +1,23 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { QueryEditor, Props, DefaultTarget } from './QueryEditor';
import { TemplateSrv } from 'app/features/templating/template_srv';
const props: Props = {
onQueryChange: target => {},
onExecuteQuery: () => {},
target: DefaultTarget,
events: { on: () => {} },
datasource: {
getDefaultProject: () => Promise.resolve('project'),
getMetricTypes: () => Promise.resolve([]),
} as any,
templateSrv: new TemplateSrv(),
};
describe('QueryEditor', () => {
it('renders correctly', () => {
const tree = renderer.create(<QueryEditor {...props} />).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,206 @@
import React from 'react';
import _ from 'lodash';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { Metrics } from './Metrics';
import { Filter } from './Filter';
import { Aggregations } from './Aggregations';
import { Alignments } from './Alignments';
import { AlignmentPeriods } from './AlignmentPeriods';
import { AliasBy } from './AliasBy';
import { Help } from './Help';
import { Target, MetricDescriptor } from '../types';
import { getAlignmentPickerData } from '../functions';
import StackdriverDatasource from '../datasource';
import { SelectOptionItem } from '@grafana/ui';
export interface Props {
onQueryChange: (target: Target) => void;
onExecuteQuery: () => void;
target: Target;
events: any;
datasource: StackdriverDatasource;
templateSrv: TemplateSrv;
}
interface State extends Target {
alignOptions: SelectOptionItem[];
lastQuery: string;
lastQueryError: string;
[key: string]: any;
}
export const DefaultTarget: State = {
defaultProject: 'loading project...',
metricType: '',
metricKind: '',
valueType: '',
refId: '',
service: '',
unit: '',
crossSeriesReducer: 'REDUCE_MEAN',
alignmentPeriod: 'stackdriver-auto',
perSeriesAligner: 'ALIGN_MEAN',
groupBys: [],
filters: [],
aliasBy: '',
alignOptions: [],
lastQuery: '',
lastQueryError: '',
usedAlignmentPeriod: '',
};
export class QueryEditor extends React.Component<Props, State> {
state: State = DefaultTarget;
componentDidMount() {
const { events, target, templateSrv } = this.props;
events.on('data-received', this.onDataReceived.bind(this));
events.on('data-error', this.onDataError.bind(this));
const { perSeriesAligner, alignOptions } = getAlignmentPickerData(target, templateSrv);
this.setState({
...this.props.target,
alignOptions,
perSeriesAligner,
});
}
componentWillUnmount() {
this.props.events.off('data-received', this.onDataReceived);
this.props.events.off('data-error', this.onDataError);
}
onDataReceived(dataList) {
const series = dataList.find(item => item.refId === this.props.target.refId);
if (series) {
this.setState({
lastQuery: decodeURIComponent(series.meta.rawQuery),
lastQueryError: '',
usedAlignmentPeriod: series.meta.alignmentPeriod,
});
}
}
onDataError(err) {
let lastQuery;
let lastQueryError;
if (err.data && err.data.error) {
lastQueryError = this.props.datasource.formatStackdriverError(err);
} else if (err.data && err.data.results) {
const queryRes = err.data.results[this.props.target.refId];
lastQuery = decodeURIComponent(queryRes.meta.rawQuery);
if (queryRes && queryRes.error) {
try {
lastQueryError = JSON.parse(queryRes.error).error.message;
} catch {
lastQueryError = queryRes.error;
}
}
}
this.setState({ lastQuery, lastQueryError });
}
onMetricTypeChange = ({ valueType, metricKind, type, unit }: MetricDescriptor) => {
const { templateSrv, onQueryChange, onExecuteQuery } = this.props;
const { perSeriesAligner, alignOptions } = getAlignmentPickerData(
{ valueType, metricKind, perSeriesAligner: this.state.perSeriesAligner },
templateSrv
);
this.setState(
{
alignOptions,
perSeriesAligner,
metricType: type,
unit,
valueType,
metricKind,
},
() => {
onQueryChange(this.state);
onExecuteQuery();
}
);
};
onPropertyChange(prop, value) {
this.setState({ [prop]: value }, () => {
this.props.onQueryChange(this.state);
this.props.onExecuteQuery();
});
}
render() {
const {
usedAlignmentPeriod,
defaultProject,
metricType,
crossSeriesReducer,
groupBys,
filters,
perSeriesAligner,
alignOptions,
alignmentPeriod,
aliasBy,
lastQuery,
lastQueryError,
refId,
} = this.state;
const { datasource, templateSrv } = this.props;
return (
<>
<Metrics
defaultProject={defaultProject}
metricType={metricType}
templateSrv={templateSrv}
datasource={datasource}
onChange={this.onMetricTypeChange}
>
{metric => (
<>
<Filter
filtersChanged={value => this.onPropertyChange('filters', value)}
groupBysChanged={value => this.onPropertyChange('groupBys', value)}
filters={filters}
groupBys={groupBys}
refId={refId}
hideGroupBys={false}
templateSrv={templateSrv}
datasource={datasource}
metricType={metric ? metric.type : ''}
/>
<Aggregations
metricDescriptor={metric}
templateSrv={templateSrv}
crossSeriesReducer={crossSeriesReducer}
groupBys={groupBys}
onChange={value => this.onPropertyChange('crossSeriesReducer', value)}
>
{displayAdvancedOptions =>
displayAdvancedOptions && (
<Alignments
alignOptions={alignOptions}
templateSrv={templateSrv}
perSeriesAligner={perSeriesAligner}
onChange={value => this.onPropertyChange('perSeriesAligner', value)}
/>
)
}
</Aggregations>
<AlignmentPeriods
templateSrv={templateSrv}
alignmentPeriod={alignmentPeriod}
perSeriesAligner={perSeriesAligner}
usedAlignmentPeriod={usedAlignmentPeriod}
onChange={value => this.onPropertyChange('alignmentPeriod', value)}
/>
<AliasBy value={aliasBy} onChange={value => this.onPropertyChange('aliasBy', value)} />
<Help datasource={datasource} rawQuery={lastQuery} lastQueryError={lastQueryError} />
</>
)}
</Metrics>
</>
);
}
}

View File

@ -63,7 +63,7 @@ export class StackdriverVariableQueryEditor extends PureComponent<VariableQueryP
this.setState(state);
}
async handleQueryTypeChange(event) {
async onQueryTypeChange(event) {
const state: any = {
selectedQueryType: event.target.value,
...await this.getLabels(this.state.selectedMetricType, event.target.value),
@ -134,7 +134,7 @@ export class StackdriverVariableQueryEditor extends PureComponent<VariableQueryP
case MetricFindQueryTypes.LabelValues:
case MetricFindQueryTypes.ResourceTypes:
return (
<React.Fragment>
<>
<SimpleSelect
value={this.state.selectedService}
options={this.insertTemplateVariables(this.state.services)}
@ -155,12 +155,12 @@ export class StackdriverVariableQueryEditor extends PureComponent<VariableQueryP
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)}
@ -173,7 +173,7 @@ export class StackdriverVariableQueryEditor extends PureComponent<VariableQueryP
onValueChange={e => this.onMetricTypeChange(e)}
label="Metric Type"
/>
</React.Fragment>
</>
);
default:
return '';
@ -182,15 +182,15 @@ export class StackdriverVariableQueryEditor extends PureComponent<VariableQueryP
render() {
return (
<React.Fragment>
<>
<SimpleSelect
value={this.state.selectedQueryType}
options={this.queryTypes}
onValueChange={e => this.handleQueryTypeChange(e)}
onValueChange={e => this.onQueryTypeChange(e)}
label="Query Type"
/>
{this.renderQueryTypeSwitch(this.state.selectedQueryType)}
</React.Fragment>
</>
);
}
}

View File

@ -0,0 +1,119 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Aggregations renders correctly 1`] = `
Array [
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<label
className="gf-form-label query-keyword width-9"
>
Aggregation
</label>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container"
>
<div
className="css-0 gf-form-select-box__placeholder"
>
Select Aggregation
</div>
<div
className="css-0"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</div>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form-label gf-form-label--grow"
>
<a
onClick={[Function]}
>
<i
className="fa fa-caret-right"
/>
Advanced Options
</a>
</label>
</div>
</div>,
<div />,
]
`;

View File

@ -0,0 +1,459 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`QueryEditor renders correctly 1`] = `
Array [
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<span
className="gf-form-label width-9 query-keyword"
>
Service
</span>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container"
>
<div
className="css-0 gf-form-select-box__placeholder"
>
Select Services
</div>
<input
className="css-14uuagi"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
readOnly={true}
tabIndex="0"
value=""
/>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<span
className="gf-form-label width-9 query-keyword"
>
Metric
</span>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container"
>
<div
className="css-0 gf-form-select-box__placeholder"
>
Select Metric
</div>
<div
className="css-0"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-3-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</div>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
<div
style={
Object {
"width": "100%",
}
}
/>,
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<label
className="gf-form-label query-keyword width-9"
>
Aggregation
</label>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container"
>
<div
className="css-0 gf-form-select-box__placeholder"
>
Select Aggregation
</div>
<div
className="css-0"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-4-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</div>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form-label gf-form-label--grow"
>
<a
onClick={[Function]}
>
<i
className="fa fa-caret-right"
/>
Advanced Options
</a>
</label>
</div>
</div>,
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<label
className="gf-form-label query-keyword width-9"
>
Alignment Period
</label>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container gf-form-select-box__value-container--has-value"
>
<div
className="css-0 gf-form-select-box__single-value"
>
<div
className="gf-form-select-box__img-value"
>
stackdriver auto
</div>
</div>
<div
className="css-0"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-5-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</div>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
<div
className="gf-form gf-form--grow"
>
</div>
</div>,
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<label
className="gf-form-label query-keyword width-9"
>
Alias By
</label>
<input
className="gf-form-input width-24"
onChange={[Function]}
type="text"
value=""
/>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<span
className="gf-form-label width-9 query-keyword"
>
Project
</span>
<input
className="gf-form-input width-15"
disabled={true}
type="text"
value="Loading project..."
/>
</div>
<div
className="gf-form"
onClick={[Function]}
>
<label
className="gf-form-label query-keyword pointer"
>
Show Help
<i
className="fa fa-caret-right"
/>
</label>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
"",
"",
]
`;

View File

@ -2,6 +2,7 @@ import { stackdriverUnitMappings } from './constants';
import appEvents from 'app/core/app_events';
import _ from 'lodash';
import StackdriverMetricFindQuery from './StackdriverMetricFindQuery';
import { MetricDescriptor } from './types';
export default class StackdriverDatasource {
id: number;
@ -28,21 +29,15 @@ export default class StackdriverDatasource {
return !target.hide && target.metricType;
})
.map(t => {
if (!t.hasOwnProperty('aggregation')) {
t.aggregation = {
crossSeriesReducer: 'REDUCE_MEAN',
groupBys: [],
};
}
return {
refId: t.refId,
intervalMs: options.intervalMs,
datasourceId: this.id,
metricType: this.templateSrv.replace(t.metricType, options.scopedVars || {}),
primaryAggregation: this.templateSrv.replace(t.aggregation.crossSeriesReducer, options.scopedVars || {}),
perSeriesAligner: this.templateSrv.replace(t.aggregation.perSeriesAligner, options.scopedVars || {}),
alignmentPeriod: this.templateSrv.replace(t.aggregation.alignmentPeriod, options.scopedVars || {}),
groupBys: this.interpolateGroupBys(t.aggregation.groupBys, options.scopedVars),
primaryAggregation: this.templateSrv.replace(t.crossSeriesReducer || 'REDUCE_MEAN', options.scopedVars || {}),
perSeriesAligner: this.templateSrv.replace(t.perSeriesAligner, options.scopedVars || {}),
alignmentPeriod: this.templateSrv.replace(t.alignmentPeriod, options.scopedVars || {}),
groupBys: this.interpolateGroupBys(t.groupBys, options.scopedVars),
view: t.view || 'FULL',
filters: (t.filters || []).map(f => {
return this.templateSrv.replace(f, options.scopedVars || {});
@ -75,9 +70,7 @@ export default class StackdriverDatasource {
refId: refId,
datasourceId: this.id,
metricType: this.templateSrv.replace(metricType),
aggregation: {
crossSeriesReducer: 'REDUCE_NONE',
},
crossSeriesReducer: 'REDUCE_NONE',
view: 'HEADERS',
},
],
@ -261,7 +254,7 @@ export default class StackdriverDatasource {
}
}
async getMetricTypes(projectName: string) {
async getMetricTypes(projectName: string): Promise<MetricDescriptor[]> {
try {
if (this.metricTypes.length === 0) {
const metricsApiPath = `v3/projects/${projectName}/metricDescriptors`;
@ -273,6 +266,7 @@ export default class StackdriverDatasource {
m.service = service;
m.serviceShortName = serviceShortName;
m.displayName = m.displayName || m.type;
return m;
});
}

View File

@ -5,13 +5,13 @@ export class FilterSegments {
filterSegments: any[];
removeSegment: any;
constructor(private uiSegmentSrv, private target, private getFilterKeysFunc, private getFilterValuesFunc) {}
constructor(private uiSegmentSrv, private filters, private getFilterKeysFunc, private getFilterValuesFunc) {}
buildSegmentModel() {
this.removeSegment = this.uiSegmentSrv.newSegment({ fake: true, value: DefaultRemoveFilterValue });
this.filterSegments = [];
this.target.filters.forEach((f, index) => {
this.filters.forEach((f, index) => {
switch (index % 4) {
case 0:
this.filterSegments.push(this.uiSegmentSrv.newKey(f));

View File

@ -0,0 +1,38 @@
import { getAlignmentOptionsByMetric } from './functions';
import { ValueTypes, MetricKind } from './constants';
describe('functions', () => {
let result;
describe('getAlignmentOptionsByMetric', () => {
describe('when double and gauge is passed', () => {
beforeEach(() => {
result = getAlignmentOptionsByMetric(ValueTypes.DOUBLE, MetricKind.GAUGE);
});
it('should return all alignment options except two', () => {
expect(result.length).toBe(9);
expect(result.map(o => o.value)).toEqual(
expect.not.arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
);
});
});
describe('when double and delta is passed', () => {
beforeEach(() => {
result = getAlignmentOptionsByMetric(ValueTypes.DOUBLE, MetricKind.DELTA);
});
it('should return all alignment options except four', () => {
expect(result.length).toBe(9);
expect(result.map(o => o.value)).toEqual(
expect.not.arrayContaining([
'ALIGN_COUNT_TRUE',
'ALIGN_COUNT_FALSE',
'ALIGN_FRACTION_TRUE',
'ALIGN_INTERPOLATE',
])
);
});
});
});
});

View File

@ -46,3 +46,21 @@ export const getLabelKeys = async (datasource, selectedMetricType) => {
: [];
return labelKeys;
};
export const getAlignmentPickerData = ({ valueType, metricKind, perSeriesAligner }, templateSrv) => {
const options = getAlignmentOptionsByMetric(valueType, metricKind).map(option => ({
...option,
label: option.text,
}));
const alignOptions = [
{
label: 'Alignment options',
expanded: true,
options,
},
];
if (!options.some(o => o.value === templateSrv.replace(perSeriesAligner))) {
perSeriesAligner = options.length > 0 ? options[0].value : '';
}
return { alignOptions, perSeriesAligner };
};

View File

@ -1,37 +1,6 @@
<stackdriver-filter target="ctrl.annotation.target" refresh="ctrl.refresh()" datasource="ctrl.datasource"
default-dropdown-value="ctrl.defaultDropdownValue" default-service-value="ctrl.defaultServiceValue" hide-group-bys="true"></stackdriver-filter>
<div class="gf-form gf-form-inline">
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Title</span>
<input type="text" class="gf-form-input width-20" ng-model="ctrl.annotation.target.title" />
</div>
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Text</span>
<input type="text" class="gf-form-input width-20" ng-model="ctrl.annotation.target.text" />
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form grafana-info-box" style="padding: 0">
<pre class="gf-form-pre alert alert-info" style="margin-right: 0"><h5>Annotation Query Format</h5>
An annotation is an event that is overlaid on top of graphs. Annotation rendering is expensive so it is important to limit the number of rows returned.
The Title and Text fields support templating and can use data returned from the query. For example, the Title field could have the following text:
<code ng-non-bindable>{{metric.type}} has value: {{metric.value}}</code>
Example Result: <code ng-non-bindable>monitoring.googleapis.com/uptime_check/http_status has this value: 502</code>
<label>Patterns:</label>
<code ng-non-bindable>{{metric.value}}</code> = value of the metric/point
<code ng-non-bindable>{{metric.type}}</code> = metric type e.g. compute.googleapis.com/instance/cpu/usage_time
<code ng-non-bindable>{{metric.name}}</code> = name part of metric e.g. instance/cpu/usage_time
<code ng-non-bindable>{{metric.service}}</code> = service part of metric e.g. compute
<code ng-non-bindable>{{metric.label.label_name}}</code> = Metric label metadata e.g. metric.label.instance_name
<code ng-non-bindable>{{resource.label.label_name}}</code> = Resource label metadata e.g. resource.label.zone
</pre>
</div>
<stackdriver-annotation-query-editor
target="ctrl.annotation.target"
on-query-change="(ctrl.onQueryChange)"
datasource="ctrl.datasource"
template-srv="ctrl.templateSrv"
></stackdriver-annotation-query-editor>

View File

@ -1,46 +0,0 @@
<div class="gf-form-inline">
<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">
<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">
<label class="gf-form-label gf-form-label--grow">
<a ng-click="ctrl.target.showAggregationOptions = !ctrl.target.showAggregationOptions">
<i class="fa fa-caret-down" ng-show="ctrl.target.showAggregationOptions"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.target.showAggregationOptions"></i>
Advanced Options
</a>
</label>
</div>
</div>
<div class="gf-form-group" ng-if="ctrl.target.showAggregationOptions">
<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">
<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">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
</div>
<div class="gf-form-inline">
<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">
<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>
<div class="gf-form gf-form--grow">
<label ng-if="alignmentPeriod" class="gf-form-label gf-form-label--grow">
{{ctrl.formatAlignmentText()}}
</label>
</div>
</div>

View File

@ -1,73 +1,10 @@
<query-editor-row query-ctrl="ctrl" has-text-edit-mode="false">
<stackdriver-filter target="ctrl.target" refresh="ctrl.refresh()" datasource="ctrl.datasource" default-dropdown-value="ctrl.defaultDropdownValue"
default-service-value="ctrl.defaultServiceValue"></stackdriver-filter>
<stackdriver-aggregation target="ctrl.target" alignment-period="ctrl.lastQueryMeta.alignmentPeriod" refresh="ctrl.refresh()"></stackdriver-aggregation>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Alias By</span>
<input type="text" class="gf-form-input width-30" ng-model="ctrl.target.aliasBy" ng-change="ctrl.refresh()"
ng-model-options="{ debounce: 500 }" />
</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 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">
<label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
Show Help
<i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
</label>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryMeta">
<label class="gf-form-label query-keyword" ng-click="ctrl.showLastQuery = !ctrl.showLastQuery">
Raw Query
<i class="fa fa-caret-down" ng-show="ctrl.showLastQuery"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showLastQuery"></i>
</label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form" ng-show="ctrl.showLastQuery">
<pre class="gf-form-pre">{{ctrl.lastQueryMeta.rawQueryString}}</pre>
</div>
<div class="gf-form grafana-info-box" style="padding: 0" ng-show="ctrl.showHelp">
<pre class="gf-form-pre alert alert-info" style="margin-right: 0"><h5>Alias Patterns</h5>Format the legend keys any way you want by using alias patterns.
Format the legend keys any way you want by using alias patterns.<br /> <br />
Example: <code ng-non-bindable>{{metric.name}} - {{metric.label.instance_name}}</code><br />
Result: &nbsp;&nbsp;<code ng-non-bindable>cpu/usage_time - server1-europe-west-1</code><br /><br />
<strong>Patterns</strong><br />
<ul>
<li>
<code ng-non-bindable>{{metric.type}}</code> = metric type e.g. compute.googleapis.com/instance/cpu/usage_time
</li>
<li>
<code ng-non-bindable>{{metric.name}}</code> = name part of metric e.g. instance/cpu/usage_time
</li>
<li>
<code ng-non-bindable>{{metric.service}}</code> = service part of metric e.g. compute
</li>
<li>
<code ng-non-bindable>{{metric.label.label_name}}</code> = Metric label metadata e.g.
metric.label.instance_name
</li>
<li>
<code ng-non-bindable>{{resource.label.label_name}}</code> = Resource label metadata e.g. resource.label.zone
</li>
</ul>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryError">
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
</div>
</query-editor-row>
<stackdriver-query-editor
target="ctrl.target"
events="ctrl.panelCtrl.events"
datasource="ctrl.datasource"
template-srv="ctrl.templateSrv"
on-query-change="(ctrl.onQueryChange)"
on-execute-query="(ctrl.onExecuteQuery)"
></stackdriver-query-editor>
</query-editor-row>

View File

@ -1,29 +1,3 @@
<div class="gf-form-inline">
<div class="gf-form">
<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 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>
@ -37,7 +11,7 @@
</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-inline" ng-hide="ctrl.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">

View File

@ -1,80 +0,0 @@
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 {
constructor() {
return {
templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.aggregation.html',
controller: 'StackdriverAggregationCtrl',
restrict: 'E',
scope: {
target: '=',
alignmentPeriod: '<',
refresh: '&',
},
};
}
}
export class StackdriverAggregationCtrl {
alignmentPeriods: any[];
aggOptions: any[];
alignOptions: any[];
target: any;
/** @ngInject */
constructor(private $scope, private templateSrv) {
this.$scope.ctrl = this;
this.target = $scope.target;
this.alignmentPeriods = options.alignmentPeriods;
this.aggOptions = options.aggOptions;
this.alignOptions = options.alignOptions;
this.setAggOptions();
this.setAlignOptions();
const self = this;
$scope.$on('metricTypeChanged', () => {
self.setAggOptions();
self.setAlignOptions();
});
}
setAlignOptions() {
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 = getAggregationOptionsByMetric(this.target.valueType, this.target.metricKind);
if (!this.aggOptions.find(o => o.value === this.templateSrv.replace(this.target.aggregation.crossSeriesReducer))) {
this.deselectAggregationOption('REDUCE_NONE');
}
if (this.target.aggregation.groupBys.length > 0) {
this.aggOptions = this.aggOptions.filter(o => o.value !== 'REDUCE_NONE');
this.deselectAggregationOption('REDUCE_NONE');
}
}
formatAlignmentText() {
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) {
const newValue = this.aggOptions.find(o => o.value !== notValidOptionValue);
this.target.aggregation.crossSeriesReducer = newValue ? newValue.value : '';
}
}
coreModule.directive('stackdriverAggregation', StackdriverAggregation);
coreModule.controller('StackdriverAggregationCtrl', StackdriverAggregationCtrl);

View File

@ -1,97 +1,26 @@
import _ from 'lodash';
import { QueryCtrl } from 'app/plugins/sdk';
import './query_aggregation_ctrl';
import './query_filter_ctrl';
export interface QueryMeta {
alignmentPeriod: string;
rawQuery: string;
rawQueryString: string;
metricLabels: { [key: string]: string[] };
resourceLabels: { [key: string]: string[] };
}
import { QueryCtrl } from 'app/plugins/sdk';
import { Target } from './types';
import { TemplateSrv } from 'app/features/templating/template_srv';
export class StackdriverQueryCtrl extends QueryCtrl {
static templateUrl = 'partials/query.editor.html';
target: {
defaultProject: string;
unit: string;
metricType: string;
service: string;
refId: string;
aggregation: {
crossSeriesReducer: string;
alignmentPeriod: string;
perSeriesAligner: string;
groupBys: string[];
};
filters: string[];
aliasBy: string;
metricKind: any;
valueType: any;
};
defaultDropdownValue = 'Select Metric';
defaultServiceValue = 'All Services';
defaults = {
defaultProject: 'loading project...',
metricType: this.defaultDropdownValue,
service: this.defaultServiceValue,
metric: '',
unit: '',
aggregation: {
crossSeriesReducer: 'REDUCE_MEAN',
alignmentPeriod: 'stackdriver-auto',
perSeriesAligner: 'ALIGN_MEAN',
groupBys: [],
},
filters: [],
showAggregationOptions: false,
aliasBy: '',
metricKind: '',
valueType: '',
};
showHelp: boolean;
showLastQuery: boolean;
lastQueryMeta: QueryMeta;
lastQueryError?: string;
templateSrv: TemplateSrv;
/** @ngInject */
constructor($scope, $injector) {
constructor($scope, $injector, templateSrv) {
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);
this.templateSrv = templateSrv;
this.onQueryChange = this.onQueryChange.bind(this);
this.onExecuteQuery = this.onExecuteQuery.bind(this);
}
onDataReceived(dataList) {
this.lastQueryError = null;
this.lastQueryMeta = null;
const anySeriesFromQuery: any = _.find(dataList, { refId: this.target.refId });
if (anySeriesFromQuery) {
this.lastQueryMeta = anySeriesFromQuery.meta;
this.lastQueryMeta.rawQueryString = decodeURIComponent(this.lastQueryMeta.rawQuery);
}
onQueryChange(target: Target) {
Object.assign(this.target, target);
}
onDataError(err) {
if (err.data && err.data.results) {
const queryRes = err.data.results[this.target.refId];
if (queryRes && queryRes.error) {
this.lastQueryMeta = queryRes.meta;
this.lastQueryMeta.rawQueryString = decodeURIComponent(this.lastQueryMeta.rawQuery);
let jsonBody;
try {
jsonBody = JSON.parse(queryRes.error);
} catch {
}
this.lastQueryError = jsonBody.error.message;
}
}
onExecuteQuery() {
this.$scope.ctrl.refresh();
}
}

View File

@ -1,7 +1,6 @@
import coreModule from 'app/core/core_module';
import _ from 'lodash';
import { FilterSegments } from './filter_segments';
import appEvents from 'app/core/app_events';
import { FilterSegments, DefaultFilterValue } from './filter_segments';
export class StackdriverFilter {
/** @ngInject */
@ -10,13 +9,15 @@ export class StackdriverFilter {
templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.filter.html',
controller: 'StackdriverFilterCtrl',
controllerAs: 'ctrl',
bindToController: true,
restrict: 'E',
scope: {
target: '=',
datasource: '=',
refresh: '&',
defaultDropdownValue: '<',
defaultServiceValue: '<',
labelData: '<',
loading: '<',
groupBys: '<',
filters: '<',
filtersChanged: '&',
groupBysChanged: '&',
hideGroupBys: '<',
},
};
@ -24,46 +25,28 @@ export class StackdriverFilter {
}
export class StackdriverFilterCtrl {
metricLabels: { [key: string]: string[] };
resourceLabels: { [key: string]: string[] };
resourceTypes: string[];
defaultRemoveGroupByValue = '-- remove group by --';
resourceTypeValue = 'resource.type';
loadLabelsPromise: Promise<any>;
service: string;
metricType: string;
metricDescriptors: any[];
metrics: any[];
services: any[];
groupBySegments: any[];
filterSegments: FilterSegments;
removeSegment: any;
target: any;
datasource: any;
filters: string[];
groupBys: string[];
hideGroupBys: boolean;
labelData: any;
loading: Promise<any>;
filtersChanged: (filters) => void;
groupBysChanged: (groupBys) => void;
/** @ngInject */
constructor(private $scope, private uiSegmentSrv, private templateSrv, private $rootScope) {
this.datasource = $scope.datasource;
this.target = $scope.target;
this.metricType = $scope.defaultDropdownValue;
this.service = $scope.defaultServiceValue;
this.metricDescriptors = [];
this.metrics = [];
this.services = [];
this.getCurrentProject()
.then(this.loadMetricDescriptors.bind(this))
.then(this.getLabels.bind(this));
this.initSegments($scope.hideGroupBys);
constructor(private $scope, private uiSegmentSrv, private templateSrv) {
this.$scope.ctrl = this;
this.initSegments(this.hideGroupBys);
}
initSegments(hideGroupBys: boolean) {
if (!hideGroupBys) {
this.groupBySegments = this.target.aggregation.groupBys.map(groupBy => {
this.groupBySegments = this.groupBys.map(groupBy => {
return this.uiSegmentSrv.getSegmentForValue(groupBy);
});
this.ensurePlusButton(this.groupBySegments);
@ -73,133 +56,17 @@ export class StackdriverFilterCtrl {
this.filterSegments = new FilterSegments(
this.uiSegmentSrv,
this.target,
this.filters,
this.getFilterKeys.bind(this),
this.getFilterValues.bind(this)
);
this.filterSegments.buildSegmentModel();
}
async getCurrentProject() {
return new Promise(async (resolve, reject) => {
try {
if (!this.target.defaultProject || this.target.defaultProject === 'loading project...') {
this.target.defaultProject = await this.datasource.getDefaultProject();
}
resolve(this.target.defaultProject);
} catch (error) {
appEvents.emit('ds-request-error', error);
reject();
}
});
}
async loadMetricDescriptors() {
if (this.target.defaultProject !== 'loading project...') {
this.metricDescriptors = await this.datasource.getMetricTypes(this.target.defaultProject);
this.services = this.getServicesList();
this.metrics = this.getMetricsList();
return this.metricDescriptors;
} else {
return [];
}
}
getServicesList() {
const defaultValue = { value: this.$scope.defaultServiceValue, text: this.$scope.defaultServiceValue };
const services = this.metricDescriptors.map(m => {
return {
value: m.service,
text: m.serviceShortName,
};
});
if (services.find(m => m.value === this.target.service)) {
this.service = this.target.service;
}
return services.length > 0 ? [defaultValue, ..._.uniqBy(services, 'value')] : [];
}
getMetricsList() {
const metrics = this.metricDescriptors.map(m => {
return {
service: m.service,
value: m.type,
serviceShortName: m.serviceShortName,
text: m.displayName,
title: m.description,
};
});
let result;
if (this.target.service === this.$scope.defaultServiceValue) {
result = metrics.map(m => ({ ...m, text: `${m.service} - ${m.text}` }));
} else {
result = metrics.filter(m => m.service === this.target.service);
}
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;
}
return result;
}
async getLabels() {
this.loadLabelsPromise = new Promise(async resolve => {
try {
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) {
console.log(error.data.message);
} else {
console.log(error);
}
appEvents.emit('alert-error', ['Error', 'Error loading metric labels for ' + this.target.metricType]);
resolve();
}
});
}
onServiceChange() {
this.target.service = this.service;
this.metrics = this.getMetricsList();
this.setMetricType();
this.getLabels();
if (!this.metrics.find(m => m.value === this.target.metricType)) {
this.target.metricType = this.$scope.defaultDropdownValue;
} else {
this.$scope.refresh();
}
}
async onMetricTypeChange() {
this.setMetricType();
this.$scope.refresh();
this.getLabels();
}
setMetricType() {
this.target.metricType = this.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;
this.$rootScope.$broadcast('metricTypeChanged');
}
async createLabelKeyElements() {
await this.loadLabelsPromise;
await this.loading;
let elements = Object.keys(this.metricLabels || {}).map(l => {
let elements = Object.keys(this.labelData.metricLabels || {}).map(l => {
return this.uiSegmentSrv.newSegment({
value: `metric.label.${l}`,
expandable: false,
@ -208,7 +75,7 @@ export class StackdriverFilterCtrl {
elements = [
...elements,
...Object.keys(this.resourceLabels || {}).map(l => {
...Object.keys(this.labelData.resourceLabels || {}).map(l => {
return this.uiSegmentSrv.newSegment({
value: `resource.label.${l}`,
expandable: false,
@ -216,7 +83,7 @@ export class StackdriverFilterCtrl {
}),
];
if (this.resourceTypes && this.resourceTypes.length > 0) {
if (this.labelData.resourceTypes && this.labelData.resourceTypes.length > 0) {
elements = [
...elements,
this.uiSegmentSrv.newSegment({
@ -229,10 +96,10 @@ export class StackdriverFilterCtrl {
return elements;
}
async getFilterKeys(segment, removeText?: string) {
async getFilterKeys(segment, removeText: string) {
let elements = await this.createLabelKeyElements();
if (this.target.filters.indexOf(this.resourceTypeValue) !== -1) {
if (this.filters.indexOf(this.resourceTypeValue) !== -1) {
elements = elements.filter(e => e.value !== this.resourceTypeValue);
}
@ -241,21 +108,24 @@ export class StackdriverFilterCtrl {
return [];
}
this.removeSegment.value = removeText;
return [...elements, this.removeSegment];
return segment.type === 'plus-button'
? elements
: [
...elements,
this.uiSegmentSrv.newSegment({ fake: true, value: removeText || this.defaultRemoveGroupByValue }),
];
}
async getGroupBys(segment) {
let elements = await this.createLabelKeyElements();
elements = elements.filter(e => this.target.aggregation.groupBys.indexOf(e.value) === -1);
elements = elements.filter(e => this.groupBys.indexOf(e.value) === -1);
const noValueOrPlusButton = !segment || segment.type === 'plus-button';
if (noValueOrPlusButton && elements.length === 0) {
return [];
}
this.removeSegment.value = this.defaultRemoveGroupByValue;
return [...elements, this.removeSegment];
return segment.type === 'plus-button' ? elements : [...elements, this.removeSegment];
}
groupByChanged(segment, index) {
@ -272,43 +142,45 @@ export class StackdriverFilterCtrl {
return memo;
};
this.target.aggregation.groupBys = this.groupBySegments.reduce(reducer, []);
const groupBys = this.groupBySegments.reduce(reducer, []);
this.groupBysChanged({ groupBys });
this.ensurePlusButton(this.groupBySegments);
this.$rootScope.$broadcast('metricTypeChanged');
this.$scope.refresh();
}
async getFilters(segment, index) {
const hasNoFilterKeys = this.metricLabels && Object.keys(this.metricLabels).length === 0;
await this.loading;
const hasNoFilterKeys = this.labelData.metricLabels && Object.keys(this.labelData.metricLabels).length === 0;
return this.filterSegments.getFilters(segment, index, hasNoFilterKeys);
}
getFilterValues(index) {
const filterKey = this.templateSrv.replace(this.filterSegments.filterSegments[index - 2].value);
if (!filterKey || !this.metricLabels || Object.keys(this.metricLabels).length === 0) {
if (!filterKey || !this.labelData.metricLabels || Object.keys(this.labelData.metricLabels).length === 0) {
return [];
}
const shortKey = filterKey.substring(filterKey.indexOf('.label.') + 7);
if (filterKey.startsWith('metric.label.') && this.metricLabels.hasOwnProperty(shortKey)) {
return this.metricLabels[shortKey];
if (filterKey.startsWith('metric.label.') && this.labelData.metricLabels.hasOwnProperty(shortKey)) {
return this.labelData.metricLabels[shortKey];
}
if (filterKey.startsWith('resource.label.') && this.resourceLabels.hasOwnProperty(shortKey)) {
return this.resourceLabels[shortKey];
if (filterKey.startsWith('resource.label.') && this.labelData.resourceLabels.hasOwnProperty(shortKey)) {
return this.labelData.resourceLabels[shortKey];
}
if (filterKey === this.resourceTypeValue) {
return this.resourceTypes;
return this.labelData.resourceTypes;
}
return [];
}
filterSegmentUpdated(segment, index) {
this.target.filters = this.filterSegments.filterSegmentUpdated(segment, index);
this.$scope.refresh();
const filters = this.filterSegments.filterSegmentUpdated(segment, index);
if (!filters.some(f => f === DefaultFilterValue)) {
this.filtersChanged({ filters });
}
}
ensurePlusButton(segments) {

View File

@ -82,7 +82,6 @@ describe('StackdriverDataSource', () => {
targets: [
{
refId: 'A',
aggregation: {},
},
],
};

View File

@ -1,74 +0,0 @@
import { StackdriverAggregationCtrl } from '../query_aggregation_ctrl';
describe('StackdriverAggregationCtrl', () => {
let ctrl;
describe('aggregation and alignment options', () => {
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: [] },
},
},
{
replace: s => s,
}
);
});
it('should populate all aggregate options except two', () => {
ctrl.setAggOptions();
expect(ctrl.aggOptions.length).toBe(11);
expect(ctrl.aggOptions.map(o => o.value)).toEqual(
expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
);
});
it('should populate all alignment options except two', () => {
ctrl.setAlignOptions();
expect(ctrl.alignOptions.length).toBe(9);
expect(ctrl.alignOptions.map(o => o.value)).toEqual(
expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
);
});
});
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'] },
},
},
{
replace: s => s,
}
);
});
it('should populate all aggregate options except three', () => {
ctrl.setAggOptions();
expect(ctrl.aggOptions.length).toBe(10);
expect(ctrl.aggOptions.map(o => o.value)).toEqual(
expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE', 'REDUCE_NONE'])
);
});
it('should select some other reducer than REDUCE_NONE', () => {
ctrl.setAggOptions();
expect(ctrl.target.aggregation.crossSeriesReducer).not.toBe('');
expect(ctrl.target.aggregation.crossSeriesReducer).not.toBe('REDUCE_NONE');
});
});
});
});
});

View File

@ -5,6 +5,7 @@ import { DefaultRemoveFilterValue, DefaultFilterValue } from '../filter_segments
describe('StackdriverQueryFilterCtrl', () => {
let ctrl;
let result;
let groupByChangedMock;
describe('when initializing query editor', () => {
beforeEach(() => {
@ -32,10 +33,10 @@ describe('StackdriverQueryFilterCtrl', () => {
describe('when labels are fetched', () => {
beforeEach(async () => {
ctrl.metricLabels = { 'metric-key-1': ['metric-value-1'] };
ctrl.resourceLabels = { 'resource-key-1': ['resource-value-1'] };
ctrl.labelData.metricLabels = { 'metric-key-1': ['metric-value-1'] };
ctrl.labelData.resourceLabels = { 'resource-key-1': ['resource-value-1'] };
result = await ctrl.getGroupBys();
result = await ctrl.getGroupBys({ type: '' });
});
it('should populate group bys segments', () => {
@ -48,17 +49,17 @@ describe('StackdriverQueryFilterCtrl', () => {
describe('when a group by label is selected', () => {
beforeEach(async () => {
ctrl.metricLabels = {
ctrl.labelData.metricLabels = {
'metric-key-1': ['metric-value-1'],
'metric-key-2': ['metric-value-2'],
};
ctrl.resourceLabels = {
ctrl.labelData.resourceLabels = {
'resource-key-1': ['resource-value-1'],
'resource-key-2': ['resource-value-2'],
};
ctrl.target.aggregation.groupBys = ['metric.label.metric-key-1', 'resource.label.resource-key-1'];
ctrl.groupBys = ['metric.label.metric-key-1', 'resource.label.resource-key-1'];
result = await ctrl.getGroupBys();
result = await ctrl.getGroupBys({ type: '' });
});
it('should not be used to populate group bys segments', () => {
@ -71,6 +72,8 @@ describe('StackdriverQueryFilterCtrl', () => {
describe('when a group by is selected', () => {
beforeEach(() => {
groupByChangedMock = jest.fn();
ctrl.groupBysChanged = groupByChangedMock;
const removeSegment = { fake: true, value: '-- remove group by --' };
const segment = { value: 'groupby1' };
ctrl.groupBySegments = [segment, removeSegment];
@ -78,12 +81,14 @@ describe('StackdriverQueryFilterCtrl', () => {
});
it('should be added to group bys list', () => {
expect(ctrl.target.aggregation.groupBys.length).toBe(1);
expect(groupByChangedMock).toHaveBeenCalledWith({ groupBys: ['groupby1'] });
});
});
describe('when a selected group by is removed', () => {
beforeEach(() => {
groupByChangedMock = jest.fn();
ctrl.groupBysChanged = groupByChangedMock;
const removeSegment = { fake: true, value: '-- remove group by --' };
const segment = { value: 'groupby1' };
ctrl.groupBySegments = [segment, removeSegment];
@ -91,7 +96,7 @@ describe('StackdriverQueryFilterCtrl', () => {
});
it('should be added to group bys list', () => {
expect(ctrl.target.aggregation.groupBys.length).toBe(0);
expect(groupByChangedMock).toHaveBeenCalledWith({ groupBys: [] });
});
});
});
@ -130,11 +135,11 @@ describe('StackdriverQueryFilterCtrl', () => {
describe('when values for a key filter part are fetched', () => {
beforeEach(async () => {
ctrl.metricLabels = {
ctrl.labelData.metricLabels = {
'metric-key-1': ['metric-value-1'],
'metric-key-2': ['metric-value-2'],
};
ctrl.resourceLabels = {
ctrl.labelData.resourceLabels = {
'resource-key-1': ['resource-value-1'],
'resource-key-2': ['resource-value-2'],
};
@ -155,11 +160,11 @@ describe('StackdriverQueryFilterCtrl', () => {
describe('when values for a value filter part are fetched', () => {
beforeEach(async () => {
ctrl.metricLabels = {
ctrl.labelData.metricLabels = {
'metric-key-1': ['metric-value-1'],
'metric-key-2': ['metric-value-2'],
};
ctrl.resourceLabels = {
ctrl.labelData.resourceLabels = {
'resource-key-1': ['resource-value-1'],
'resource-key-2': ['resource-value-2'],
};
@ -198,6 +203,7 @@ describe('StackdriverQueryFilterCtrl', () => {
});
});
});
describe('when has one existing filter', () => {
describe('and user clicks on key segment', () => {
beforeEach(() => {
@ -213,7 +219,6 @@ describe('StackdriverQueryFilterCtrl', () => {
];
ctrl.filterSegmentUpdated(existingKeySegment, 0);
});
it('should not add any new segments', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(4);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@ -229,7 +234,6 @@ describe('StackdriverQueryFilterCtrl', () => {
ctrl.filterSegments.filterSegments = [existingKeySegment, existingOperatorSegment, existingValueSegment];
ctrl.filterSegmentUpdated(existingValueSegment, 2);
});
it('should ensure that plus segment exists', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(4);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@ -238,7 +242,6 @@ describe('StackdriverQueryFilterCtrl', () => {
expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
});
});
describe('and user clicks on value segment and value is equal to fake value', () => {
beforeEach(() => {
const existingKeySegment = { value: 'filterkey1', type: 'key' };
@ -247,7 +250,6 @@ describe('StackdriverQueryFilterCtrl', () => {
ctrl.filterSegments.filterSegments = [existingKeySegment, existingOperatorSegment, existingValueSegment];
ctrl.filterSegmentUpdated(existingValueSegment, 2);
});
it('should not add plus segment', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(3);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@ -269,13 +271,11 @@ describe('StackdriverQueryFilterCtrl', () => {
];
ctrl.filterSegmentUpdated(existingKeySegment, 0);
});
it('should remove filter segments', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(1);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('plus-button');
});
});
describe('and user removes key segment and there is a previous filter', () => {
beforeEach(() => {
const existingKeySegment1 = { value: DefaultRemoveFilterValue, type: 'key' };
@ -296,7 +296,6 @@ describe('StackdriverQueryFilterCtrl', () => {
];
ctrl.filterSegmentUpdated(existingKeySegment2, 4);
});
it('should remove filter segments and the condition segment', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(4);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@ -305,7 +304,6 @@ describe('StackdriverQueryFilterCtrl', () => {
expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
});
});
describe('and user removes key segment and there is a filter after it', () => {
beforeEach(() => {
const existingKeySegment1 = { value: DefaultRemoveFilterValue, type: 'key' };
@ -326,7 +324,6 @@ describe('StackdriverQueryFilterCtrl', () => {
];
ctrl.filterSegmentUpdated(existingKeySegment1, 0);
});
it('should remove filter segments and the condition segment', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(4);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@ -335,7 +332,6 @@ describe('StackdriverQueryFilterCtrl', () => {
expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
});
});
describe('and user clicks on plus button', () => {
beforeEach(() => {
const existingKeySegment = { value: 'filterkey1', type: 'key' };
@ -350,7 +346,6 @@ describe('StackdriverQueryFilterCtrl', () => {
];
ctrl.filterSegmentUpdated(plusSegment, 3);
});
it('should condition segment and new filter segments', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(7);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@ -367,13 +362,6 @@ describe('StackdriverQueryFilterCtrl', () => {
});
function createCtrlWithFakes(existingFilters?: string[]) {
StackdriverFilterCtrl.prototype.loadMetricDescriptors = () => {
return Promise.resolve([]);
};
StackdriverFilterCtrl.prototype.getLabels = () => {
return Promise.resolve();
};
const fakeSegmentServer = {
newKey: val => {
return { value: val, type: 'key' };
@ -403,40 +391,24 @@ function createCtrlWithFakes(existingFilters?: string[]) {
},
};
const scope = {
target: createTarget(existingFilters),
hideGroupBys: false,
groupBys: [],
filters: existingFilters || [],
labelData: {
metricLabels: {},
resourceLabels: {},
resourceTypes: [],
},
filtersChanged: () => {},
groupBysChanged: () => {},
datasource: {
getDefaultProject: () => {
return 'project';
},
},
defaultDropdownValue: 'Select Metric',
defaultServiceValue: 'All Services',
refresh: () => {},
};
return new StackdriverFilterCtrl(scope, fakeSegmentServer, new TemplateSrvStub(), { $broadcast: param => {} });
}
function createTarget(existingFilters?: string[]) {
return {
project: {
id: '',
name: '',
},
unit: '',
metricType: 'ametric',
service: '',
refId: 'A',
aggregation: {
crossSeriesReducer: '',
alignmentPeriod: '',
perSeriesAligner: '',
groupBys: [],
},
filters: existingFilters || [],
aliasBy: '',
metricService: '',
metricKind: '',
valueType: '',
};
Object.assign(StackdriverFilterCtrl.prototype, scope);
return new StackdriverFilterCtrl(scope, fakeSegmentServer, new TemplateSrvStub());
}

View File

@ -19,3 +19,50 @@ export interface VariableQueryData {
metricTypes: Array<{ value: string; name: string }>;
services: Array<{ value: string; name: string }>;
}
export interface Target {
defaultProject: string;
unit: string;
metricType: string;
service: string;
refId: string;
crossSeriesReducer: string;
alignmentPeriod: string;
perSeriesAligner: string;
groupBys: string[];
filters: string[];
aliasBy: string;
metricKind: string;
valueType: string;
}
export interface AnnotationTarget {
defaultProject: string;
metricType: string;
refId: string;
filters: string[];
metricKind: string;
valueType: string;
title: string;
text: string;
}
export interface QueryMeta {
alignmentPeriod: string;
rawQuery: string;
rawQueryString: string;
metricLabels: { [key: string]: string[] };
resourceLabels: { [key: string]: string[] };
resourceTypes: string[];
}
export interface MetricDescriptor {
valueType: string;
metricKind: string;
type: string;
unit: string;
service: string;
serviceShortName: string;
displayName: string;
description: string;
}

View File

@ -0,0 +1,5 @@
export interface Variable {
name: string;
type: string;
current: any;
}

4095
yarn.lock

File diff suppressed because it is too large Load Diff