CloudWatch: Datasource improvements (#20268)

* CloudWatch: Datasource improvements

* Add statistic as template variale

* Add wildcard to list of values

* Template variable intercept dimension key

* Return row specific errors when transformation error occured

* Add meta feedback

* Make it possible to retrieve values without known metrics

* Add curated dashboard for EC2

* Fix broken tests

* Use correct dashboard name

* Display alert in case multi template var is being used for some certain props in the cloudwatch query

* Minor fixes after feedback

* Update dashboard json

* Update snapshot test

* Make sure region default is intercepted in cloudwatch link

* Update dashboards

* Include ec2 dashboard in ds

* Do not include ec2 dashboard in beta1

* Display actual region
This commit is contained in:
Erik Sundell
2019-11-14 10:59:41 +01:00
committed by GitHub
parent 1f018adbf3
commit 00bef917ee
62 changed files with 7552 additions and 1537 deletions

View File

@@ -26,7 +26,7 @@ export default class AppNotificationItem extends Component<Props> {
<Alert
severity={appNotification.severity}
title={appNotification.title}
children={appNotification.text}
children={appNotification.component || appNotification.text}
onRemove={() => onClearNotification(appNotification.id)}
/>
);

View File

@@ -32,12 +32,13 @@ export const createSuccessNotification = (title: string, text = ''): AppNotifica
id: Date.now(),
});
export const createErrorNotification = (title: string, text = ''): AppNotification => {
export const createErrorNotification = (title: string, text = '', component?: React.ReactElement): AppNotification => {
return {
...defaultErrorNotification,
title: title,
text: getMessageFromError(text),
title,
id: Date.now(),
component,
};
};

View File

@@ -0,0 +1,10 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { Alias } from './Alias';
describe('Alias', () => {
it('should render component', () => {
const tree = renderer.create(<Alias value={'legend'} onChange={() => {}} />).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,21 @@
import React, { FunctionComponent, useState } from 'react';
import { debounce } from 'lodash';
import { Input } from '@grafana/ui';
export interface Props {
onChange: (alias: any) => void;
value: string;
}
export const Alias: FunctionComponent<Props> = ({ value = '', onChange }) => {
const [alias, setAlias] = useState(value);
const propagateOnChange = debounce(onChange, 1500);
onChange = (e: any) => {
setAlias(e.target.value);
propagateOnChange(e.target.value);
};
return <Input type="text" className="gf-form-input width-16" value={alias} onChange={onChange} />;
};

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { shallow } from 'enzyme';
import ConfigEditor, { Props } from './ConfigEditor';
jest.mock('app/features/plugins/datasource_srv', () => ({
getDatasourceSrv: () => ({
loadDatasource: jest.fn().mockImplementation(() =>
Promise.resolve({
getRegions: jest.fn().mockReturnValue([
{
label: 'ap-east-1',
value: 'ap-east-1',
},
]),
})
),
}),
}));
const setup = (propOverrides?: object) => {
const props: Props = {
options: {
id: 1,
orgId: 1,
typeLogoUrl: '',
name: 'CloudWatch',
access: 'proxy',
url: '',
database: '',
type: 'cloudwatch',
user: '',
password: '',
basicAuth: false,
basicAuthPassword: '',
basicAuthUser: '',
isDefault: true,
readOnly: false,
withCredentials: false,
secureJsonFields: {
accessKey: false,
secretKey: false,
},
jsonData: {
assumeRoleArn: '',
database: '',
customMetricsNamespaces: '',
authType: 'keys',
defaultRegion: 'us-east-2',
timeField: '@timestamp',
},
secureJsonData: {
secretKey: '',
accessKey: '',
},
},
onOptionsChange: jest.fn(),
};
Object.assign(props, propOverrides);
return shallow(<ConfigEditor {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should disable access key id field', () => {
const wrapper = setup({
secureJsonFields: {
secretKey: true,
},
});
expect(wrapper).toMatchSnapshot();
});
it('should should show credentials profile name field', () => {
const wrapper = setup({
jsonData: {
authType: 'credentials',
},
});
expect(wrapper).toMatchSnapshot();
});
it('should should show access key and secret access key fields', () => {
const wrapper = setup({
jsonData: {
authType: 'keys',
},
});
expect(wrapper).toMatchSnapshot();
});
it('should should show arn role field', () => {
const wrapper = setup({
jsonData: {
authType: 'arn',
},
});
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,390 @@
import React, { PureComponent, ChangeEvent } from 'react';
import { FormLabel, Select, Input, Button } from '@grafana/ui';
import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data';
import { SelectableValue } from '@grafana/data';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import CloudWatchDatasource from '../datasource';
import { CloudWatchJsonData, CloudWatchSecureJsonData } from '../types';
export type Props = DataSourcePluginOptionsEditorProps<CloudWatchJsonData>;
type CloudwatchSettings = DataSourceSettings<CloudWatchJsonData, CloudWatchSecureJsonData>;
export interface State {
config: CloudwatchSettings;
authProviderOptions: SelectableValue[];
regions: SelectableValue[];
}
export class ConfigEditor extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
const { options } = this.props;
this.state = {
config: ConfigEditor.defaults(options),
authProviderOptions: [
{ label: 'Access & secret key', value: 'keys' },
{ label: 'Credentials file', value: 'credentials' },
{ label: 'ARN', value: 'arn' },
],
regions: [],
};
this.updateDatasource(this.state.config);
}
static getDerivedStateFromProps(props: Props, state: State) {
return {
...state,
config: ConfigEditor.defaults(props.options),
};
}
static defaults = (options: any) => {
options.jsonData.authType = options.jsonData.authType || 'credentials';
options.jsonData.timeField = options.jsonData.timeField || '@timestamp';
if (!options.hasOwnProperty('secureJsonData')) {
options.secureJsonData = {};
}
if (!options.hasOwnProperty('jsonData')) {
options.jsonData = {};
}
if (!options.hasOwnProperty('secureJsonFields')) {
options.secureJsonFields = {};
}
return options;
};
async componentDidMount() {
this.loadRegions();
}
loadRegions() {
getDatasourceSrv()
.loadDatasource(this.state.config.name)
.then((ds: CloudWatchDatasource) => {
return ds.getRegions();
})
.then(
(regions: any) => {
this.setState({
regions: regions.map((region: any) => {
return {
value: region.value,
label: region.text,
};
}),
});
},
(err: any) => {
const regions = [
'ap-east-1',
'ap-northeast-1',
'ap-northeast-2',
'ap-northeast-3',
'ap-south-1',
'ap-southeast-1',
'ap-southeast-2',
'ca-central-1',
'cn-north-1',
'cn-northwest-1',
'eu-central-1',
'eu-north-1',
'eu-west-1',
'eu-west-2',
'eu-west-3',
'me-south-1',
'sa-east-1',
'us-east-1',
'us-east-2',
'us-gov-east-1',
'us-gov-west-1',
'us-iso-east-1',
'us-isob-east-1',
'us-west-1',
'us-west-2',
];
this.setState({
regions: regions.map((region: string) => {
return {
value: region,
label: region,
};
}),
});
// expected to fail when creating new datasource
// console.error('failed to get latest regions', err);
}
);
}
updateDatasource = async (config: any) => {
for (const j in config.jsonData) {
if (config.jsonData[j].length === 0) {
delete config.jsonData[j];
}
}
for (const k in config.secureJsonData) {
if (config.secureJsonData[k].length === 0) {
delete config.secureJsonData[k];
}
}
this.props.onOptionsChange({
...config,
});
};
onAuthProviderChange = (authType: SelectableValue<string>) => {
this.updateDatasource({
...this.state.config,
jsonData: {
...this.state.config.jsonData,
authType: authType.value,
},
});
};
onRegionChange = (defaultRegion: SelectableValue<string>) => {
this.updateDatasource({
...this.state.config,
jsonData: {
...this.state.config.jsonData,
defaultRegion: defaultRegion.value,
},
});
};
onResetAccessKey = () => {
this.updateDatasource({
...this.state.config,
secureJsonFields: {
...this.state.config.secureJsonFields,
accessKey: false,
},
});
};
onAccessKeyChange = (accessKey: string) => {
this.updateDatasource({
...this.state.config,
secureJsonData: {
...this.state.config.secureJsonData,
accessKey,
},
});
};
onResetSecretKey = () => {
this.updateDatasource({
...this.state.config,
secureJsonFields: {
...this.state.config.secureJsonFields,
secretKey: false,
},
});
};
onSecretKeyChange = (secretKey: string) => {
this.updateDatasource({
...this.state.config,
secureJsonData: {
...this.state.config.secureJsonData,
secretKey,
},
});
};
onCredentialProfileNameChange = (database: string) => {
this.updateDatasource({
...this.state.config,
database,
});
};
onArnAssumeRoleChange = (assumeRoleArn: string) => {
this.updateDatasource({
...this.state.config,
jsonData: {
...this.state.config.jsonData,
assumeRoleArn,
},
});
};
onCustomMetricsNamespacesChange = (customMetricsNamespaces: string) => {
this.updateDatasource({
...this.state.config,
jsonData: {
...this.state.config.jsonData,
customMetricsNamespaces,
},
});
};
render() {
const { config, authProviderOptions, regions } = this.state;
return (
<>
<h3 className="page-heading">CloudWatch Details</h3>
<div className="gf-form-group">
<div className="gf-form-inline">
<div className="gf-form">
<FormLabel className="width-14">Auth Provider</FormLabel>
<Select
className="width-30"
value={authProviderOptions.find(authProvider => authProvider.value === config.jsonData.authType)}
options={authProviderOptions}
defaultValue={config.jsonData.authType}
onChange={this.onAuthProviderChange}
/>
</div>
</div>
{config.jsonData.authType === 'credentials' && (
<div className="gf-form-inline">
<div className="gf-form">
<FormLabel
className="width-14"
tooltip="Credentials profile name, as specified in ~/.aws/credentials, leave blank for default."
>
Credentials Profile Name
</FormLabel>
<div className="width-30">
<Input
className="width-30"
placeholder="default"
value={config.jsonData.database}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onCredentialProfileNameChange(event.target.value)
}
/>
</div>
</div>
</div>
)}
{config.jsonData.authType === 'keys' && (
<div>
{config.secureJsonFields.accessKey ? (
<div className="gf-form-inline">
<div className="gf-form">
<FormLabel className="width-14">Access Key ID</FormLabel>
<Input className="width-25" placeholder="Configured" disabled={true} />
</div>
<div className="gf-form">
<div className="max-width-30 gf-form-inline">
<Button variant="secondary" type="button" onClick={this.onResetAccessKey}>
Reset
</Button>
</div>
</div>
</div>
) : (
<div className="gf-form-inline">
<div className="gf-form">
<FormLabel className="width-14">Access Key ID</FormLabel>
<div className="width-30">
<Input
className="width-30"
value={config.secureJsonData.accessKey || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => this.onAccessKeyChange(event.target.value)}
/>
</div>
</div>
</div>
)}
{config.secureJsonFields.secretKey ? (
<div className="gf-form-inline">
<div className="gf-form">
<FormLabel className="width-14">Secret Access Key</FormLabel>
<Input className="width-25" placeholder="Configured" disabled={true} />
</div>
<div className="gf-form">
<div className="max-width-30 gf-form-inline">
<Button variant="secondary" type="button" onClick={this.onResetSecretKey}>
Reset
</Button>
</div>
</div>
</div>
) : (
<div className="gf-form-inline">
<div className="gf-form">
<FormLabel className="width-14">Secret Access Key</FormLabel>
<div className="width-30">
<Input
className="width-30"
value={config.secureJsonData.secretKey || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => this.onSecretKeyChange(event.target.value)}
/>
</div>
</div>
</div>
)}
</div>
)}
{config.jsonData.authType === 'arn' && (
<div className="gf-form-inline">
<div className="gf-form">
<FormLabel className="width-14" tooltip="ARN of Assume Role">
Assume Role ARN
</FormLabel>
<div className="width-30">
<Input
className="width-30"
placeholder="arn:aws:iam:*"
value={config.jsonData.assumeRoleArn || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => this.onArnAssumeRoleChange(event.target.value)}
/>
</div>
</div>
</div>
)}
<div className="gf-form-inline">
<div className="gf-form">
<FormLabel
className="width-14"
tooltip="Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region."
>
Default Region
</FormLabel>
<Select
className="width-30"
value={regions.find(region => region.value === config.jsonData.defaultRegion)}
options={regions}
defaultValue={config.jsonData.defaultRegion}
onChange={this.onRegionChange}
/>
</div>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<FormLabel className="width-14" tooltip="Namespaces of Custom Metrics.">
Custom Metrics
</FormLabel>
<Input
className="width-30"
placeholder="Namespace1,Namespace2"
value={config.jsonData.customMetricsNamespaces || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onCustomMetricsNamespacesChange(event.target.value)
}
/>
</div>
</div>
</div>
</>
);
}
}
export default ConfigEditor;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import { Dimensions } from './';
import { SelectableStrings } from '../types';
describe('Dimensions', () => {
it('renders', () => {
mount(
<Dimensions
dimensions={{}}
onChange={dimensions => console.log(dimensions)}
loadKeys={() => Promise.resolve<SelectableStrings>([])}
loadValues={() => Promise.resolve<SelectableStrings>([])}
/>
);
});
describe('and no dimension were passed to the component', () => {
it('initially displays just an add button', () => {
const wrapper = shallow(
<Dimensions
dimensions={{}}
onChange={() => {}}
loadKeys={() => Promise.resolve<SelectableStrings>([])}
loadValues={() => Promise.resolve<SelectableStrings>([])}
/>
);
expect(wrapper.html()).toEqual(
`<div class="gf-form"><a class="gf-form-label query-part"><i class="fa fa-plus"></i></a></div>`
);
});
});
describe('and one dimension key along with a value were passed to the component', () => {
it('initially displays the dimension key, value and an add button', () => {
const wrapper = shallow(
<Dimensions
dimensions={{ somekey: 'somevalue' }}
onChange={() => {}}
loadKeys={() => Promise.resolve<SelectableStrings>([])}
loadValues={() => Promise.resolve<SelectableStrings>([])}
/>
);
expect(wrapper.html()).toEqual(
`<div class="gf-form"><a class="gf-form-label query-part">somekey</a></div><label class="gf-form-label query-segment-operator">=</label><div class="gf-form"><a class="gf-form-label query-part">somevalue</a></div><div class="gf-form"><a class="gf-form-label query-part"><i class="fa fa-plus"></i></a></div>`
);
});
});
});

View File

@@ -0,0 +1,80 @@
import React, { FunctionComponent, Fragment, useState, useEffect } from 'react';
import isEqual from 'lodash/isEqual';
import { SelectableValue } from '@grafana/data';
import { SegmentAsync } from '@grafana/ui';
import { SelectableStrings } from '../types';
export interface Props {
dimensions: { [key: string]: string | string[] };
onChange: (dimensions: { [key: string]: string }) => void;
loadValues: (key: string) => Promise<SelectableStrings>;
loadKeys: () => Promise<SelectableStrings>;
}
const removeText = '-- remove dimension --';
const removeOption: SelectableValue<string> = { label: removeText, value: removeText };
// The idea of this component is that is should only trigger the onChange event in the case
// there is a complete dimension object. E.g, when a new key is added is doesn't have a value.
// That should not trigger onChange.
export const Dimensions: FunctionComponent<Props> = ({ dimensions, loadValues, loadKeys, onChange }) => {
const [data, setData] = useState(dimensions);
useEffect(() => {
const completeDimensions = Object.entries(data).reduce(
(res, [key, value]) => (value ? { ...res, [key]: value } : res),
{}
);
if (!isEqual(completeDimensions, dimensions)) {
onChange(completeDimensions);
}
}, [data]);
const excludeUsedKeys = (options: SelectableStrings) => {
return options.filter(({ value }) => !Object.keys(data).includes(value));
};
return (
<>
{Object.entries(data).map(([key, value], index) => (
<Fragment key={index}>
<SegmentAsync
allowCustomValue
value={key}
loadOptions={() => loadKeys().then(keys => [removeOption, ...excludeUsedKeys(keys)])}
onChange={newKey => {
const { [key]: value, ...newDimensions } = data;
if (newKey === removeText) {
setData({ ...newDimensions });
} else {
setData({ ...newDimensions, [newKey]: '' });
}
}}
/>
<label className="gf-form-label query-segment-operator">=</label>
<SegmentAsync
allowCustomValue
value={value || 'select dimension value'}
loadOptions={() => loadValues(key)}
onChange={newValue => setData({ ...data, [key]: newValue })}
/>
{Object.values(data).length > 1 && index + 1 !== Object.values(data).length && (
<label className="gf-form-label query-keyword">AND</label>
)}
</Fragment>
))}
{Object.values(data).every(v => v) && (
<SegmentAsync
allowCustomValue
Component={
<a className="gf-form-label query-part">
<i className="fa fa-plus" />
</a>
}
loadOptions={() => loadKeys().then(excludeUsedKeys)}
onChange={(newKey: string) => setData({ ...data, [newKey]: '' })}
/>
)}
</>
);
};

View File

@@ -0,0 +1,28 @@
import React, { InputHTMLAttributes, FunctionComponent } from 'react';
import { FormLabel } from '@grafana/ui';
export interface Props extends InputHTMLAttributes<HTMLInputElement> {
label: string;
tooltip?: string;
children?: React.ReactNode;
}
export const QueryField: FunctionComponent<Partial<Props>> = ({ label, tooltip, children }) => (
<>
<FormLabel width={8} className="query-keyword" tooltip={tooltip}>
{label}
</FormLabel>
{children}
</>
);
export const QueryInlineField: FunctionComponent<Props> = ({ ...props }) => {
return (
<div className={'gf-form-inline'}>
<QueryField {...props} />
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
);
};

View File

@@ -0,0 +1,102 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { mount } from 'enzyme';
import { DataSourceInstanceSettings } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { CustomVariable } from 'app/features/templating/all';
import { QueryEditor, Props } from './QueryEditor';
import CloudWatchDatasource from '../datasource';
const setup = () => {
const instanceSettings = {
jsonData: { defaultRegion: 'us-east-1' },
} as DataSourceInstanceSettings;
const templateSrv = new TemplateSrv();
templateSrv.init([
new CustomVariable(
{
name: 'var3',
options: [
{ selected: true, value: 'var3-foo' },
{ selected: false, value: 'var3-bar' },
{ selected: true, value: 'var3-baz' },
],
current: {
value: ['var3-foo', 'var3-baz'],
},
multi: true,
},
{} as any
),
]);
const datasource = new CloudWatchDatasource(instanceSettings, {} as any, {} as any, templateSrv as any, {} as any);
datasource.metricFindQuery = async param => [{ value: 'test', label: 'test' }];
const props: Props = {
query: {
refId: '',
id: '',
region: 'us-east-1',
namespace: 'ec2',
metricName: 'CPUUtilization',
dimensions: { somekey: 'somevalue' },
statistics: new Array<string>(),
period: '',
expression: '',
alias: '',
highResolution: false,
matchExact: true,
},
datasource,
onChange: jest.fn(),
onRunQuery: jest.fn(),
};
return props;
};
describe('QueryEditor', () => {
it('should render component', () => {
const props = setup();
const tree = renderer.create(<QueryEditor {...props} />).toJSON();
expect(tree).toMatchSnapshot();
});
describe('should use correct default values', () => {
it('when region is null is display default in the label', () => {
const props = setup();
props.query.region = null;
const wrapper = mount(<QueryEditor {...props} />);
expect(
wrapper
.find('.gf-form-inline')
.first()
.find('.gf-form-label.query-part')
.first()
.text()
).toEqual('default');
});
it('should init props correctly', () => {
const props = setup();
props.query.namespace = null;
props.query.metricName = null;
props.query.expression = null;
props.query.dimensions = null;
props.query.region = null;
props.query.statistics = null;
const wrapper = mount(<QueryEditor {...props} />);
const {
query: { namespace, region, metricName, dimensions, statistics, expression },
} = wrapper.props();
expect(namespace).toEqual('');
expect(metricName).toEqual('');
expect(expression).toEqual('');
expect(region).toEqual('default');
expect(statistics).toEqual(['Average']);
expect(dimensions).toEqual({});
});
});
});

View File

@@ -0,0 +1,277 @@
import React, { PureComponent, ChangeEvent } from 'react';
import { SelectableValue, QueryEditorProps } from '@grafana/data';
import { Input, Segment, SegmentAsync, ValidationEvents, EventsWithValidation, Switch } from '@grafana/ui';
import { CloudWatchQuery } from '../types';
import CloudWatchDatasource from '../datasource';
import { SelectableStrings } from '../types';
import { Stats, Dimensions, QueryInlineField, QueryField, Alias } from './';
export type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery>;
interface State {
regions: SelectableStrings;
namespaces: SelectableStrings;
metricNames: SelectableStrings;
variableOptionGroup: SelectableValue<string>;
showMeta: boolean;
}
const idValidationEvents: ValidationEvents = {
[EventsWithValidation.onBlur]: [
{
rule: value => new RegExp(/^$|^[a-z][a-zA-Z0-9_]*$/).test(value),
errorMessage: 'Invalid format. Only alphanumeric characters and underscores are allowed',
},
],
};
export class QueryEditor extends PureComponent<Props, State> {
state: State = { regions: [], namespaces: [], metricNames: [], variableOptionGroup: {}, showMeta: false };
componentWillMount() {
const { query } = this.props;
if (!query.namespace) {
query.namespace = '';
}
if (!query.metricName) {
query.metricName = '';
}
if (!query.expression) {
query.expression = '';
}
if (!query.dimensions) {
query.dimensions = {};
}
if (!query.region) {
query.region = 'default';
}
if (!query.statistics || !query.statistics.length) {
query.statistics = ['Average'];
}
if (!query.hasOwnProperty('highResolution')) {
query.highResolution = false;
}
if (!query.hasOwnProperty('matchExact')) {
query.matchExact = true;
}
}
componentDidMount() {
const { datasource } = this.props;
const variableOptionGroup = {
label: 'Template Variables',
options: this.props.datasource.variables.map(this.toOption),
};
Promise.all([datasource.metricFindQuery('regions()'), datasource.metricFindQuery('namespaces()')]).then(
([regions, namespaces]) => {
this.setState({
...this.state,
regions: [...regions, variableOptionGroup],
namespaces: [...namespaces, variableOptionGroup],
variableOptionGroup,
});
}
);
}
loadMetricNames = async () => {
const { namespace, region } = this.props.query;
return this.props.datasource.metricFindQuery(`metrics(${namespace},${region})`).then(this.appendTemplateVariables);
};
appendTemplateVariables = (values: SelectableValue[]) => [
...values,
{ label: 'Template Variables', options: this.props.datasource.variables.map(this.toOption) },
];
toOption = (value: any) => ({ label: value, value });
onChange(query: CloudWatchQuery) {
const { onChange, onRunQuery } = this.props;
onChange(query);
onRunQuery();
}
render() {
const { query, datasource, onChange, onRunQuery, data } = this.props;
const { regions, namespaces, variableOptionGroup: variableOptionGroup, showMeta } = this.state;
const metaDataExist = data && Object.values(data).length && data.state === 'Done';
return (
<>
<QueryInlineField label="Region">
<Segment
value={query.region || 'Select region'}
options={regions}
allowCustomValue
onChange={region => this.onChange({ ...query, region })}
/>
</QueryInlineField>
{query.expression.length === 0 && (
<>
<QueryInlineField label="Namespace">
<Segment
value={query.namespace || 'Select namespace'}
allowCustomValue
options={namespaces}
onChange={namespace => this.onChange({ ...query, namespace })}
/>
</QueryInlineField>
<QueryInlineField label="Metric Name">
<SegmentAsync
value={query.metricName || 'Select metric name'}
allowCustomValue
loadOptions={this.loadMetricNames}
onChange={metricName => this.onChange({ ...query, metricName })}
/>
</QueryInlineField>
<QueryInlineField label="Stats">
<Stats
stats={datasource.standardStatistics.map(this.toOption)}
values={query.statistics}
onChange={statistics => this.onChange({ ...query, statistics })}
variableOptionGroup={variableOptionGroup}
/>
</QueryInlineField>
<QueryInlineField label="Dimensions">
<Dimensions
dimensions={query.dimensions}
onChange={dimensions => this.onChange({ ...query, dimensions })}
loadKeys={() =>
datasource.getDimensionKeys(query.namespace, query.region).then(this.appendTemplateVariables)
}
loadValues={newKey => {
const { [newKey]: value, ...newDimensions } = query.dimensions;
return datasource
.getDimensionValues(query.region, query.namespace, query.metricName, newKey, newDimensions)
.then(this.appendTemplateVariables);
}}
/>
</QueryInlineField>
</>
)}
{query.statistics.length <= 1 && (
<div className="gf-form-inline">
<div className="gf-form">
<QueryField
className="query-keyword"
label="Id"
tooltip="Id can include numbers, letters, and underscore, and must start with a lowercase letter."
>
<Input
className="gf-form-input width-8"
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange({ ...query, id: event.target.value })}
validationEvents={idValidationEvents}
value={query.id || ''}
/>
</QueryField>
</div>
<div className="gf-form gf-form--grow">
<QueryField
className="gf-form--grow"
label="Expression"
tooltip="Optionally you can add an expression here. Please note that if a math expression that is referencing other queries is being used, it will not be possible to create an alert rule based on this query"
>
<Input
className="gf-form-input"
onBlur={onRunQuery}
value={query.expression || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange({ ...query, expression: event.target.value })
}
/>
</QueryField>
</div>
</div>
)}
<div className="gf-form-inline">
<div className="gf-form">
<QueryField
className="query-keyword"
label="Min Period"
tooltip="Minimum interval between points in seconds"
>
<Input
className="gf-form-input width-8"
value={query.period || ''}
placeholder="auto"
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange({ ...query, period: event.target.value })}
/>
</QueryField>
</div>
<div className="gf-form">
<QueryField
className="query-keyword"
label="Alias"
tooltip="Alias replacement variables: {{metric}}, {{stat}}, {{namespace}}, {{region}}, {{period}}, {{label}}, {{YOUR_DIMENSION_NAME}}"
>
<Alias value={query.alias} onChange={(value: string) => this.onChange({ ...query, alias: value })} />
</QueryField>
<Switch
label="HighRes"
labelClass="query-keyword"
checked={query.highResolution}
onChange={() => this.onChange({ ...query, highResolution: !query.highResolution })}
/>
<Switch
label="Match Exact"
labelClass="query-keyword"
tooltip="Only show metrics that exactly match all defined dimension names."
checked={query.matchExact}
onChange={() => this.onChange({ ...query, matchExact: !query.matchExact })}
/>
<label className="gf-form-label">
<a
onClick={() =>
metaDataExist &&
this.setState({
...this.state,
showMeta: !showMeta,
})
}
>
<i className={`fa fa-caret-${showMeta ? 'down' : 'right'}`} /> {showMeta ? 'Hide' : 'Show'} Query
Preview
</a>
</label>
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
{showMeta && metaDataExist && (
<table className="filter-table form-inline">
<thead>
<tr>
<th>Metric Data Query ID</th>
<th>Metric Data Query Expression</th>
<th />
</tr>
</thead>
<tbody>
{data.series[0].meta.gmdMeta.map(({ ID, Expression }: any) => (
<tr key={ID}>
<td>{ID}</td>
<td>{Expression}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
);
}
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { Stats } from './Stats';
const toOption = (value: any) => ({ label: value, value });
describe('Stats', () => {
it('should render component', () => {
const tree = renderer
.create(
<Stats
values={['Average', 'Minimum']}
variableOptionGroup={{ label: 'templateVar', value: 'templateVar' }}
onChange={() => {}}
stats={['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount'].map(toOption)}
/>
)
.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,47 @@
import React, { FunctionComponent } from 'react';
import { SelectableStrings } from '../types';
import { SelectableValue } from '@grafana/data';
import { Segment } from '@grafana/ui';
export interface Props {
values: string[];
onChange: (values: string[]) => void;
variableOptionGroup: SelectableValue<string>;
stats: SelectableStrings;
}
const removeText = '-- remove stat --';
const removeOption: SelectableValue<string> = { label: removeText, value: removeText };
export const Stats: FunctionComponent<Props> = ({ stats, values, onChange, variableOptionGroup }) => (
<>
{values &&
values.map((value, index) => (
<Segment
allowCustomValue
key={value + index}
value={value}
options={[removeOption, ...stats, variableOptionGroup]}
onChange={value =>
onChange(
value === removeText
? values.filter((_, i) => i !== index)
: values.map((v, i) => (i === index ? value : v))
)
}
/>
))}
{values.length !== stats.length && (
<Segment
Component={
<a className="gf-form-label query-part">
<i className="fa fa-plus" />
</a>
}
allowCustomValue
onChange={(value: string) => onChange([...values, value])}
options={[...stats.filter(({ value }) => !values.includes(value)), variableOptionGroup]}
/>
)}
</>
);

View File

@@ -0,0 +1,27 @@
import React, { FunctionComponent } from 'react';
export interface Props {
region: string;
}
export const ThrottlingErrorMessage: FunctionComponent<Props> = ({ region }) => (
<p>
Please visit the&nbsp;
<a
target="_blank"
className="text-link"
href={`https://${region}.console.aws.amazon.com/servicequotas/home?region=${region}#!/services/monitoring/quotas/L-5E141212`}
>
AWS Service Quotas console
</a>
&nbsp;to request a quota increase or see our&nbsp;
<a
target="_blank"
className="text-link"
href={`https://grafana.com/docs/features/datasources/cloudwatch/#service-quotas`}
>
documentation
</a>
&nbsp;to learn more.
</p>
);

View File

@@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Alias should render component 1`] = `
<div
style={
Object {
"flexGrow": 1,
}
}
>
<input
className="gf-form-input gf-form-input width-16"
onChange={[Function]}
type="text"
value="legend"
/>
</div>
`;

View File

@@ -0,0 +1,901 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should disable access key id field 1`] = `
<Fragment>
<h3
className="page-heading"
>
CloudWatch Details
</h3>
<div
className="gf-form-group"
>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Auth Provider
</Component>
<Select
allowCustomValue={false}
autoFocus={false}
backspaceRemovesValue={true}
className="width-30"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
defaultValue="keys"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
Object {
"label": "Access & secret key",
"value": "keys",
},
Object {
"label": "Credentials file",
"value": "credentials",
},
Object {
"label": "ARN",
"value": "arn",
},
]
}
tabSelectsValue={true}
value={
Object {
"label": "Access & secret key",
"value": "keys",
}
}
/>
</div>
</div>
<div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Access Key ID
</Component>
<div
className="width-30"
>
<Input
className="width-30"
onChange={[Function]}
value=""
/>
</div>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Secret Access Key
</Component>
<div
className="width-30"
>
<Input
className="width-30"
onChange={[Function]}
value=""
/>
</div>
</div>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
tooltip="Specify the region, such as for US West (Oregon) use \` us-west-2 \` as the region."
>
Default Region
</Component>
<Select
allowCustomValue={false}
autoFocus={false}
backspaceRemovesValue={true}
className="width-30"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
defaultValue="us-east-2"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={Array []}
tabSelectsValue={true}
/>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
tooltip="Namespaces of Custom Metrics."
>
Custom Metrics
</Component>
<Input
className="width-30"
onChange={[Function]}
placeholder="Namespace1,Namespace2"
value=""
/>
</div>
</div>
</div>
</Fragment>
`;
exports[`Render should render component 1`] = `
<Fragment>
<h3
className="page-heading"
>
CloudWatch Details
</h3>
<div
className="gf-form-group"
>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Auth Provider
</Component>
<Select
allowCustomValue={false}
autoFocus={false}
backspaceRemovesValue={true}
className="width-30"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
defaultValue="keys"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
Object {
"label": "Access & secret key",
"value": "keys",
},
Object {
"label": "Credentials file",
"value": "credentials",
},
Object {
"label": "ARN",
"value": "arn",
},
]
}
tabSelectsValue={true}
value={
Object {
"label": "Access & secret key",
"value": "keys",
}
}
/>
</div>
</div>
<div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Access Key ID
</Component>
<div
className="width-30"
>
<Input
className="width-30"
onChange={[Function]}
value=""
/>
</div>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Secret Access Key
</Component>
<div
className="width-30"
>
<Input
className="width-30"
onChange={[Function]}
value=""
/>
</div>
</div>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
tooltip="Specify the region, such as for US West (Oregon) use \` us-west-2 \` as the region."
>
Default Region
</Component>
<Select
allowCustomValue={false}
autoFocus={false}
backspaceRemovesValue={true}
className="width-30"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
defaultValue="us-east-2"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={Array []}
tabSelectsValue={true}
/>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
tooltip="Namespaces of Custom Metrics."
>
Custom Metrics
</Component>
<Input
className="width-30"
onChange={[Function]}
placeholder="Namespace1,Namespace2"
value=""
/>
</div>
</div>
</div>
</Fragment>
`;
exports[`Render should should show access key and secret access key fields 1`] = `
<Fragment>
<h3
className="page-heading"
>
CloudWatch Details
</h3>
<div
className="gf-form-group"
>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Auth Provider
</Component>
<Select
allowCustomValue={false}
autoFocus={false}
backspaceRemovesValue={true}
className="width-30"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
defaultValue="keys"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
Object {
"label": "Access & secret key",
"value": "keys",
},
Object {
"label": "Credentials file",
"value": "credentials",
},
Object {
"label": "ARN",
"value": "arn",
},
]
}
tabSelectsValue={true}
value={
Object {
"label": "Access & secret key",
"value": "keys",
}
}
/>
</div>
</div>
<div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Access Key ID
</Component>
<div
className="width-30"
>
<Input
className="width-30"
onChange={[Function]}
value=""
/>
</div>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Secret Access Key
</Component>
<div
className="width-30"
>
<Input
className="width-30"
onChange={[Function]}
value=""
/>
</div>
</div>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
tooltip="Specify the region, such as for US West (Oregon) use \` us-west-2 \` as the region."
>
Default Region
</Component>
<Select
allowCustomValue={false}
autoFocus={false}
backspaceRemovesValue={true}
className="width-30"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
defaultValue="us-east-2"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={Array []}
tabSelectsValue={true}
/>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
tooltip="Namespaces of Custom Metrics."
>
Custom Metrics
</Component>
<Input
className="width-30"
onChange={[Function]}
placeholder="Namespace1,Namespace2"
value=""
/>
</div>
</div>
</div>
</Fragment>
`;
exports[`Render should should show arn role field 1`] = `
<Fragment>
<h3
className="page-heading"
>
CloudWatch Details
</h3>
<div
className="gf-form-group"
>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Auth Provider
</Component>
<Select
allowCustomValue={false}
autoFocus={false}
backspaceRemovesValue={true}
className="width-30"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
defaultValue="keys"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
Object {
"label": "Access & secret key",
"value": "keys",
},
Object {
"label": "Credentials file",
"value": "credentials",
},
Object {
"label": "ARN",
"value": "arn",
},
]
}
tabSelectsValue={true}
value={
Object {
"label": "Access & secret key",
"value": "keys",
}
}
/>
</div>
</div>
<div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Access Key ID
</Component>
<div
className="width-30"
>
<Input
className="width-30"
onChange={[Function]}
value=""
/>
</div>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Secret Access Key
</Component>
<div
className="width-30"
>
<Input
className="width-30"
onChange={[Function]}
value=""
/>
</div>
</div>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
tooltip="Specify the region, such as for US West (Oregon) use \` us-west-2 \` as the region."
>
Default Region
</Component>
<Select
allowCustomValue={false}
autoFocus={false}
backspaceRemovesValue={true}
className="width-30"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
defaultValue="us-east-2"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={Array []}
tabSelectsValue={true}
/>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
tooltip="Namespaces of Custom Metrics."
>
Custom Metrics
</Component>
<Input
className="width-30"
onChange={[Function]}
placeholder="Namespace1,Namespace2"
value=""
/>
</div>
</div>
</div>
</Fragment>
`;
exports[`Render should should show credentials profile name field 1`] = `
<Fragment>
<h3
className="page-heading"
>
CloudWatch Details
</h3>
<div
className="gf-form-group"
>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Auth Provider
</Component>
<Select
allowCustomValue={false}
autoFocus={false}
backspaceRemovesValue={true}
className="width-30"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
defaultValue="keys"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
Object {
"label": "Access & secret key",
"value": "keys",
},
Object {
"label": "Credentials file",
"value": "credentials",
},
Object {
"label": "ARN",
"value": "arn",
},
]
}
tabSelectsValue={true}
value={
Object {
"label": "Access & secret key",
"value": "keys",
}
}
/>
</div>
</div>
<div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Access Key ID
</Component>
<div
className="width-30"
>
<Input
className="width-30"
onChange={[Function]}
value=""
/>
</div>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
>
Secret Access Key
</Component>
<div
className="width-30"
>
<Input
className="width-30"
onChange={[Function]}
value=""
/>
</div>
</div>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
tooltip="Specify the region, such as for US West (Oregon) use \` us-west-2 \` as the region."
>
Default Region
</Component>
<Select
allowCustomValue={false}
autoFocus={false}
backspaceRemovesValue={true}
className="width-30"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
defaultValue="us-east-2"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={Array []}
tabSelectsValue={true}
/>
</div>
</div>
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<Component
className="width-14"
tooltip="Namespaces of Custom Metrics."
>
Custom Metrics
</Component>
<Input
className="width-30"
onChange={[Function]}
placeholder="Namespace1,Namespace2"
value=""
/>
</div>
</div>
</div>
</Fragment>
`;

View File

@@ -0,0 +1,396 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`QueryEditor should render component 1`] = `
Array [
<div
className="gf-form-inline"
>
<label
className="gf-form-label width-8 query-keyword"
>
Region
</label>
<div
className="gf-form"
onClick={[Function]}
>
<a
className="gf-form-label query-part"
>
us-east-1
</a>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
<div
className="gf-form-inline"
>
<label
className="gf-form-label width-8 query-keyword"
>
Namespace
</label>
<div
className="gf-form"
onClick={[Function]}
>
<a
className="gf-form-label query-part"
>
ec2
</a>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
<div
className="gf-form-inline"
>
<label
className="gf-form-label width-8 query-keyword"
>
Metric Name
</label>
<div
className="gf-form"
onClick={[Function]}
>
<a
className="gf-form-label query-part"
>
CPUUtilization
</a>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
<div
className="gf-form-inline"
>
<label
className="gf-form-label width-8 query-keyword"
>
Stats
</label>
<div
className="gf-form"
onClick={[Function]}
>
<a
className="gf-form-label query-part"
>
Average
</a>
</div>
<div
className="gf-form"
onClick={[Function]}
>
<a
className="gf-form-label query-part"
>
<i
className="fa fa-plus"
/>
</a>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
<div
className="gf-form-inline"
>
<label
className="gf-form-label width-8 query-keyword"
>
Dimensions
</label>
<div
className="gf-form"
onClick={[Function]}
>
<a
className="gf-form-label query-part"
>
somekey
</a>
</div>
<label
className="gf-form-label query-segment-operator"
>
=
</label>
<div
className="gf-form"
onClick={[Function]}
>
<a
className="gf-form-label query-part"
>
somevalue
</a>
</div>
<div
className="gf-form"
onClick={[Function]}
>
<a
className="gf-form-label query-part"
>
<i
className="fa fa-plus"
/>
</a>
</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"
>
<label
className="gf-form-label width-8 query-keyword"
>
Id
<div
className="gf-form-help-icon gf-form-help-icon--right-normal"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<i
className="fa fa-info-circle"
/>
</div>
</label>
<div
style={
Object {
"flexGrow": 1,
}
}
>
<input
className="gf-form-input gf-form-input width-8"
onBlur={[Function]}
onChange={[Function]}
value=""
/>
</div>
</div>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form-label width-8 query-keyword"
>
Expression
<div
className="gf-form-help-icon gf-form-help-icon--right-normal"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<i
className="fa fa-info-circle"
/>
</div>
</label>
<div
style={
Object {
"flexGrow": 1,
}
}
>
<input
className="gf-form-input gf-form-input"
onBlur={[MockFunction]}
onChange={[Function]}
value=""
/>
</div>
</div>
</div>,
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<label
className="gf-form-label width-8 query-keyword"
>
Min Period
<div
className="gf-form-help-icon gf-form-help-icon--right-normal"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<i
className="fa fa-info-circle"
/>
</div>
</label>
<div
style={
Object {
"flexGrow": 1,
}
}
>
<input
className="gf-form-input gf-form-input width-8"
onBlur={[MockFunction]}
onChange={[Function]}
placeholder="auto"
value=""
/>
</div>
</div>
<div
className="gf-form"
>
<label
className="gf-form-label width-8 query-keyword"
>
Alias
<div
className="gf-form-help-icon gf-form-help-icon--right-normal"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<i
className="fa fa-info-circle"
/>
</div>
</label>
<div
style={
Object {
"flexGrow": 1,
}
}
>
<input
className="gf-form-input gf-form-input width-16"
onChange={[Function]}
type="text"
value=""
/>
</div>
<div
className="gf-form-switch-container-react"
>
<label
className="gf-form gf-form-switch-container "
htmlFor="1"
>
<div
className="gf-form-label query-keyword pointer"
>
HighRes
</div>
<div
className="gf-form-switch "
>
<input
checked={false}
id="1"
onChange={[Function]}
type="checkbox"
/>
<span
className="gf-form-switch__slider"
/>
</div>
</label>
</div>
<div
className="gf-form-switch-container-react"
>
<label
className="gf-form gf-form-switch-container "
htmlFor="2"
>
<div
className="gf-form-label query-keyword pointer"
>
Match Exact
<div
className="gf-form-help-icon gf-form-help-icon--right-normal"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<i
className="fa fa-info-circle"
/>
</div>
</div>
<div
className="gf-form-switch "
>
<input
checked={true}
id="2"
onChange={[Function]}
type="checkbox"
/>
<span
className="gf-form-switch__slider"
/>
</div>
</label>
</div>
<label
className="gf-form-label"
>
<a
onClick={[Function]}
>
<i
className="fa fa-caret-right"
/>
Show
Query Preview
</a>
</label>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
]
`;

View File

@@ -0,0 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Stats should render component 1`] = `
Array [
<div
className="gf-form"
onClick={[Function]}
>
<a
className="gf-form-label query-part"
>
Average
</a>
</div>,
<div
className="gf-form"
onClick={[Function]}
>
<a
className="gf-form-label query-part"
>
Minimum
</a>
</div>,
<div
className="gf-form"
onClick={[Function]}
>
<a
className="gf-form-label query-part"
>
<i
className="fa fa-plus"
/>
</a>
</div>,
]
`;

View File

@@ -0,0 +1,4 @@
export { Stats } from './Stats';
export { Dimensions } from './Dimensions';
export { QueryInlineField, QueryField } from './Forms';
export { Alias } from './Alias';

View File

@@ -1,89 +0,0 @@
import _ from 'lodash';
import DatasourceSrv from 'app/features/plugins/datasource_srv';
import CloudWatchDatasource from './datasource';
export class CloudWatchConfigCtrl {
static templateUrl = 'partials/config.html';
current: any;
datasourceSrv: any;
accessKeyExist = false;
secretKeyExist = false;
/** @ngInject */
constructor($scope: any, datasourceSrv: DatasourceSrv) {
this.current.jsonData.timeField = this.current.jsonData.timeField || '@timestamp';
this.current.jsonData.authType = this.current.jsonData.authType || 'credentials';
this.accessKeyExist = this.current.secureJsonFields.accessKey;
this.secretKeyExist = this.current.secureJsonFields.secretKey;
this.datasourceSrv = datasourceSrv;
this.getRegions();
}
resetAccessKey() {
this.accessKeyExist = false;
}
resetSecretKey() {
this.secretKeyExist = false;
}
authTypes = [
{ name: 'Access & secret key', value: 'keys' },
{ name: 'Credentials file', value: 'credentials' },
{ name: 'ARN', value: 'arn' },
];
indexPatternTypes: any = [
{ name: 'No pattern', value: undefined },
{ name: 'Hourly', value: 'Hourly', example: '[logstash-]YYYY.MM.DD.HH' },
{ name: 'Daily', value: 'Daily', example: '[logstash-]YYYY.MM.DD' },
{ name: 'Weekly', value: 'Weekly', example: '[logstash-]GGGG.WW' },
{ name: 'Monthly', value: 'Monthly', example: '[logstash-]YYYY.MM' },
{ name: 'Yearly', value: 'Yearly', example: '[logstash-]YYYY' },
];
regions = [
'ap-east-1',
'ap-northeast-1',
'ap-northeast-2',
'ap-northeast-3',
'ap-south-1',
'ap-southeast-1',
'ap-southeast-2',
'ca-central-1',
'cn-north-1',
'cn-northwest-1',
'eu-central-1',
'eu-north-1',
'eu-west-1',
'eu-west-2',
'eu-west-3',
'me-south-1',
'sa-east-1',
'us-east-1',
'us-east-2',
'us-gov-east-1',
'us-gov-west-1',
'us-iso-east-1',
'us-isob-east-1',
'us-west-1',
'us-west-2',
];
getRegions() {
this.datasourceSrv
.loadDatasource(this.current.name)
.then((ds: CloudWatchDatasource) => {
return ds.getRegions();
})
.then(
(regions: any) => {
this.regions = _.map(regions, 'value');
},
(err: any) => {
console.error('failed to get latest regions');
}
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,545 @@
{
"__inputs": [
{
"name": "DS_CLOUDWATCH",
"label": "CloudWatch",
"description": "",
"type": "datasource",
"pluginId": "cloudwatch",
"pluginName": "CloudWatch"
}
],
"__requires": [
{
"type": "datasource",
"id": "cloudwatch",
"name": "CloudWatch",
"version": "1.0.0"
},
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "6.6.0-pre"
},
{
"type": "panel",
"id": "graph",
"name": "Graph",
"version": ""
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"id": null,
"iteration": 1573631164529,
"links": [],
"panels": [
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "$datasource",
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 0
},
"hiddenSeries": false,
"id": 2,
"legend": {
"alignAsTable": false,
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": []
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"dimensions": {
"FunctionName": "$function"
},
"expression": "",
"highResolution": false,
"matchExact": true,
"metricName": "Invocations",
"namespace": "AWS/Lambda",
"refId": "A",
"region": "$region",
"statistics": ["$statistic"]
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Invocations $statistic",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "$datasource",
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 0
},
"hiddenSeries": false,
"id": 3,
"legend": {
"alignAsTable": false,
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": []
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"dimensions": {
"FunctionName": "$function"
},
"expression": "",
"highResolution": false,
"matchExact": true,
"metricName": "Duration",
"namespace": "AWS/Lambda",
"refId": "A",
"region": "$region",
"statistics": ["Average"]
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Duration Average",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "$datasource",
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 9
},
"hiddenSeries": false,
"id": 4,
"legend": {
"alignAsTable": false,
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": []
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"dimensions": {
"FunctionName": "$function"
},
"expression": "",
"highResolution": false,
"matchExact": true,
"metricName": "Errors",
"namespace": "AWS/Lambda",
"refId": "A",
"region": "$region",
"statistics": ["Average"]
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Errors $statistic",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "$datasource",
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 9
},
"hiddenSeries": false,
"id": 5,
"legend": {
"alignAsTable": false,
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": []
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"dimensions": {
"FunctionName": "$function"
},
"expression": "",
"highResolution": false,
"matchExact": true,
"metricName": "Throttles",
"namespace": "AWS/Lambda",
"refId": "A",
"region": "$region",
"statistics": ["Average"]
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Throttles $statistic",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
}
],
"schemaVersion": 21,
"style": "dark",
"tags": [],
"templating": {
"list": [
{
"current": {
"selected": true,
"text": "CloudWatch",
"value": "CloudWatch"
},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"multi": false,
"name": "datasource",
"options": [],
"query": "cloudwatch",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
},
{
"allValue": null,
"current": {
"text": "default",
"value": "default"
},
"datasource": "${DS_CLOUDWATCH}",
"definition": "regions()",
"hide": 0,
"includeAll": false,
"label": "Region",
"multi": false,
"name": "region",
"options": [],
"query": "regions()",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"allValue": null,
"current": {},
"datasource": "${DS_CLOUDWATCH}",
"definition": "statistics()",
"hide": 0,
"includeAll": false,
"label": "Statistic",
"multi": false,
"name": "statistic",
"options": [],
"query": "statistics()",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"allValue": null,
"current": {
"text": "*",
"value": ["*"]
},
"datasource": "${DS_CLOUDWATCH}",
"definition": "dimension_values($region, AWS/Lambda, , FunctionName)",
"hide": 0,
"includeAll": true,
"label": "FunctionName",
"multi": true,
"name": "function",
"options": [],
"query": "dimension_values($region, AWS/Lambda, , FunctionName)",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Lambda",
"uid": "VgpJGb1Zg",
"version": 6
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,11 @@
import React from 'react';
import angular, { IQService } from 'angular';
import _ from 'lodash';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { AppNotificationTimeout } from 'app/types';
import { store } from 'app/store/store';
import kbn from 'app/core/utils/kbn';
import {
dateMath,
ScopedVars,
@@ -9,22 +15,39 @@ import {
DataQueryRequest,
DataSourceInstanceSettings,
} from '@grafana/data';
import kbn from 'app/core/utils/kbn';
import { CloudWatchQuery } from './types';
import { BackendSrv } from 'app/core/services/backend_srv';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
// import * as moment from 'moment';
import { ThrottlingErrorMessage } from './components/ThrottlingErrorMessage';
import memoizedDebounce from './memoizedDebounce';
import { CloudWatchQuery, CloudWatchJsonData } from './types';
export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery> {
const displayAlert = (datasourceName: string, region: string) =>
store.dispatch(
notifyApp(
createErrorNotification(
`CloudWatch request limit reached in ${region} for data source ${datasourceName}`,
'',
React.createElement(ThrottlingErrorMessage, { region }, null)
)
)
);
const displayCustomError = (title: string, message: string) =>
store.dispatch(notifyApp(createErrorNotification(title, message)));
export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery, CloudWatchJsonData> {
type: any;
proxyUrl: any;
defaultRegion: any;
standardStatistics: any;
datasourceName: string;
debouncedAlert: (datasourceName: string, region: string) => void;
debouncedCustomAlert: (title: string, message: string) => void;
/** @ngInject */
constructor(
private instanceSettings: DataSourceInstanceSettings,
instanceSettings: DataSourceInstanceSettings<CloudWatchJsonData>,
private $q: IQService,
private backendSrv: BackendSrv,
private templateSrv: TemplateSrv,
@@ -34,13 +57,14 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
this.type = 'cloudwatch';
this.proxyUrl = instanceSettings.url;
this.defaultRegion = instanceSettings.jsonData.defaultRegion;
this.instanceSettings = instanceSettings;
this.datasourceName = instanceSettings.name;
this.standardStatistics = ['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount'];
this.debouncedAlert = memoizedDebounce(displayAlert, AppNotificationTimeout.Error);
this.debouncedCustomAlert = memoizedDebounce(displayCustomError, AppNotificationTimeout.Error);
}
query(options: DataQueryRequest<CloudWatchQuery>) {
options = angular.copy(options);
options.targets = this.expandTemplateVariable(options.targets, options.scopedVars, this.templateSrv);
const queries = _.filter(options.targets, item => {
return (
@@ -49,16 +73,14 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
item.expression.length > 0)
);
}).map(item => {
item.region = this.templateSrv.replace(this.getActualRegion(item.region), options.scopedVars);
item.namespace = this.templateSrv.replace(item.namespace, options.scopedVars);
item.metricName = this.templateSrv.replace(item.metricName, options.scopedVars);
item.region = this.replace(this.getActualRegion(item.region), options.scopedVars, true, 'region');
item.namespace = this.replace(item.namespace, options.scopedVars, true, 'namespace');
item.metricName = this.replace(item.metricName, options.scopedVars, true, 'metric name');
item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars);
item.statistics = item.statistics.map(s => {
return this.templateSrv.replace(s, options.scopedVars);
});
item.statistics = item.statistics.map(stat => this.replace(stat, options.scopedVars, true, 'statistics'));
item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting
item.id = this.templateSrv.replace(item.id, options.scopedVars);
item.expression = this.templateSrv.replace(item.expression, options.scopedVars);
item.id = this.replace(item.id, options.scopedVars, true, 'id');
item.expression = this.replace(item.expression, options.scopedVars, true, 'expression');
// valid ExtendedStatistics is like p90.00, check the pattern
const hasInvalidStatistics = item.statistics.some(s => {
@@ -79,7 +101,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
refId: item.refId,
intervalMs: options.intervalMs,
maxDataPoints: options.maxDataPoints,
datasourceId: this.instanceSettings.id,
datasourceId: this.id,
type: 'timeSeriesQuery',
},
item
@@ -102,6 +124,10 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
return this.performTimeSeriesQuery(request, options.range);
}
get variables() {
return this.templateSrv.variables.map(v => `$${v.name}`);
}
getPeriod(target: any, options: any, now?: number) {
const start = this.convertToCloudWatchTime(options.range.from, false);
const end = this.convertToCloudWatchTime(options.range.to, true);
@@ -149,30 +175,50 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
}
buildCloudwatchConsoleUrl(
{ region, namespace, metricName, dimensions, statistics, period }: CloudWatchQuery,
{ region, namespace, metricName, dimensions, statistics, period, expression }: CloudWatchQuery,
start: string,
end: string,
title: string
title: string,
gmdMeta: Array<{ Expression: string }>
) {
const conf = {
region = this.getActualRegion(region);
let conf = {
view: 'timeSeries',
stacked: false,
title,
start,
end,
region,
metrics: [
...statistics.map(stat => [
namespace,
metricName,
...Object.entries(dimensions).reduce((acc, [key, value]) => [...acc, key, value], []),
{
stat,
period,
},
]),
],
};
} as any;
const isSearchExpression =
gmdMeta && gmdMeta.length && gmdMeta.every(({ Expression: expression }) => /SEARCH().*/.test(expression));
const isMathExpression = !isSearchExpression && expression;
if (isMathExpression) {
return '';
}
if (isSearchExpression) {
const metrics: any =
gmdMeta && gmdMeta.length ? gmdMeta.map(({ Expression: expression }) => ({ expression })) : [{ expression }];
conf = { ...conf, metrics };
} else {
conf = {
...conf,
metrics: [
...statistics.map(stat => [
namespace,
metricName,
...Object.entries(dimensions).reduce((acc, [key, value]) => [...acc, key, value[0]], []),
{
stat,
period,
},
]),
],
};
}
return `https://${region}.console.aws.amazon.com/cloudwatch/deeplink.js?region=${region}#metricsV2:graph=${encodeURIComponent(
JSON.stringify(conf)
@@ -180,44 +226,70 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
}
performTimeSeriesQuery(request: any, { from, to }: TimeRange) {
return this.awsRequest('/api/tsdb/query', request).then((res: any) => {
if (!res.results) {
return { data: [] };
}
const dataFrames = Object.values(request.queries).reduce((acc: any, queryRequest: any) => {
const queryResult = res.results[queryRequest.refId];
if (!queryResult) {
return acc;
return this.awsRequest('/api/tsdb/query', request)
.then((res: any) => {
if (!res.results) {
return { data: [] };
}
return Object.values(request.queries).reduce(
({ data, error }: any, queryRequest: any) => {
const queryResult = res.results[queryRequest.refId];
if (!queryResult) {
return { data, error };
}
const link = this.buildCloudwatchConsoleUrl(
queryRequest,
from.toISOString(),
to.toISOString(),
queryRequest.refId,
queryResult.meta.gmdMeta
);
return {
error: error || queryResult.error ? { message: queryResult.error } : null,
data: [
...data,
...queryResult.series.map(({ name, points }: any) => {
const dataFrame = toDataFrame({
target: name,
datapoints: points,
refId: queryRequest.refId,
meta: queryResult.meta,
});
if (link) {
for (const field of dataFrame.fields) {
field.config.links = [
{
url: link,
title: 'View in CloudWatch console',
targetBlank: true,
},
];
}
}
return dataFrame;
}),
],
};
},
{ data: [], error: null }
);
})
.catch((err: any = { data: { error: '' } }) => {
if (/^Throttling:.*/.test(err.data.message)) {
const failedRedIds = Object.keys(err.data.results);
const regionsAffected = Object.values(request.queries).reduce(
(res: string[], { refId, region }: CloudWatchQuery) =>
!failedRedIds.includes(refId) || res.includes(region) ? res : [...res, region],
[]
) as string[];
regionsAffected.forEach(region => this.debouncedAlert(this.datasourceName, this.getActualRegion(region)));
}
const link = this.buildCloudwatchConsoleUrl(
queryRequest,
from.toISOString(),
to.toISOString(),
`query${queryRequest.refId}`
);
return [
...acc,
...queryResult.series.map(({ name, points, meta }: any) => {
const series = { target: name, datapoints: points };
const dataFrame = toDataFrame(meta && meta.unit ? { ...series, unit: meta.unit } : series);
for (const field of dataFrame.fields) {
field.config.links = [
{
url: link,
title: 'View in CloudWatch console',
targetBlank: true,
},
];
}
return dataFrame;
}),
];
}, []);
return { data: dataFrames };
});
throw err;
});
}
transformSuggestDataFromTable(suggestData: any) {
@@ -225,6 +297,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
return {
text: v[0],
value: v[1],
label: v[1],
};
});
}
@@ -240,7 +313,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
refId: 'metricFindQuery',
intervalMs: 1, // dummy
maxDataPoints: 1, // dummy
datasourceId: this.instanceSettings.id,
datasourceId: this.id,
type: 'metricFindQuery',
subtype: subtype,
},
@@ -260,34 +333,48 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
return this.doMetricQueryRequest('namespaces', null);
}
getMetrics(namespace: string, region: string) {
async getMetrics(namespace: string, region: string) {
if (!namespace || !region) {
return [];
}
return this.doMetricQueryRequest('metrics', {
region: this.templateSrv.replace(this.getActualRegion(region)),
namespace: this.templateSrv.replace(namespace),
});
}
getDimensionKeys(namespace: string, region: string) {
async getDimensionKeys(namespace: string, region: string) {
if (!namespace) {
return [];
}
return this.doMetricQueryRequest('dimension_keys', {
region: this.templateSrv.replace(this.getActualRegion(region)),
namespace: this.templateSrv.replace(namespace),
});
}
getDimensionValues(
async getDimensionValues(
region: string,
namespace: string,
metricName: string,
dimensionKey: string,
filterDimensions: {}
) {
return this.doMetricQueryRequest('dimension_values', {
if (!namespace || !metricName) {
return [];
}
const values = await this.doMetricQueryRequest('dimension_values', {
region: this.templateSrv.replace(this.getActualRegion(region)),
namespace: this.templateSrv.replace(namespace),
metricName: this.templateSrv.replace(metricName),
metricName: this.templateSrv.replace(metricName.trim()),
dimensionKey: this.templateSrv.replace(dimensionKey),
dimensions: this.convertDimensionFormat(filterDimensions, {}),
});
return values.length ? [{ value: '*', text: '*', label: '*' }, ...values] : values;
}
getEbsVolumeIds(region: string, instanceId: string) {
@@ -313,7 +400,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
});
}
metricFindQuery(query: string) {
async metricFindQuery(query: string) {
let region;
let namespace;
let metricName;
@@ -382,6 +469,11 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
return this.getResourceARNs(region, resourceType, tagsJSON);
}
const statsQuery = query.match(/^statistics\(\)/);
if (statsQuery) {
return this.standardStatistics.map((s: string) => ({ value: s, label: s, text: s }));
}
return this.$q.when([]);
}
@@ -414,7 +506,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
refId: 'annotationQuery',
intervalMs: 1, // dummy
maxDataPoints: 1, // dummy
datasourceId: this.instanceSettings.id,
datasourceId: this.id,
type: 'annotationQuery',
},
parameters
@@ -445,7 +537,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
}
testDatasource() {
/* use billing metrics for test */
// use billing metrics for test
const region = this.defaultRegion;
const namespace = 'AWS/Billing';
const metricName = 'EstimatedCharges';
@@ -479,68 +571,6 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
return region;
}
getExpandedVariables(target: any, dimensionKey: any, variable: any, templateSrv: TemplateSrv) {
/* if the all checkbox is marked we should add all values to the targets */
const allSelected: any = _.find(variable.options, { selected: true, text: 'All' });
const selectedVariables = _.filter(variable.options, v => {
if (allSelected) {
return v.text !== 'All';
} else {
return v.selected;
}
});
const currentVariables = !_.isArray(variable.current.value)
? [variable.current]
: variable.current.value.map((v: any) => {
return {
text: v,
value: v,
};
});
const useSelectedVariables =
selectedVariables.some((s: any) => {
return s.value === currentVariables[0].value;
}) || currentVariables[0].value === '$__all';
return (useSelectedVariables ? selectedVariables : currentVariables).map((v: any) => {
const t = angular.copy(target);
const scopedVar: any = {};
scopedVar[variable.name] = v;
t.refId = target.refId + '_' + v.value;
t.dimensions[dimensionKey] = templateSrv.replace(t.dimensions[dimensionKey], scopedVar);
if (variable.multi && target.id) {
t.id = target.id + window.btoa(v.value).replace(/=/g, '0'); // generate unique id
} else {
t.id = target.id;
}
return t;
});
}
expandTemplateVariable(targets: any, scopedVars: ScopedVars, templateSrv: TemplateSrv) {
// Datasource and template srv logic uber-complected. This should be cleaned up.
return _.chain(targets)
.map(target => {
if (target.id && target.id.length > 0 && target.expression && target.expression.length > 0) {
return [target];
}
const variableIndex = _.keyBy(templateSrv.variables, 'name');
const dimensionKey = _.findKey(target.dimensions, v => {
const variableName = templateSrv.getVariableName(v);
return templateSrv.variableExists(v) && !_.has(scopedVars, variableName) && variableIndex[variableName].multi;
});
if (dimensionKey) {
const multiVariable = variableIndex[templateSrv.getVariableName(target.dimensions[dimensionKey])];
return this.getExpandedVariables(target, dimensionKey, multiVariable, templateSrv);
} else {
return [target];
}
})
.flatten()
.value();
}
convertToCloudWatchTime(date: any, roundUp: any) {
if (_.isString(date)) {
date = dateMath.parse(date, roundUp);
@@ -548,11 +578,38 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
return Math.round(date.valueOf() / 1000);
}
convertDimensionFormat(dimensions: any, scopedVars: ScopedVars) {
const convertedDimensions: any = {};
_.each(dimensions, (value, key) => {
convertedDimensions[this.templateSrv.replace(key, scopedVars)] = this.templateSrv.replace(value, scopedVars);
});
return convertedDimensions;
convertDimensionFormat(dimensions: { [key: string]: string | string[] }, scopedVars: ScopedVars) {
return Object.entries(dimensions).reduce((result, [key, value]) => {
key = this.replace(key, scopedVars, true, 'dimension keys');
if (Array.isArray(value)) {
return { ...result, [key]: value };
}
const valueVar = this.templateSrv.variables.find(({ name }) => name === this.templateSrv.getVariableName(value));
if (valueVar) {
if (valueVar.multi) {
const values = this.templateSrv.replace(value, scopedVars, 'pipe').split('|');
return { ...result, [key]: values };
}
return { ...result, [key]: [this.templateSrv.replace(value, scopedVars)] };
}
return { ...result, [key]: [value] };
}, {});
}
replace(target: string, scopedVars: ScopedVars, displayErrorIfIsMultiTemplateVariable?: boolean, fieldName?: string) {
if (displayErrorIfIsMultiTemplateVariable) {
const variable = this.templateSrv.variables.find(({ name }) => name === this.templateSrv.getVariableName(target));
if (variable && variable.multi) {
this.debouncedCustomAlert(
'CloudWatch templating error',
`Multi template variables are not supported for ${fieldName || target}`
);
}
}
return this.templateSrv.replace(target, scopedVars);
}
}

View File

@@ -0,0 +1,13 @@
import { debounce, memoize } from 'lodash';
export default (func: (...args: any[]) => void, wait = 7000) => {
const mem = memoize(
(...args) =>
debounce(func, wait, {
leading: true,
}),
(...args) => JSON.stringify(args)
);
return (...args: any[]) => mem(...args)(...args);
};

View File

@@ -1,16 +0,0 @@
import './query_parameter_ctrl';
import CloudWatchDatasource from './datasource';
import { CloudWatchQueryCtrl } from './query_ctrl';
import { CloudWatchConfigCtrl } from './config_ctrl';
class CloudWatchAnnotationsQueryCtrl {
static templateUrl = 'partials/annotations.editor.html';
}
export {
CloudWatchDatasource as Datasource,
CloudWatchQueryCtrl as QueryCtrl,
CloudWatchConfigCtrl as ConfigCtrl,
CloudWatchAnnotationsQueryCtrl as AnnotationsQueryCtrl,
};

View File

@@ -0,0 +1,16 @@
import { DataSourcePlugin } from '@grafana/data';
import { ConfigEditor } from './components/ConfigEditor';
import { QueryEditor } from './components/QueryEditor';
import CloudWatchDatasource from './datasource';
import { CloudWatchJsonData, CloudWatchQuery } from './types';
class CloudWatchAnnotationsQueryCtrl {
static templateUrl = 'partials/annotations.editor.html';
}
export const plugin = new DataSourcePlugin<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>(
CloudWatchDatasource
)
.setConfigEditor(ConfigEditor)
.setQueryEditor(QueryEditor)
.setAnnotationQueryCtrl(CloudWatchAnnotationsQueryCtrl);

View File

@@ -1,55 +0,0 @@
<h3 class="page-heading">CloudWatch details</h3>
<div class="gf-form-group max-width-30">
<div class="gf-form gf-form-select-wrapper">
<label class="gf-form-label width-13">Auth Provider</label>
<select class="gf-form-input gf-max-width-13" ng-model="ctrl.current.jsonData.authType" ng-options="f.value as f.name for f in ctrl.authTypes"></select>
</div>
<div class="gf-form" ng-show='ctrl.current.jsonData.authType == "credentials"'>
<label class="gf-form-label width-13">Credentials profile name</label>
<input type="text" class="gf-form-input max-width-18 gf-form-input--has-help-icon" ng-model='ctrl.current.database' placeholder="default"></input>
<info-popover mode="right-absolute">
Credentials profile name, as specified in ~/.aws/credentials, leave blank for default
</info-popover>
</div>
<div class="gf-form" ng-show='ctrl.current.jsonData.authType == "keys"'>
<label class="gf-form-label width-13">Access key ID </label>
<label class="gf-form-label width-13" ng-show="ctrl.accessKeyExist">Configured</label>
<a class="btn btn-secondary gf-form-btn" type="submit" ng-click="ctrl.resetAccessKey()" ng-show="ctrl.accessKeyExist">Reset</a>
<input type="text" class="gf-form-input max-width-18" ng-hide="ctrl.accessKeyExist" ng-model='ctrl.current.secureJsonData.accessKey'></input>
</div>
<div class="gf-form" ng-show='ctrl.current.jsonData.authType == "keys"'>
<label class="gf-form-label width-13">Secret access key</label>
<label class="gf-form-label width-13" ng-show="ctrl.secretKeyExist">Configured</label>
<a class="btn btn-secondary gf-form-btn" type="submit" ng-click="ctrl.resetSecretKey()" ng-show="ctrl.secretKeyExist">Reset</a>
<input type="text" class="gf-form-input max-width-18" ng-hide="ctrl.secretKeyExist" ng-model='ctrl.current.secureJsonData.secretKey'></input>
</div>
<div class="gf-form" ng-show='ctrl.current.jsonData.authType == "arn"'>
<label class="gf-form-label width-13">Assume Role ARN</label>
<input type="text" class="gf-form-input max-width-18 gf-form-input--has-help-icon" ng-model='ctrl.current.jsonData.assumeRoleArn' placeholder="arn:aws:iam:*"></input>
<info-popover mode="right-absolute">
ARN of Assume Role
</info-popover>
</div>
<div class="gf-form">
<label class="gf-form-label width-13">Default Region</label>
<div class="gf-form-select-wrapper max-width-18 gf-form-select-wrapper--has-help-icon">
<select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ctrl.regions"></select>
<info-popover mode="right-absolute">
Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region.
</info-popover>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-13">Custom Metrics</label>
<input type="text" class="gf-form-input max-width-18 gf-form-input--has-help-icon" ng-model='ctrl.current.jsonData.customMetricsNamespaces' placeholder="Namespace1,Namespace2"></input>
<info-popover mode="right-absolute">
Namespaces of Custom Metrics
</info-popover>
</div>
</div>

View File

@@ -1,4 +0,0 @@
<query-editor-row query-ctrl="ctrl" can-collapse="false">
<cloudwatch-query-parameter target="ctrl.target" datasource="ctrl.datasource" on-change="ctrl.refresh()"></cloudwatch-query-parameter>
</query-editor-row>

View File

@@ -1,92 +1,143 @@
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-8">Region</label>
<metric-segment segment="regionSegment" get-options="getRegions()" on-change="regionChanged()"></metric-segment>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword width-8">Region</label>
<metric-segment segment="regionSegment" get-options="getRegions()" on-change="regionChanged()"></metric-segment>
</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-if="target.expression.length === 0">
<div class="gf-form">
<label class="gf-form-label query-keyword width-8">Metric</label>
<div class="gf-form">
<label class="gf-form-label query-keyword width-8">Metric</label>
<metric-segment segment="namespaceSegment" get-options="getNamespaces()" on-change="namespaceChanged()"></metric-segment>
<metric-segment segment="metricSegment" get-options="getMetrics()" on-change="metricChanged()"></metric-segment>
</div>
<metric-segment
segment="namespaceSegment"
get-options="getNamespaces()"
on-change="namespaceChanged()"
></metric-segment>
<metric-segment segment="metricSegment" get-options="getMetrics()" on-change="metricChanged()"></metric-segment>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword">Stats</label>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword">Stats</label>
</div>
<div class="gf-form" ng-repeat="segment in statSegments">
<metric-segment segment="segment" get-options="getStatSegments(segment, $index)" on-change="statSegmentChanged(segment, $index)"></metric-segment>
</div>
<div class="gf-form" ng-repeat="segment in statSegments">
<metric-segment
segment="segment"
get-options="getStatSegments(segment, $index)"
on-change="statSegmentChanged(segment, $index)"
></metric-segment>
</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-if="target.expression.length === 0">
<div class="gf-form">
<label class="gf-form-label query-keyword width-8">Dimensions</label>
<metric-segment ng-repeat="segment in dimSegments" segment="segment" get-options="getDimSegments(segment, $index)" on-change="dimSegmentChanged(segment, $index)"></metric-segment>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword width-8">Dimensions</label>
<metric-segment
ng-repeat="segment in dimSegments"
segment="segment"
get-options="getDimSegments(segment, $index)"
on-change="dimSegmentChanged(segment, $index)"
></metric-segment>
</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-if="target.statistics.length === 1">
<div class="gf-form">
<label class=" gf-form-label query-keyword width-8 ">
Id
<info-popover mode="right-normal ">Id can include numbers, letters, and underscore, and must start with a lowercase letter.</info-popover>
</label>
<input type="text " class="gf-form-input " ng-model="target.id " spellcheck='false' ng-pattern='/^[a-z][a-zA-Z0-9_]*$/' ng-model-onblur ng-change="onChange() ">
</div>
<div class="gf-form max-width-30 ">
<label class="gf-form-label query-keyword width-7 ">Expression</label>
<input type="text " class="gf-form-input " ng-model="target.expression
" spellcheck='false' ng-model-onblur ng-change="onChange() ">
</div>
<div class="gf-form">
<label class=" gf-form-label query-keyword width-8 ">
Id
<info-popover mode="right-normal "
>Id can include numbers, letters, and underscore, and must start with a lowercase letter.</info-popover
>
</label>
<input
type="text "
class="gf-form-input "
ng-model="target.id "
spellcheck="false"
ng-pattern="/^[a-z][a-zA-Z0-9_]*$/"
ng-model-onblur
ng-change="onChange() "
/>
</div>
<div class="gf-form max-width-30 ">
<label class="gf-form-label query-keyword width-7 ">Expression</label>
<input
type="text "
class="gf-form-input "
ng-model="target.expression
"
spellcheck="false"
ng-model-onblur
ng-change="onChange() "
/>
</div>
</div>
<div class="gf-form-inline ">
<div class="gf-form ">
<label class="gf-form-label query-keyword width-8 ">
Min period
<info-popover mode="right-normal ">Minimum interval between points in seconds</info-popover>
</label>
<input type="text " class="gf-form-input " ng-model="target.period " spellcheck='false' placeholder="auto
" ng-model-onblur ng-change="onChange() " />
</div>
<div class="gf-form max-width-30 ">
<label class="gf-form-label query-keyword width-7 ">Alias</label>
<input type="text " class="gf-form-input " ng-model="target.alias " spellcheck='false' ng-model-onblur ng-change="onChange() ">
<info-popover mode="right-absolute ">
Alias replacement variables:
<ul ng-non-bindable>
<li>{{metric}}</li>
<li>{{stat}}</li>
<li>{{namespace}}</li>
<li>{{region}}</li>
<li>{{period}}</li>
<li>{{label}}</li>
<li>{{YOUR_DIMENSION_NAME}}</li>
</ul>
</info-popover>
</div>
<div class="gf-form ">
<gf-form-switch class="gf-form " label="HighRes " label-class="width-5 " checked="target.highResolution " on-change="onChange() ">
</gf-form-switch>
</div>
<div class="gf-form ">
<label class="gf-form-label query-keyword width-8 ">
Min period
<info-popover mode="right-normal ">Minimum interval between points in seconds</info-popover>
</label>
<input
type="text "
class="gf-form-input "
ng-model="target.period "
spellcheck="false"
placeholder="auto
"
ng-model-onblur
ng-change="onChange() "
/>
</div>
<div class="gf-form max-width-30 ">
<label class="gf-form-label query-keyword width-7 ">Alias</label>
<input
type="text "
class="gf-form-input "
ng-model="target.alias "
spellcheck="false"
ng-model-onblur
ng-change="onChange() "
/>
<info-popover mode="right-absolute ">
Alias replacement variables:
<ul ng-non-bindable>
<li>{{ metric }}</li>
<li>{{ stat }}</li>
<li>{{ namespace }}</li>
<li>{{ region }}</li>
<li>{{ period }}</li>
<li>{{ label }}</li>
<li>{{ YOUR_DIMENSION_NAME }}</li>
</ul>
</info-popover>
</div>
<div class="gf-form ">
<gf-form-switch
class="gf-form "
label="HighRes "
label-class="width-5 "
checked="target.highResolution "
on-change="onChange()"
>
</gf-form-switch>
</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

@@ -7,7 +7,6 @@
"metrics": true,
"alerting": true,
"annotations": true,
"info": {
"description": "Data source for Amazon AWS monitoring service",
"author": {

View File

@@ -1,15 +0,0 @@
import './query_parameter_ctrl';
import { QueryCtrl } from 'app/plugins/sdk';
import { auto } from 'angular';
export class CloudWatchQueryCtrl extends QueryCtrl {
static templateUrl = 'partials/query.editor.html';
aliasSyntax: string;
/** @ngInject */
constructor($scope: any, $injector: auto.IInjectorService) {
super($scope, $injector);
this.aliasSyntax = '{{metric}} {{stat}} {{namespace}} {{region}} {{<dimension name>}}';
}
}

View File

@@ -1,5 +1,6 @@
import '../datasource';
import CloudWatchDatasource from '../datasource';
import * as redux from 'app/store/store';
import { dateMath } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { CustomVariable } from 'app/features/templating/all';
@@ -12,6 +13,7 @@ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
describe('CloudWatchDatasource', () => {
const instanceSettings = {
jsonData: { defaultRegion: 'us-east-1' },
name: 'TestDatasource',
} as DataSourceInstanceSettings;
const templateSrv = new TemplateSrv();
@@ -45,6 +47,7 @@ describe('CloudWatchDatasource', () => {
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
expression: '',
refId: 'A',
region: 'us-east-1',
namespace: 'AWS/EC2',
@@ -90,7 +93,7 @@ describe('CloudWatchDatasource', () => {
const params = requestParams.queries[0];
expect(params.namespace).toBe(query.targets[0].namespace);
expect(params.metricName).toBe(query.targets[0].metricName);
expect(params.dimensions['InstanceId']).toBe('i-12345678');
expect(params.dimensions['InstanceId']).toStrictEqual(['i-12345678']);
expect(params.statistics).toEqual(query.targets[0].statistics);
expect(params.period).toBe(query.targets[0].period);
done();
@@ -164,6 +167,142 @@ describe('CloudWatchDatasource', () => {
done();
});
});
describe('a correct cloudwatch url should be built for each time series in the response', () => {
beforeEach(() => {
ctx.backendSrv.datasourceRequest = jest.fn(params => {
requestParams = params.data;
return Promise.resolve({ data: response });
});
});
it('should be built correctly if theres one search expressions returned in meta for a given query row', done => {
response.results['A'].meta.gmdMeta = [{ Expression: `REMOVE_EMPTY(SEARCH('some expression'))` }];
ctx.ds.query(query).then((result: any) => {
expect(result.data[0].name).toBe(response.results.A.series[0].name);
expect(result.data[0].fields[0].config.links[0].title).toBe('View in CloudWatch console');
expect(decodeURIComponent(result.data[0].fields[0].config.links[0].url)).toContain(
`region=us-east-1#metricsV2:graph={"view":"timeSeries","stacked":false,"title":"A","start":"2016-12-31T15:00:00.000Z","end":"2016-12-31T16:00:00.000Z","region":"us-east-1","metrics":[{"expression":"REMOVE_EMPTY(SEARCH(\'some expression\'))"}]}`
);
done();
});
});
it('should be built correctly if theres two search expressions returned in meta for a given query row', done => {
response.results['A'].meta.gmdMeta = [
{ Expression: `REMOVE_EMPTY(SEARCH('first expression'))` },
{ Expression: `REMOVE_EMPTY(SEARCH('second expression'))` },
];
ctx.ds.query(query).then((result: any) => {
expect(result.data[0].name).toBe(response.results.A.series[0].name);
expect(result.data[0].fields[0].config.links[0].title).toBe('View in CloudWatch console');
expect(decodeURIComponent(result.data[0].fields[0].config.links[0].url)).toContain(
`region=us-east-1#metricsV2:graph={"view":"timeSeries","stacked":false,"title":"A","start":"2016-12-31T15:00:00.000Z","end":"2016-12-31T16:00:00.000Z","region":"us-east-1","metrics":[{"expression":"REMOVE_EMPTY(SEARCH(\'first expression\'))"},{"expression":"REMOVE_EMPTY(SEARCH(\'second expression\'))"}]}`
);
done();
});
});
it('should be built correctly if the query is a metric stat query', done => {
response.results['A'].meta.gmdMeta = [];
ctx.ds.query(query).then((result: any) => {
expect(result.data[0].name).toBe(response.results.A.series[0].name);
expect(result.data[0].fields[0].config.links[0].title).toBe('View in CloudWatch console');
expect(decodeURIComponent(result.data[0].fields[0].config.links[0].url)).toContain(
`region=us-east-1#metricsV2:graph={\"view\":\"timeSeries\",\"stacked\":false,\"title\":\"A\",\"start\":\"2016-12-31T15:00:00.000Z\",\"end\":\"2016-12-31T16:00:00.000Z\",\"region\":\"us-east-1\",\"metrics\":[[\"AWS/EC2\",\"CPUUtilization\",\"InstanceId\",\"i-12345678\",{\"stat\":\"Average\",\"period\":\"300\"}]]}`
);
done();
});
});
it('should not be added at all if query is a math expression', done => {
query.targets[0].expression = 'a * 2';
response.results['A'].meta.searchExpressions = [];
ctx.ds.query(query).then((result: any) => {
expect(result.data[0].fields[0].config.links).toBeUndefined();
done();
});
});
});
describe('and throttling exception is thrown', () => {
const partialQuery = {
namespace: 'AWS/EC2',
metricName: 'CPUUtilization',
dimensions: {
InstanceId: 'i-12345678',
},
statistics: ['Average'],
period: '300',
expression: '',
};
const query = {
range: defaultTimeRange,
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{ ...partialQuery, refId: 'A', region: 'us-east-1' },
{ ...partialQuery, refId: 'B', region: 'us-east-2' },
{ ...partialQuery, refId: 'C', region: 'us-east-1' },
{ ...partialQuery, refId: 'D', region: 'us-east-2' },
{ ...partialQuery, refId: 'E', region: 'eu-north-1' },
],
};
const backendErrorResponse = {
data: {
message: 'Throttling: exception',
results: {
A: {
error: 'Throttling: exception',
refId: 'A',
meta: {},
},
B: {
error: 'Throttling: exception',
refId: 'B',
meta: {},
},
C: {
error: 'Throttling: exception',
refId: 'C',
meta: {},
},
D: {
error: 'Throttling: exception',
refId: 'D',
meta: {},
},
E: {
error: 'Throttling: exception',
refId: 'E',
meta: {},
},
},
},
};
beforeEach(() => {
redux.setStore({
dispatch: jest.fn(),
});
ctx.backendSrv.datasourceRequest = jest.fn(() => {
return Promise.reject(backendErrorResponse);
});
});
it('should display one alert error message per region+datasource combination', done => {
const memoizedDebounceSpy = jest.spyOn(ctx.ds, 'debouncedAlert');
ctx.ds.query(query).catch(() => {
expect(memoizedDebounceSpy).toHaveBeenCalledWith('TestDatasource', 'us-east-1');
expect(memoizedDebounceSpy).toHaveBeenCalledWith('TestDatasource', 'us-east-2');
expect(memoizedDebounceSpy).toHaveBeenCalledWith('TestDatasource', 'eu-north-1');
expect(memoizedDebounceSpy).toBeCalledTimes(3);
done();
});
});
});
});
describe('When query region is "default"', () => {
@@ -308,6 +447,21 @@ describe('CloudWatchDatasource', () => {
},
{} as any
),
new CustomVariable(
{
name: 'var4',
options: [
{ selected: true, value: 'var4-foo' },
{ selected: false, value: 'var4-bar' },
{ selected: true, value: 'var4-baz' },
],
current: {
value: ['var4-foo', 'var4-baz'],
},
multi: true,
},
{} as any
),
]);
ctx.backendSrv.datasourceRequest = jest.fn(params => {
@@ -336,12 +490,12 @@ describe('CloudWatchDatasource', () => {
};
ctx.ds.query(query).then(() => {
expect(requestParams.queries[0].dimensions['dim2']).toBe('var2-foo');
expect(requestParams.queries[0].dimensions['dim2']).toStrictEqual(['var2-foo']);
done();
});
});
it('should generate the correct query for multilple template variables', done => {
it('should generate the correct query in the case of one multilple template variables', done => {
const query = {
range: defaultTimeRange,
rangeRaw: { from: 1483228800, to: 1483232400 },
@@ -367,12 +521,38 @@ describe('CloudWatchDatasource', () => {
};
ctx.ds.query(query).then(() => {
expect(requestParams.queries[0].dimensions['dim1']).toBe('var1-foo');
expect(requestParams.queries[0].dimensions['dim2']).toBe('var2-foo');
expect(requestParams.queries[0].dimensions['dim3']).toBe('var3-foo');
expect(requestParams.queries[1].dimensions['dim1']).toBe('var1-foo');
expect(requestParams.queries[1].dimensions['dim2']).toBe('var2-foo');
expect(requestParams.queries[1].dimensions['dim3']).toBe('var3-baz');
expect(requestParams.queries[0].dimensions['dim1']).toStrictEqual(['var1-foo']);
expect(requestParams.queries[0].dimensions['dim2']).toStrictEqual(['var2-foo']);
expect(requestParams.queries[0].dimensions['dim3']).toStrictEqual(['var3-foo', 'var3-baz']);
done();
});
});
it('should generate the correct query in the case of multilple multi template variables', done => {
const query = {
range: defaultTimeRange,
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
refId: 'A',
region: 'us-east-1',
namespace: 'TestNamespace',
metricName: 'TestMetricName',
dimensions: {
dim1: '[[var1]]',
dim3: '[[var3]]',
dim4: '[[var4]]',
},
statistics: ['Average'],
period: 300,
},
],
};
ctx.ds.query(query).then(() => {
expect(requestParams.queries[0].dimensions['dim1']).toStrictEqual(['var1-foo']);
expect(requestParams.queries[0].dimensions['dim3']).toStrictEqual(['var3-foo', 'var3-baz']);
expect(requestParams.queries[0].dimensions['dim4']).toStrictEqual(['var4-foo', 'var4-baz']);
done();
});
});
@@ -402,67 +582,9 @@ describe('CloudWatchDatasource', () => {
};
ctx.ds.query(query).then(() => {
expect(requestParams.queries[0].dimensions['dim1']).toBe('var1-foo');
expect(requestParams.queries[0].dimensions['dim2']).toBe('var2-foo');
expect(requestParams.queries[0].dimensions['dim3']).toBe('var3-foo');
expect(requestParams.queries[1].dimensions['dim1']).toBe('var1-foo');
expect(requestParams.queries[1].dimensions['dim2']).toBe('var2-foo');
expect(requestParams.queries[1].dimensions['dim3']).toBe('var3-baz');
done();
});
});
it('should generate the correct query for multilple template variables with expression', done => {
const query: any = {
range: defaultTimeRange,
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
refId: 'A',
id: 'id1',
region: 'us-east-1',
namespace: 'TestNamespace',
metricName: 'TestMetricName',
dimensions: {
dim1: '[[var1]]',
dim2: '[[var2]]',
dim3: '[[var3]]',
},
statistics: ['Average'],
period: 300,
expression: '',
},
{
refId: 'B',
id: 'id2',
expression: 'METRICS("id1") * 2',
dimensions: {
// garbage data for fail test
dim1: '[[var1]]',
dim2: '[[var2]]',
dim3: '[[var3]]',
},
statistics: [], // dummy
},
],
scopedVars: {
var1: { selected: true, value: 'var1-foo' },
var2: { selected: true, value: 'var2-foo' },
},
};
ctx.ds.query(query).then(() => {
expect(requestParams.queries.length).toBe(3);
expect(requestParams.queries[0].id).toMatch(/^id1.*/);
expect(requestParams.queries[0].dimensions['dim1']).toBe('var1-foo');
expect(requestParams.queries[0].dimensions['dim2']).toBe('var2-foo');
expect(requestParams.queries[0].dimensions['dim3']).toBe('var3-foo');
expect(requestParams.queries[1].id).toMatch(/^id1.*/);
expect(requestParams.queries[1].dimensions['dim1']).toBe('var1-foo');
expect(requestParams.queries[1].dimensions['dim2']).toBe('var2-foo');
expect(requestParams.queries[1].dimensions['dim3']).toBe('var3-baz');
expect(requestParams.queries[2].id).toMatch(/^id2.*/);
expect(requestParams.queries[2].expression).toBe('METRICS("id1") * 2');
expect(requestParams.queries[0].dimensions['dim1']).toStrictEqual(['var1-foo']);
expect(requestParams.queries[0].dimensions['dim2']).toStrictEqual(['var2-foo']);
expect(requestParams.queries[0].dimensions['dim3']).toStrictEqual(['var3-foo', 'var3-baz']);
done();
});
});
@@ -471,9 +593,9 @@ describe('CloudWatchDatasource', () => {
function describeMetricFindQuery(query: any, func: any) {
describe('metricFindQuery ' + query, () => {
const scenario: any = {};
scenario.setup = (setupCallback: any) => {
beforeEach(() => {
setupCallback();
scenario.setup = async (setupCallback: any) => {
beforeEach(async () => {
await setupCallback();
ctx.backendSrv.datasourceRequest = jest.fn(args => {
scenario.request = args.data;
return Promise.resolve({ data: scenario.requestResponse });
@@ -488,8 +610,8 @@ describe('CloudWatchDatasource', () => {
});
}
describeMetricFindQuery('regions()', (scenario: any) => {
scenario.setup(() => {
describeMetricFindQuery('regions()', async (scenario: any) => {
await scenario.setup(() => {
scenario.requestResponse = {
results: {
metricFindQuery: {
@@ -506,8 +628,8 @@ describe('CloudWatchDatasource', () => {
});
});
describeMetricFindQuery('namespaces()', (scenario: any) => {
scenario.setup(() => {
describeMetricFindQuery('namespaces()', async (scenario: any) => {
await scenario.setup(() => {
scenario.requestResponse = {
results: {
metricFindQuery: {
@@ -524,8 +646,8 @@ describe('CloudWatchDatasource', () => {
});
});
describeMetricFindQuery('metrics(AWS/EC2)', (scenario: any) => {
scenario.setup(() => {
describeMetricFindQuery('metrics(AWS/EC2, us-east-2)', async (scenario: any) => {
await scenario.setup(() => {
scenario.requestResponse = {
results: {
metricFindQuery: {
@@ -542,8 +664,8 @@ describe('CloudWatchDatasource', () => {
});
});
describeMetricFindQuery('dimension_keys(AWS/EC2)', (scenario: any) => {
scenario.setup(() => {
describeMetricFindQuery('dimension_keys(AWS/EC2)', async (scenario: any) => {
await scenario.setup(() => {
scenario.requestResponse = {
results: {
metricFindQuery: {
@@ -554,14 +676,15 @@ describe('CloudWatchDatasource', () => {
});
it('should call __GetDimensions and return result', () => {
console.log({ a: scenario.requestResponse.results });
expect(scenario.result[0].text).toBe('InstanceId');
expect(scenario.request.queries[0].type).toBe('metricFindQuery');
expect(scenario.request.queries[0].subtype).toBe('dimension_keys');
});
});
describeMetricFindQuery('dimension_values(us-east-1,AWS/EC2,CPUUtilization,InstanceId)', (scenario: any) => {
scenario.setup(() => {
describeMetricFindQuery('dimension_values(us-east-1,AWS/EC2,CPUUtilization,InstanceId)', async (scenario: any) => {
await scenario.setup(() => {
scenario.requestResponse = {
results: {
metricFindQuery: {
@@ -578,8 +701,8 @@ describe('CloudWatchDatasource', () => {
});
});
describeMetricFindQuery('dimension_values(default,AWS/EC2,CPUUtilization,InstanceId)', (scenario: any) => {
scenario.setup(() => {
describeMetricFindQuery('dimension_values(default,AWS/EC2,CPUUtilization,InstanceId)', async (scenario: any) => {
await scenario.setup(() => {
scenario.requestResponse = {
results: {
metricFindQuery: {
@@ -596,32 +719,35 @@ describe('CloudWatchDatasource', () => {
});
});
describeMetricFindQuery('resource_arns(default,ec2:instance,{"environment":["production"]})', (scenario: any) => {
scenario.setup(() => {
scenario.requestResponse = {
results: {
metricFindQuery: {
tables: [
{
rows: [
[
'arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567',
'arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321',
describeMetricFindQuery(
'resource_arns(default,ec2:instance,{"environment":["production"]})',
async (scenario: any) => {
await scenario.setup(() => {
scenario.requestResponse = {
results: {
metricFindQuery: {
tables: [
{
rows: [
[
'arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567',
'arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321',
],
],
],
},
],
},
],
},
},
},
};
});
};
});
it('should call __ListMetrics and return result', () => {
expect(scenario.result[0].text).toContain('arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567');
expect(scenario.request.queries[0].type).toBe('metricFindQuery');
expect(scenario.request.queries[0].subtype).toBe('resource_arns');
});
});
it('should call __ListMetrics and return result', () => {
expect(scenario.result[0].text).toContain('arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567');
expect(scenario.request.queries[0].type).toBe('metricFindQuery');
expect(scenario.request.queries[0].subtype).toBe('resource_arns');
});
}
);
it('should caclculate the correct period', () => {
const hourSec = 60 * 60;

View File

@@ -1,12 +1,29 @@
import { DataQuery } from '@grafana/data';
import { DataQuery, SelectableValue, DataSourceJsonData } from '@grafana/data';
export interface CloudWatchQuery extends DataQuery {
id: string;
region: string;
namespace: string;
metricName: string;
dimensions: { [key: string]: string };
dimensions: { [key: string]: string | string[] };
statistics: string[];
period: string;
expression: string;
alias: string;
highResolution: boolean;
matchExact: boolean;
}
export type SelectableStrings = Array<SelectableValue<string>>;
export interface CloudWatchJsonData extends DataSourceJsonData {
timeField?: string;
assumeRoleArn?: string;
database?: string;
customMetricsNamespaces?: string;
}
export interface CloudWatchSecureJsonData {
accessKey: string;
secretKey: string;
}

View File

@@ -4,6 +4,7 @@ export interface AppNotification {
icon: string;
title: string;
text: string;
component?: React.ReactElement;
timeout: AppNotificationTimeout;
}