mirror of
https://github.com/grafana/grafana.git
synced 2025-12-21 20:24:41 +08:00
Compare commits
22 Commits
sriram/pos
...
v6.5.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab9c0da30e | ||
|
|
1f3c557dfd | ||
|
|
6686611369 | ||
|
|
e19d43ef2d | ||
|
|
a8f13bb0c1 | ||
|
|
0773ae80ea | ||
|
|
79bfdcb122 | ||
|
|
4d7edd3cd8 | ||
|
|
8fa29f2497 | ||
|
|
8cb1af2b21 | ||
|
|
33d84abf2c | ||
|
|
9a584bc798 | ||
|
|
fd491c39a3 | ||
|
|
47a199a731 | ||
|
|
4647c48427 | ||
|
|
d6f352cdf5 | ||
|
|
7b517bcb10 | ||
|
|
6243776004 | ||
|
|
0cc17c384a | ||
|
|
1033687df6 | ||
|
|
3abca7a820 | ||
|
|
ece9015afe |
@@ -2,5 +2,5 @@
|
|||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"useWorkspaces": true,
|
"useWorkspaces": true,
|
||||||
"packages": ["packages/*"],
|
"packages": ["packages/*"],
|
||||||
"version": "6.5.0-pre"
|
"version": "6.5.0-beta.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"name": "grafana",
|
"name": "grafana",
|
||||||
"version": "6.5.0-pre",
|
"version": "6.5.0-beta1",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "http://github.com/grafana/grafana.git"
|
"url": "http://github.com/grafana/grafana.git"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"author": "Grafana Labs",
|
"author": "Grafana Labs",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"name": "@grafana/data",
|
"name": "@grafana/data",
|
||||||
"version": "6.4.0-pre",
|
"version": "6.5.0-beta.1",
|
||||||
"description": "Grafana Data Library",
|
"description": "Grafana Data Library",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"typescript"
|
"typescript"
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ describe('toDataFrame', () => {
|
|||||||
expect(again).toBe(input);
|
expect(again).toBe(input);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws when table rows is not array', () => {
|
||||||
|
expect(() =>
|
||||||
|
toDataFrame({
|
||||||
|
columns: [],
|
||||||
|
rows: {},
|
||||||
|
})
|
||||||
|
).toThrowError('Expected table rows to be array, got object.');
|
||||||
|
});
|
||||||
|
|
||||||
it('migrate from 6.3 style rows', () => {
|
it('migrate from 6.3 style rows', () => {
|
||||||
const oldDataFrame = {
|
const oldDataFrame = {
|
||||||
fields: [{ name: 'A' }, { name: 'B' }, { name: 'C' }],
|
fields: [{ name: 'A' }, { name: 'B' }, { name: 'C' }],
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// Libraries
|
// Libraries
|
||||||
import isNumber from 'lodash/isNumber';
|
import { isArray, isBoolean, isNumber, isString } from 'lodash';
|
||||||
import isString from 'lodash/isString';
|
|
||||||
import isBoolean from 'lodash/isBoolean';
|
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +32,10 @@ function convertTableToDataFrame(table: TableData): DataFrame {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isArray(table.rows)) {
|
||||||
|
throw new Error(`Expected table rows to be array, got ${typeof table.rows}.`);
|
||||||
|
}
|
||||||
|
|
||||||
for (const row of table.rows) {
|
for (const row of table.rows) {
|
||||||
for (let i = 0; i < fields.length; i++) {
|
for (let i = 0; i < fields.length; i++) {
|
||||||
fields[i].values.buffer.push(row[i]);
|
fields[i].values.buffer.push(row[i]);
|
||||||
|
|||||||
@@ -284,7 +284,6 @@ export interface ExploreQueryFieldProps<
|
|||||||
> extends QueryEditorProps<DSType, TQuery, TOptions> {
|
> extends QueryEditorProps<DSType, TQuery, TOptions> {
|
||||||
history: any[];
|
history: any[];
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
onHint?: (action: QueryFixAction) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExploreStartPageProps {
|
export interface ExploreStartPageProps {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"author": "Grafana Labs",
|
"author": "Grafana Labs",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"name": "@grafana/runtime",
|
"name": "@grafana/runtime",
|
||||||
"version": "6.4.0-pre",
|
"version": "6.5.0-beta.1",
|
||||||
"description": "Grafana Runtime Library",
|
"description": "Grafana Runtime Library",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"grafana",
|
"grafana",
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
"build": "grafana-toolkit package:build --scope=runtime"
|
"build": "grafana-toolkit package:build --scope=runtime"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grafana/data": "^6.4.0-alpha",
|
"@grafana/data": "6.5.0-beta.1",
|
||||||
"@grafana/ui": "^6.4.0-alpha",
|
"@grafana/ui": "6.5.0-beta.1",
|
||||||
"systemjs": "0.20.19",
|
"systemjs": "0.20.19",
|
||||||
"systemjs-plugin-css": "0.1.37"
|
"systemjs-plugin-css": "0.1.37"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"author": "Grafana Labs",
|
"author": "Grafana Labs",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"name": "@grafana/toolkit",
|
"name": "@grafana/toolkit",
|
||||||
"version": "6.4.0-pre",
|
"version": "6.5.0-beta.1",
|
||||||
"description": "Grafana Toolkit",
|
"description": "Grafana Toolkit",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"grafana",
|
"grafana",
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "7.6.4",
|
"@babel/core": "7.6.4",
|
||||||
"@babel/preset-env": "7.6.3",
|
"@babel/preset-env": "7.6.3",
|
||||||
"@grafana/data": "^6.4.0-alpha",
|
"@grafana/data": "6.5.0-beta.1",
|
||||||
"@grafana/ui": "^6.4.0-alpha",
|
"@grafana/ui": "6.5.0-beta.1",
|
||||||
"@types/command-exists": "^1.2.0",
|
"@types/command-exists": "^1.2.0",
|
||||||
"@types/execa": "^0.9.0",
|
"@types/execa": "^0.9.0",
|
||||||
"@types/expect-puppeteer": "3.3.1",
|
"@types/expect-puppeteer": "3.3.1",
|
||||||
@@ -42,7 +42,6 @@
|
|||||||
"@types/semver": "^6.0.0",
|
"@types/semver": "^6.0.0",
|
||||||
"@types/tmp": "^0.1.0",
|
"@types/tmp": "^0.1.0",
|
||||||
"@types/webpack": "4.4.34",
|
"@types/webpack": "4.4.34",
|
||||||
"aws-sdk": "^2.495.0",
|
|
||||||
"axios": "0.19.0",
|
"axios": "0.19.0",
|
||||||
"babel-jest": "24.8.0",
|
"babel-jest": "24.8.0",
|
||||||
"babel-loader": "8.0.6",
|
"babel-loader": "8.0.6",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Task, TaskRunner } from './task';
|
import { Task, TaskRunner } from './task';
|
||||||
import { pluginBuildRunner } from './plugin.build';
|
import { pluginBuildRunner } from './plugin.build';
|
||||||
import { restoreCwd } from '../utils/cwd';
|
import { restoreCwd } from '../utils/cwd';
|
||||||
import { S3Client } from '../../plugins/aws';
|
|
||||||
import { getPluginJson } from '../../config/utils/pluginValidation';
|
import { getPluginJson } from '../../config/utils/pluginValidation';
|
||||||
import { getPluginId } from '../../config/utils/getPluginId';
|
import { getPluginId } from '../../config/utils/getPluginId';
|
||||||
import { PluginMeta } from '@grafana/data';
|
import { PluginMeta } from '@grafana/data';
|
||||||
@@ -10,28 +9,18 @@ import { PluginMeta } from '@grafana/data';
|
|||||||
import execa = require('execa');
|
import execa = require('execa');
|
||||||
import path = require('path');
|
import path = require('path');
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { getPackageDetails, findImagesInFolder, appendPluginHistory, getGrafanaVersions } from '../../plugins/utils';
|
import { getPackageDetails, findImagesInFolder, getGrafanaVersions } from '../../plugins/utils';
|
||||||
import {
|
import {
|
||||||
job,
|
job,
|
||||||
getJobFolder,
|
getJobFolder,
|
||||||
writeJobStats,
|
writeJobStats,
|
||||||
getCiFolder,
|
getCiFolder,
|
||||||
getPluginBuildInfo,
|
getPluginBuildInfo,
|
||||||
getBuildNumber,
|
|
||||||
getPullRequestNumber,
|
getPullRequestNumber,
|
||||||
getCircleDownloadBaseURL,
|
getCircleDownloadBaseURL,
|
||||||
} from '../../plugins/env';
|
} from '../../plugins/env';
|
||||||
import { agregateWorkflowInfo, agregateCoverageInfo, agregateTestInfo } from '../../plugins/workflow';
|
import { agregateWorkflowInfo, agregateCoverageInfo, agregateTestInfo } from '../../plugins/workflow';
|
||||||
import {
|
import { PluginPackageDetails, PluginBuildReport, TestResultsInfo } from '../../plugins/types';
|
||||||
PluginPackageDetails,
|
|
||||||
PluginBuildReport,
|
|
||||||
PluginHistory,
|
|
||||||
defaultPluginHistory,
|
|
||||||
TestResultsInfo,
|
|
||||||
PluginDevInfo,
|
|
||||||
PluginDevSummary,
|
|
||||||
DevSummary,
|
|
||||||
} from '../../plugins/types';
|
|
||||||
import { runEndToEndTests } from '../../plugins/e2e/launcher';
|
import { runEndToEndTests } from '../../plugins/e2e/launcher';
|
||||||
import { getEndToEndSettings } from '../../plugins/index';
|
import { getEndToEndSettings } from '../../plugins/index';
|
||||||
|
|
||||||
@@ -185,6 +174,9 @@ const packagePluginRunner: TaskRunner<PluginCIOptions> = async () => {
|
|||||||
throw new Error('Invalid zip file: ' + zipFile);
|
throw new Error('Invalid zip file: ' + zipFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make a copy so it is easy for report to read
|
||||||
|
await execa('cp', [pluginJsonFile, distDir]);
|
||||||
|
|
||||||
const info: PluginPackageDetails = {
|
const info: PluginPackageDetails = {
|
||||||
plugin: await getPackageDetails(zipFile, distDir),
|
plugin: await getPackageDetails(zipFile, distDir),
|
||||||
};
|
};
|
||||||
@@ -346,88 +338,19 @@ const pluginReportRunner: TaskRunner<PluginCIOptions> = async ({ upload }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Initalizing S3 Client');
|
const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY;
|
||||||
const s3 = new S3Client();
|
if (!GRAFANA_API_KEY) {
|
||||||
|
console.log('Enter a GRAFANA_API_KEY to upload the plugin report');
|
||||||
const build = pluginMeta.info.build;
|
return;
|
||||||
if (!build) {
|
|
||||||
throw new Error('Metadata missing build info');
|
|
||||||
}
|
}
|
||||||
|
const url = `https://grafana.com/api/plugins/${report.plugin.id}/ci`;
|
||||||
|
|
||||||
const version = pluginMeta.info.version || 'unknown';
|
console.log('Sending report to:', url);
|
||||||
const branch = build.branch || 'unknown';
|
const axios = require('axios');
|
||||||
const buildNumber = getBuildNumber();
|
const info = await axios.post(url, report, {
|
||||||
const root = `dev/${pluginMeta.id}`;
|
headers: { Authorization: 'bearer ' + GRAFANA_API_KEY },
|
||||||
const dirKey = pr ? `${root}/pr/${pr}/${buildNumber}` : `${root}/branch/${branch}/${buildNumber}`;
|
|
||||||
|
|
||||||
const jobKey = `${dirKey}/index.json`;
|
|
||||||
if (await s3.exists(jobKey)) {
|
|
||||||
throw new Error('Job already registered: ' + jobKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Write Job', jobKey);
|
|
||||||
await s3.writeJSON(jobKey, report, {
|
|
||||||
Tagging: `version=${version}&type=${pluginMeta.type}`,
|
|
||||||
});
|
});
|
||||||
|
console.log('RESULT: ', info);
|
||||||
// Upload logo
|
|
||||||
const logo = await s3.uploadLogo(report.plugin.info, {
|
|
||||||
local: path.resolve(ciDir, 'dist'),
|
|
||||||
remote: root,
|
|
||||||
});
|
|
||||||
|
|
||||||
const latest: PluginDevInfo = {
|
|
||||||
pluginId: pluginMeta.id,
|
|
||||||
name: pluginMeta.name,
|
|
||||||
logo,
|
|
||||||
build: pluginMeta.info.build!,
|
|
||||||
version,
|
|
||||||
};
|
|
||||||
|
|
||||||
let base = `${root}/branch/${branch}/`;
|
|
||||||
latest.build.number = buildNumber;
|
|
||||||
if (pr) {
|
|
||||||
latest.build.pr = pr;
|
|
||||||
base = `${root}/pr/${pr}/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const historyKey = base + `history.json`;
|
|
||||||
console.log('Read', historyKey);
|
|
||||||
const history: PluginHistory = await s3.readJSON(historyKey, defaultPluginHistory);
|
|
||||||
appendPluginHistory(report, latest, history);
|
|
||||||
|
|
||||||
await s3.writeJSON(historyKey, history);
|
|
||||||
console.log('wrote history');
|
|
||||||
|
|
||||||
// Private things may want to upload
|
|
||||||
if (upload) {
|
|
||||||
s3.uploadPackages(packageInfo, {
|
|
||||||
local: packageDir,
|
|
||||||
remote: dirKey + '/packages',
|
|
||||||
});
|
|
||||||
|
|
||||||
s3.uploadTestFiles(report.tests, {
|
|
||||||
local: ciDir,
|
|
||||||
remote: dirKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Update Directory Indexes');
|
|
||||||
|
|
||||||
let indexKey = `${root}/index.json`;
|
|
||||||
const index: PluginDevSummary = await s3.readJSON(indexKey, { branch: {}, pr: {} });
|
|
||||||
if (pr) {
|
|
||||||
index.pr[pr] = latest;
|
|
||||||
} else {
|
|
||||||
index.branch[branch] = latest;
|
|
||||||
}
|
|
||||||
await s3.writeJSON(indexKey, index);
|
|
||||||
|
|
||||||
indexKey = `dev/index.json`;
|
|
||||||
const pluginIndex: DevSummary = await s3.readJSON(indexKey, {});
|
|
||||||
pluginIndex[pluginMeta.id] = latest;
|
|
||||||
await s3.writeJSON(indexKey, pluginIndex);
|
|
||||||
console.log('wrote index');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ciPluginReportTask = new Task<PluginCIOptions>('Generate Plugin Report', pluginReportRunner);
|
export const ciPluginReportTask = new Task<PluginCIOptions>('Generate Plugin Report', pluginReportRunner);
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
import AWS from 'aws-sdk';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
import { PluginPackageDetails, ZipFileInfo, TestResultsInfo } from './types';
|
|
||||||
import defaults from 'lodash/defaults';
|
|
||||||
import clone from 'lodash/clone';
|
|
||||||
import { PluginMetaInfo } from '@grafana/data';
|
|
||||||
|
|
||||||
interface UploadArgs {
|
|
||||||
local: string;
|
|
||||||
remote: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class S3Client {
|
|
||||||
readonly bucket: string;
|
|
||||||
readonly prefix: string;
|
|
||||||
readonly s3: AWS.S3;
|
|
||||||
|
|
||||||
constructor(bucket?: string) {
|
|
||||||
this.bucket = bucket || 'grafana-experiments';
|
|
||||||
this.prefix = 'plugins/';
|
|
||||||
|
|
||||||
this.s3 = new AWS.S3({ apiVersion: '2006-03-01' });
|
|
||||||
this.s3.headBucket({ Bucket: this.bucket }, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
throw new Error('Unable to read: ' + this.bucket);
|
|
||||||
} else {
|
|
||||||
console.log('s3: ' + data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async uploadPackage(file: ZipFileInfo, folder: UploadArgs): Promise<string> {
|
|
||||||
const fpath = path.resolve(process.cwd(), folder.local, file.name);
|
|
||||||
return await this.uploadFile(fpath, folder.remote + '/' + file.name, file.md5);
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadPackages(packageInfo: PluginPackageDetails, folder: UploadArgs) {
|
|
||||||
await this.uploadPackage(packageInfo.plugin, folder);
|
|
||||||
if (packageInfo.docs) {
|
|
||||||
await this.uploadPackage(packageInfo.docs, folder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadTestFiles(tests: TestResultsInfo[], folder: UploadArgs) {
|
|
||||||
for (const test of tests) {
|
|
||||||
for (const s of test.screenshots) {
|
|
||||||
const img = path.resolve(folder.local, 'jobs', test.job, s);
|
|
||||||
await this.uploadFile(img, folder.remote + `/jobs/${test.job}/${s}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadLogo(meta: PluginMetaInfo, folder: UploadArgs): Promise<string | undefined> {
|
|
||||||
const { logos } = meta;
|
|
||||||
if (logos && logos.large) {
|
|
||||||
const img = folder.local + '/' + logos.large;
|
|
||||||
const idx = img.lastIndexOf('.');
|
|
||||||
const name = 'logo' + img.substring(idx);
|
|
||||||
const key = folder.remote + '/' + name;
|
|
||||||
await this.uploadFile(img, key);
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadFile(fpath: string, path: string, md5?: string): Promise<string> {
|
|
||||||
if (!fs.existsSync(fpath)) {
|
|
||||||
return Promise.reject('File not found: ' + fpath);
|
|
||||||
}
|
|
||||||
console.log('Uploading: ' + fpath);
|
|
||||||
const stream = fs.createReadStream(fpath);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.s3.putObject(
|
|
||||||
{
|
|
||||||
Key: this.prefix + path,
|
|
||||||
Bucket: this.bucket,
|
|
||||||
Body: stream,
|
|
||||||
ContentType: getContentTypeForFile(path),
|
|
||||||
},
|
|
||||||
(err, data) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
if (md5 && md5 !== data.ETag && `"${md5}"` !== data.ETag) {
|
|
||||||
reject(`Upload ETag does not match MD5 (${md5} !== ${data.ETag})`);
|
|
||||||
} else {
|
|
||||||
resolve(data.ETag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async exists(key: string): Promise<boolean> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.s3.getObject(
|
|
||||||
{
|
|
||||||
Bucket: this.bucket,
|
|
||||||
Key: this.prefix + key,
|
|
||||||
},
|
|
||||||
(err, data) => {
|
|
||||||
if (err) {
|
|
||||||
resolve(false);
|
|
||||||
} else {
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async readJSON<T>(key: string, defaultValue: T): Promise<T> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.s3.getObject(
|
|
||||||
{
|
|
||||||
Bucket: this.bucket,
|
|
||||||
Key: this.prefix + key,
|
|
||||||
},
|
|
||||||
(err, data) => {
|
|
||||||
if (err) {
|
|
||||||
resolve(clone(defaultValue));
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const v = JSON.parse(data.Body as string);
|
|
||||||
resolve(defaults(v, defaultValue));
|
|
||||||
} catch (e) {
|
|
||||||
console.log('ERROR', e);
|
|
||||||
reject('Error reading response');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeJSON(
|
|
||||||
key: string,
|
|
||||||
obj: {},
|
|
||||||
params?: Partial<AWS.S3.Types.PutObjectRequest>
|
|
||||||
): Promise<AWS.S3.Types.PutObjectOutput> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.s3.putObject(
|
|
||||||
{
|
|
||||||
...params,
|
|
||||||
Key: this.prefix + key,
|
|
||||||
Bucket: this.bucket,
|
|
||||||
Body: JSON.stringify(obj, null, 2), // Pretty print
|
|
||||||
ContentType: 'application/json',
|
|
||||||
},
|
|
||||||
(err, data) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getContentTypeForFile(name: string): string | undefined {
|
|
||||||
const idx = name.lastIndexOf('.');
|
|
||||||
if (idx > 0) {
|
|
||||||
const ext = name.substring(idx + 1).toLowerCase();
|
|
||||||
if (ext === 'zip') {
|
|
||||||
return 'application/zip';
|
|
||||||
}
|
|
||||||
if (ext === 'json') {
|
|
||||||
return 'application/json';
|
|
||||||
}
|
|
||||||
if (ext === 'svg') {
|
|
||||||
return 'image/svg+xml';
|
|
||||||
}
|
|
||||||
if (ext === 'png') {
|
|
||||||
return 'image/png';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export * from './aws';
|
|
||||||
export * from './env';
|
export * from './env';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './workflow';
|
export * from './workflow';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"author": "Grafana Labs",
|
"author": "Grafana Labs",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"name": "@grafana/ui",
|
"name": "@grafana/ui",
|
||||||
"version": "6.4.0-pre",
|
"version": "6.5.0-beta.1",
|
||||||
"description": "Grafana Components Library",
|
"description": "Grafana Components Library",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"grafana",
|
"grafana",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"build": "grafana-toolkit package:build --scope=ui"
|
"build": "grafana-toolkit package:build --scope=ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grafana/data": "^6.4.0-alpha",
|
"@grafana/data": "6.5.0-beta.1",
|
||||||
"@grafana/slate-react": "0.22.9-grafana",
|
"@grafana/slate-react": "0.22.9-grafana",
|
||||||
"@torkelo/react-select": "2.1.1",
|
"@torkelo/react-select": "2.1.1",
|
||||||
"@types/react-color": "2.17.0",
|
"@types/react-color": "2.17.0",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ $arrowSize: 15px;
|
|||||||
.ColorPicker {
|
.ColorPicker {
|
||||||
@extend .popper;
|
@extend .popper;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
// !important because these styles are also provided to popper via .popper classes from Tooltip component
|
||||||
|
// hope to get rid of those soon
|
||||||
|
padding: $arrowSize !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ColorPicker__arrow {
|
.ColorPicker__arrow {
|
||||||
@@ -75,32 +78,19 @@ $arrowSize: 15px;
|
|||||||
border-color: #1e2028;
|
border-color: #1e2028;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top
|
// !important because these styles are also provided to popper via .popper classes from Tooltip component
|
||||||
.ColorPicker[data-placement^='top'] {
|
// hope to get rid of those soon
|
||||||
padding-bottom: $arrowSize;
|
.ColorPicker[data-placement^='top'],
|
||||||
}
|
|
||||||
|
|
||||||
// Bottom
|
|
||||||
.ColorPicker[data-placement^='bottom'] {
|
.ColorPicker[data-placement^='bottom'] {
|
||||||
padding-top: $arrowSize;
|
padding-left: 0 !important;
|
||||||
|
padding-right: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ColorPicker[data-placement^='bottom-start'] {
|
// !important because these styles are also provided to popper via .popper classes from Tooltip component
|
||||||
padding-top: $arrowSize;
|
// hope to get rid of those soon
|
||||||
}
|
.ColorPicker[data-placement^='left'],
|
||||||
|
|
||||||
.ColorPicker[data-placement^='bottom-end'] {
|
|
||||||
padding-top: $arrowSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right
|
|
||||||
.ColorPicker[data-placement^='right'] {
|
.ColorPicker[data-placement^='right'] {
|
||||||
padding-left: $arrowSize;
|
padding-top: 0 !important;
|
||||||
}
|
|
||||||
|
|
||||||
// Left
|
|
||||||
.ColorPicker[data-placement^='left'] {
|
|
||||||
padding-right: $arrowSize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ColorPickerPopover {
|
.ColorPickerPopover {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ set -e
|
|||||||
|
|
||||||
BUILD_FAST=0
|
BUILD_FAST=0
|
||||||
UBUNTU_BASE=0
|
UBUNTU_BASE=0
|
||||||
|
TAG_SUFFIX=""
|
||||||
|
|
||||||
while [ "$1" != "" ]; do
|
while [ "$1" != "" ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -13,6 +14,7 @@ while [ "$1" != "" ]; do
|
|||||||
;;
|
;;
|
||||||
"--ubuntu")
|
"--ubuntu")
|
||||||
UBUNTU_BASE=1
|
UBUNTU_BASE=1
|
||||||
|
TAG_SUFFIX="-ubuntu"
|
||||||
echo "Ubuntu base image enabled"
|
echo "Ubuntu base image enabled"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
@@ -33,20 +35,40 @@ else
|
|||||||
_grafana_version=$_grafana_tag
|
_grafana_version=$_grafana_tag
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ $UBUNTU_BASE = "0" ]; then
|
echo "Building ${_docker_repo}:${_grafana_version}${TAG_SUFFIX}"
|
||||||
echo "Building ${_docker_repo}:${_grafana_version}"
|
|
||||||
else
|
|
||||||
echo "Building ${_docker_repo}:${_grafana_version}-ubuntu"
|
|
||||||
fi
|
|
||||||
|
|
||||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||||
|
|
||||||
# Build grafana image for a specific arch
|
# Build grafana image for a specific arch
|
||||||
docker_build () {
|
docker_build () {
|
||||||
base_image=$1
|
arch=$1
|
||||||
grafana_tgz=$2
|
|
||||||
tag=$3
|
case "$arch" in
|
||||||
dockerfile=${4:-Dockerfile}
|
"x64")
|
||||||
|
base_arch=""
|
||||||
|
repo_arch=""
|
||||||
|
;;
|
||||||
|
"armv7")
|
||||||
|
base_arch="arm32v7/"
|
||||||
|
repo_arch="-arm32v7-linux"
|
||||||
|
;;
|
||||||
|
"arm64")
|
||||||
|
base_arch="arm64v8/"
|
||||||
|
repo_arch="-arm64v8-linux"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [ $UBUNTU_BASE = "0" ]; then
|
||||||
|
libc="-musl"
|
||||||
|
dockerfile="Dockerfile"
|
||||||
|
base_image="${base_arch}alpine:3.10"
|
||||||
|
else
|
||||||
|
libc=""
|
||||||
|
dockerfile="Dockerfile.ubuntu"
|
||||||
|
base_image="${base_arch}ubuntu:18.10"
|
||||||
|
fi
|
||||||
|
|
||||||
|
grafana_tgz="grafana-latest.linux-${arch}${libc}.tar.gz"
|
||||||
|
tag="${_docker_repo}${repo_arch}:${_grafana_version}${TAG_SUFFIX}"
|
||||||
|
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg BASE_IMAGE=${base_image} \
|
--build-arg BASE_IMAGE=${base_image} \
|
||||||
@@ -58,48 +80,32 @@ docker_build () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
docker_tag_linux_amd64 () {
|
docker_tag_linux_amd64 () {
|
||||||
repo=$1
|
tag=$1
|
||||||
tag=$2
|
docker tag "${_docker_repo}:${_grafana_version}${TAG_SUFFIX}" "${_docker_repo}:${tag}${TAG_SUFFIX}"
|
||||||
docker tag "${_docker_repo}:${_grafana_version}" "${repo}:${tag}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Tag docker images of all architectures
|
# Tag docker images of all architectures
|
||||||
docker_tag_all () {
|
docker_tag_all () {
|
||||||
repo=$1
|
tag=$1
|
||||||
tag=$2
|
docker_tag_linux_amd64 $1
|
||||||
docker_tag_linux_amd64 $1 $2
|
|
||||||
if [ $BUILD_FAST = "0" ]; then
|
if [ $BUILD_FAST = "0" ]; then
|
||||||
docker tag "${_docker_repo}-arm32v7-linux:${_grafana_version}" "${repo}-arm32v7-linux:${tag}"
|
docker tag "${_docker_repo}-arm32v7-linux:${_grafana_version}${TAG_SUFFIX}" "${_docker_repo}-arm32v7-linux:${tag}${TAG_SUFFIX}"
|
||||||
docker tag "${_docker_repo}-arm64v8-linux:${_grafana_version}" "${repo}-arm64v8-linux:${tag}"
|
docker tag "${_docker_repo}-arm64v8-linux:${_grafana_version}${TAG_SUFFIX}" "${_docker_repo}-arm64v8-linux:${tag}${TAG_SUFFIX}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ $UBUNTU_BASE = "0" ]; then
|
docker_build "x64"
|
||||||
docker_build "alpine:3.10" "grafana-latest.linux-x64-musl.tar.gz" "${_docker_repo}:${_grafana_version}"
|
if [ $BUILD_FAST = "0" ]; then
|
||||||
if [ $BUILD_FAST = "0" ]; then
|
docker_build "armv7"
|
||||||
docker_build "arm32v7/alpine:3.10" "grafana-latest.linux-armv7-musl.tar.gz" "${_docker_repo}-arm32v7-linux:${_grafana_version}"
|
docker_build "arm64"
|
||||||
docker_build "arm64v8/alpine:3.10" "grafana-latest.linux-arm64-musl.tar.gz" "${_docker_repo}-arm64v8-linux:${_grafana_version}"
|
fi
|
||||||
fi
|
|
||||||
|
# Tag as 'latest' for official release; otherwise tag as grafana/grafana:master
|
||||||
# Tag as 'latest' for official release; otherwise tag as grafana/grafana:master
|
if echo "$_grafana_tag" | grep -q "^v"; then
|
||||||
if echo "$_grafana_tag" | grep -q "^v"; then
|
docker_tag_all "latest"
|
||||||
docker_tag_all "${_docker_repo}" "latest"
|
# Create the expected tag for running the end to end tests successfully
|
||||||
# Create the expected tag for running the end to end tests successfully
|
docker tag "${_docker_repo}:${_grafana_version}${TAG_SUFFIX}" "grafana/grafana-dev:${_grafana_tag}${TAG_SUFFIX}"
|
||||||
docker tag "${_docker_repo}:${_grafana_version}" "grafana/grafana-dev:${_grafana_tag}"
|
else
|
||||||
else
|
docker_tag_all "master"
|
||||||
docker_tag_all "${_docker_repo}" "master"
|
docker tag "${_docker_repo}:${_grafana_version}${TAG_SUFFIX}" "grafana/grafana-dev:${_grafana_version}${TAG_SUFFIX}"
|
||||||
docker tag "${_docker_repo}:${_grafana_version}" "grafana/grafana-dev:${_grafana_version}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
docker_build "ubuntu:18.10" "grafana-latest.linux-x64.tar.gz" "${_docker_repo}:${_grafana_version}-ubuntu" Dockerfile.ubuntu
|
|
||||||
|
|
||||||
# Tag as 'latest-ubuntu' for official release; otherwise tag as grafana/grafana:master-ubuntu
|
|
||||||
if echo "$_grafana_tag" | grep -q "^v"; then
|
|
||||||
docker tag "${_docker_repo}:${_grafana_version}-ubuntu" "${_docker_repo}:latest-ubuntu"
|
|
||||||
# Create the expected tag for running the end to end tests successfully
|
|
||||||
docker tag "${_docker_repo}:${_grafana_version}-ubuntu" "grafana/grafana-dev:${_grafana_tag}-ubuntu"
|
|
||||||
else
|
|
||||||
docker tag "${_docker_repo}:${_grafana_version}-ubuntu" "${_docker_repo}:master-ubuntu"
|
|
||||||
docker tag "${_docker_repo}:${_grafana_version}-ubuntu" "grafana/grafana-dev:${_grafana_version}-ubuntu"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
UBUNTU_BASE=0
|
UBUNTU_BASE=0
|
||||||
|
TAG_SUFFIX=""
|
||||||
|
|
||||||
while [ "$1" != "" ]; do
|
while [ "$1" != "" ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
"--ubuntu")
|
"--ubuntu")
|
||||||
UBUNTU_BASE=1
|
UBUNTU_BASE=1
|
||||||
|
TAG_SUFFIX="-ubuntu"
|
||||||
echo "Ubuntu base image enabled"
|
echo "Ubuntu base image enabled"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
@@ -29,60 +31,39 @@ fi
|
|||||||
|
|
||||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||||
|
|
||||||
if [ $UBUNTU_BASE = "0" ]; then
|
echo "pushing ${_docker_repo}:${_grafana_version}${TAG_SUFFIX}"
|
||||||
echo "pushing ${_docker_repo}:${_grafana_version}"
|
|
||||||
else
|
|
||||||
echo "pushing ${_docker_repo}:${_grafana_version}-ubuntu"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
docker_push_all () {
|
docker_push_all () {
|
||||||
repo=$1
|
repo=$1
|
||||||
tag=$2
|
tag=$2
|
||||||
|
|
||||||
if [ $UBUNTU_BASE = "0" ]; then
|
# Push each image individually
|
||||||
# Push each image individually
|
docker push "${repo}:${tag}${TAG_SUFFIX}"
|
||||||
docker push "${repo}:${tag}"
|
docker push "${repo}-arm32v7-linux:${tag}${TAG_SUFFIX}"
|
||||||
docker push "${repo}-arm32v7-linux:${tag}"
|
docker push "${repo}-arm64v8-linux:${tag}${TAG_SUFFIX}"
|
||||||
docker push "${repo}-arm64v8-linux:${tag}"
|
|
||||||
|
|
||||||
# Create and push a multi-arch manifest
|
# Create and push a multi-arch manifest
|
||||||
docker manifest create "${repo}:${tag}" \
|
docker manifest create "${repo}:${tag}${TAG_SUFFIX}" \
|
||||||
"${repo}:${tag}" \
|
"${repo}:${tag}${TAG_SUFFIX}" \
|
||||||
"${repo}-arm32v7-linux:${tag}" \
|
"${repo}-arm32v7-linux:${tag}${TAG_SUFFIX}" \
|
||||||
"${repo}-arm64v8-linux:${tag}"
|
"${repo}-arm64v8-linux:${tag}${TAG_SUFFIX}"
|
||||||
|
|
||||||
docker manifest push "${repo}:${tag}"
|
docker manifest push "${repo}:${tag}${TAG_SUFFIX}"
|
||||||
else
|
|
||||||
docker push "${repo}:${tag}-ubuntu"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -vq "beta"; then
|
if echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -vq "beta"; then
|
||||||
echo "pushing ${_docker_repo}:latest"
|
echo "pushing ${_docker_repo}:latest${TAG_SUFFIX}"
|
||||||
docker_push_all "${_docker_repo}" "latest"
|
docker_push_all "${_docker_repo}" "latest"
|
||||||
docker_push_all "${_docker_repo}" "${_grafana_version}"
|
docker_push_all "${_docker_repo}" "${_grafana_version}"
|
||||||
# Push to the grafana-dev repository with the expected tag
|
# Push to the grafana-dev repository with the expected tag
|
||||||
# for running the end to end tests successfully
|
# for running the end to end tests successfully
|
||||||
if [ ${UBUNTU_BASE} = "0" ]; then
|
docker push "grafana/grafana-dev:${_grafana_tag}${TAG_SUFFIX}"
|
||||||
docker push "grafana/grafana-dev:${_grafana_tag}"
|
|
||||||
else
|
|
||||||
docker push "grafana/grafana-dev:${_grafana_tag}-ubuntu"
|
|
||||||
fi
|
|
||||||
elif echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -q "beta"; then
|
elif echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -q "beta"; then
|
||||||
docker_push_all "${_docker_repo}" "${_grafana_version}"
|
docker_push_all "${_docker_repo}" "${_grafana_version}"
|
||||||
# Push to the grafana-dev repository with the expected tag
|
# Push to the grafana-dev repository with the expected tag
|
||||||
# for running the end to end tests successfully
|
# for running the end to end tests successfully
|
||||||
if [ ${UBUNTU_BASE} = "0" ]; then
|
docker push "grafana/grafana-dev:${_grafana_tag}${TAG_SUFFIX}"
|
||||||
docker push "grafana/grafana-dev:${_grafana_tag}"
|
|
||||||
else
|
|
||||||
docker push "grafana/grafana-dev:${_grafana_tag}-ubuntu"
|
|
||||||
fi
|
|
||||||
elif echo "$_grafana_tag" | grep -q "master"; then
|
elif echo "$_grafana_tag" | grep -q "master"; then
|
||||||
docker_push_all "${_docker_repo}" "master"
|
docker_push_all "${_docker_repo}" "master"
|
||||||
if [ ${UBUNTU_BASE} = "0" ]; then
|
docker push "grafana/grafana-dev:${_grafana_version}${TAG_SUFFIX}"
|
||||||
docker push "grafana/grafana-dev:${_grafana_version}"
|
|
||||||
else
|
|
||||||
docker push "grafana/grafana-dev:${_grafana_version}-ubuntu"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -88,14 +88,15 @@ func (this *CacheServer) Handler(ctx *macaron.Context) {
|
|||||||
hash := urlPath[strings.LastIndex(urlPath, "/")+1:]
|
hash := urlPath[strings.LastIndex(urlPath, "/")+1:]
|
||||||
|
|
||||||
var avatar *Avatar
|
var avatar *Avatar
|
||||||
|
obj, exists := this.cache.Get(hash)
|
||||||
if obj, exist := this.cache.Get(hash); exist {
|
if exists {
|
||||||
avatar = obj.(*Avatar)
|
avatar = obj.(*Avatar)
|
||||||
} else {
|
} else {
|
||||||
avatar = New(hash)
|
avatar = New(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
if avatar.Expired() {
|
if avatar.Expired() {
|
||||||
|
// The cache item is either expired or newly created, update it from the server
|
||||||
if err := avatar.Update(); err != nil {
|
if err := avatar.Update(); err != nil {
|
||||||
log.Trace("avatar update error: %v", err)
|
log.Trace("avatar update error: %v", err)
|
||||||
avatar = this.notFound
|
avatar = this.notFound
|
||||||
@@ -104,9 +105,9 @@ func (this *CacheServer) Handler(ctx *macaron.Context) {
|
|||||||
|
|
||||||
if avatar.notFound {
|
if avatar.notFound {
|
||||||
avatar = this.notFound
|
avatar = this.notFound
|
||||||
} else {
|
} else if !exists {
|
||||||
if err := this.cache.Add(hash, avatar, gocache.DefaultExpiration); err != nil {
|
if err := this.cache.Add(hash, avatar, gocache.DefaultExpiration); err != nil {
|
||||||
log.Warn("Error adding avatar to cache: %s", err)
|
log.Trace("Error adding avatar to cache: %s", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +222,6 @@ func (this *thunderTask) fetch() error {
|
|||||||
req.Header.Set("Cache-Control", "no-cache")
|
req.Header.Set("Cache-Control", "no-cache")
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.154 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.154 Safari/537.36")
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
this.Avatar.notFound = true
|
this.Avatar.notFound = true
|
||||||
return fmt.Errorf("gravatar unreachable, %v", err)
|
return fmt.Errorf("gravatar unreachable, %v", err)
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ func (tn *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.EvalCo
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
imageFile, err = os.Open(evalContext.ImageOnDiskPath)
|
imageFile, err = os.Open(evalContext.ImageOnDiskPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
err := imageFile.Close()
|
err := imageFile.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -144,10 +148,6 @@ func (tn *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.EvalCo
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ruleURL, err := evalContext.GetRuleURL()
|
ruleURL, err := evalContext.GetRuleURL()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -230,7 +230,12 @@ func syncOrgRoles(user *models.User, extUser *models.ExternalUserInfo) error {
|
|||||||
// delete any removed org roles
|
// delete any removed org roles
|
||||||
for _, orgId := range deleteOrgIds {
|
for _, orgId := range deleteOrgIds {
|
||||||
cmd := &models.RemoveOrgUserCommand{OrgId: orgId, UserId: user.Id}
|
cmd := &models.RemoveOrgUserCommand{OrgId: orgId, UserId: user.Id}
|
||||||
if err := bus.Dispatch(cmd); err != nil {
|
err := bus.Dispatch(cmd)
|
||||||
|
if err == models.ErrLastOrgAdmin {
|
||||||
|
logger.Error(err.Error(), "userId", cmd.UserId, "orgId", cmd.OrgId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
pkg/services/login/login_test.go
Normal file
136
pkg/services/login/login_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package login
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
log "github.com/inconshreveable/log15"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_syncOrgRoles_doesNotBreakWhenTryingToRemoveLastOrgAdmin(t *testing.T) {
|
||||||
|
user := createSimpleUser()
|
||||||
|
externalUser := createSimpleExternalUser()
|
||||||
|
remResp := createResponseWithOneErrLastOrgAdminItem()
|
||||||
|
|
||||||
|
bus.ClearBusHandlers()
|
||||||
|
defer bus.ClearBusHandlers()
|
||||||
|
bus.AddHandler("test", func(q *models.GetUserOrgListQuery) error {
|
||||||
|
|
||||||
|
q.Result = createUserOrgDTO()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.AddHandler("test", func(cmd *models.RemoveOrgUserCommand) error {
|
||||||
|
testData := remResp[0]
|
||||||
|
remResp = remResp[1:]
|
||||||
|
|
||||||
|
require.Equal(t, testData.orgId, cmd.OrgId)
|
||||||
|
return testData.response
|
||||||
|
})
|
||||||
|
bus.AddHandler("test", func(cmd *models.SetUsingOrgCommand) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
err := syncOrgRoles(&user, &externalUser)
|
||||||
|
require.Empty(t, remResp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) {
|
||||||
|
var logOutput string
|
||||||
|
logger.SetHandler(log.FuncHandler(func(r *log.Record) error {
|
||||||
|
logOutput = r.Msg
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
user := createSimpleUser()
|
||||||
|
externalUser := createSimpleExternalUser()
|
||||||
|
remResp := createResponseWithOneErrLastOrgAdminItem()
|
||||||
|
|
||||||
|
bus.ClearBusHandlers()
|
||||||
|
defer bus.ClearBusHandlers()
|
||||||
|
bus.AddHandler("test", func(q *models.GetUserOrgListQuery) error {
|
||||||
|
|
||||||
|
q.Result = createUserOrgDTO()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.AddHandler("test", func(cmd *models.RemoveOrgUserCommand) error {
|
||||||
|
testData := remResp[0]
|
||||||
|
remResp = remResp[1:]
|
||||||
|
|
||||||
|
require.Equal(t, testData.orgId, cmd.OrgId)
|
||||||
|
return testData.response
|
||||||
|
})
|
||||||
|
bus.AddHandler("test", func(cmd *models.SetUsingOrgCommand) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
err := syncOrgRoles(&user, &externalUser)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, models.ErrLastOrgAdmin.Error(), logOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSimpleUser() models.User {
|
||||||
|
user := models.User{
|
||||||
|
Id: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func createUserOrgDTO() []*models.UserOrgDTO {
|
||||||
|
users := []*models.UserOrgDTO{
|
||||||
|
{
|
||||||
|
OrgId: 1,
|
||||||
|
Name: "Bar",
|
||||||
|
Role: models.ROLE_VIEWER,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OrgId: 10,
|
||||||
|
Name: "Foo",
|
||||||
|
Role: models.ROLE_ADMIN,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OrgId: 11,
|
||||||
|
Name: "Stuff",
|
||||||
|
Role: models.ROLE_VIEWER,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSimpleExternalUser() models.ExternalUserInfo {
|
||||||
|
externalUser := models.ExternalUserInfo{
|
||||||
|
AuthModule: "ldap",
|
||||||
|
OrgRoles: map[int64]models.RoleType{
|
||||||
|
1: models.ROLE_VIEWER,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return externalUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func createResponseWithOneErrLastOrgAdminItem() []struct {
|
||||||
|
orgId int64
|
||||||
|
response error
|
||||||
|
} {
|
||||||
|
remResp := []struct {
|
||||||
|
orgId int64
|
||||||
|
response error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
orgId: 10,
|
||||||
|
response: models.ErrLastOrgAdmin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
orgId: 11,
|
||||||
|
response: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return remResp
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo
|
|||||||
namespace := parameters.Get("namespace").MustString("")
|
namespace := parameters.Get("namespace").MustString("")
|
||||||
metricName := parameters.Get("metricName").MustString("")
|
metricName := parameters.Get("metricName").MustString("")
|
||||||
dimensions := parameters.Get("dimensions").MustMap()
|
dimensions := parameters.Get("dimensions").MustMap()
|
||||||
statistics, extendedStatistics, err := parseStatistics(parameters)
|
statistics, err := parseStatistics(parameters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("Failed to call cloudwatch:DescribeAlarms")
|
return nil, errors.New("Failed to call cloudwatch:DescribeAlarms")
|
||||||
}
|
}
|
||||||
alarmNames = filterAlarms(resp, namespace, metricName, dimensions, statistics, extendedStatistics, period)
|
alarmNames = filterAlarms(resp, namespace, metricName, dimensions, statistics, period)
|
||||||
} else {
|
} else {
|
||||||
if region == "" || namespace == "" || metricName == "" || len(statistics) == 0 {
|
if region == "" || namespace == "" || metricName == "" || len(statistics) == 0 {
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -82,22 +82,6 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo
|
|||||||
alarmNames = append(alarmNames, alarm.AlarmName)
|
alarmNames = append(alarmNames, alarm.AlarmName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, s := range extendedStatistics {
|
|
||||||
params := &cloudwatch.DescribeAlarmsForMetricInput{
|
|
||||||
Namespace: aws.String(namespace),
|
|
||||||
MetricName: aws.String(metricName),
|
|
||||||
Dimensions: qd,
|
|
||||||
ExtendedStatistic: aws.String(s),
|
|
||||||
Period: aws.Int64(period),
|
|
||||||
}
|
|
||||||
resp, err := svc.DescribeAlarmsForMetric(params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("Failed to call cloudwatch:DescribeAlarmsForMetric")
|
|
||||||
}
|
|
||||||
for _, alarm := range resp.MetricAlarms {
|
|
||||||
alarmNames = append(alarmNames, alarm.AlarmName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startTime, err := queryContext.TimeRange.ParseFrom()
|
startTime, err := queryContext.TimeRange.ParseFrom()
|
||||||
@@ -158,7 +142,7 @@ func transformAnnotationToTable(data []map[string]string, result *tsdb.QueryResu
|
|||||||
result.Meta.Set("rowCount", len(data))
|
result.Meta.Set("rowCount", len(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterAlarms(alarms *cloudwatch.DescribeAlarmsOutput, namespace string, metricName string, dimensions map[string]interface{}, statistics []string, extendedStatistics []string, period int64) []*string {
|
func filterAlarms(alarms *cloudwatch.DescribeAlarmsOutput, namespace string, metricName string, dimensions map[string]interface{}, statistics []string, period int64) []*string {
|
||||||
alarmNames := make([]*string, 0)
|
alarmNames := make([]*string, 0)
|
||||||
|
|
||||||
for _, alarm := range alarms.MetricAlarms {
|
for _, alarm := range alarms.MetricAlarms {
|
||||||
@@ -197,18 +181,6 @@ func filterAlarms(alarms *cloudwatch.DescribeAlarmsOutput, namespace string, met
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(extendedStatistics) != 0 {
|
|
||||||
found := false
|
|
||||||
for _, s := range extendedStatistics {
|
|
||||||
if *alarm.Statistic == s {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if period != 0 && *alarm.Period != period {
|
if period != 0 && *alarm.Period != period {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,13 @@ package cloudwatch
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
|
||||||
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
|
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
|
||||||
"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface"
|
"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/tsdb"
|
"github.com/grafana/grafana/pkg/tsdb"
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type CloudWatchExecutor struct {
|
type CloudWatchExecutor struct {
|
||||||
@@ -38,21 +33,13 @@ func NewCloudWatchExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
plog log.Logger
|
plog log.Logger
|
||||||
standardStatistics map[string]bool
|
aliasFormat *regexp.Regexp
|
||||||
aliasFormat *regexp.Regexp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
plog = log.New("tsdb.cloudwatch")
|
plog = log.New("tsdb.cloudwatch")
|
||||||
tsdb.RegisterTsdbQueryEndpoint("cloudwatch", NewCloudWatchExecutor)
|
tsdb.RegisterTsdbQueryEndpoint("cloudwatch", NewCloudWatchExecutor)
|
||||||
standardStatistics = map[string]bool{
|
|
||||||
"Average": true,
|
|
||||||
"Maximum": true,
|
|
||||||
"Minimum": true,
|
|
||||||
"Sum": true,
|
|
||||||
"SampleCount": true,
|
|
||||||
}
|
|
||||||
aliasFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`)
|
aliasFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,162 +62,3 @@ func (e *CloudWatchExecutor) Query(ctx context.Context, dsInfo *models.DataSourc
|
|||||||
|
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) {
|
|
||||||
results := &tsdb.Response{
|
|
||||||
Results: make(map[string]*tsdb.QueryResult),
|
|
||||||
}
|
|
||||||
resultChan := make(chan *tsdb.QueryResult, len(queryContext.Queries))
|
|
||||||
|
|
||||||
eg, ectx := errgroup.WithContext(ctx)
|
|
||||||
|
|
||||||
getMetricDataQueries := make(map[string]map[string]*CloudWatchQuery)
|
|
||||||
for i, model := range queryContext.Queries {
|
|
||||||
queryType := model.Model.Get("type").MustString()
|
|
||||||
if queryType != "timeSeriesQuery" && queryType != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
RefId := queryContext.Queries[i].RefId
|
|
||||||
query, err := parseQuery(queryContext.Queries[i].Model)
|
|
||||||
if err != nil {
|
|
||||||
results.Results[RefId] = &tsdb.QueryResult{
|
|
||||||
Error: err,
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
query.RefId = RefId
|
|
||||||
|
|
||||||
if query.Id != "" {
|
|
||||||
if _, ok := getMetricDataQueries[query.Region]; !ok {
|
|
||||||
getMetricDataQueries[query.Region] = make(map[string]*CloudWatchQuery)
|
|
||||||
}
|
|
||||||
getMetricDataQueries[query.Region][query.Id] = query
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if query.Id == "" && query.Expression != "" {
|
|
||||||
results.Results[query.RefId] = &tsdb.QueryResult{
|
|
||||||
Error: fmt.Errorf("Invalid query: id should be set if using expression"),
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
eg.Go(func() error {
|
|
||||||
defer func() {
|
|
||||||
if err := recover(); err != nil {
|
|
||||||
plog.Error("Execute Query Panic", "error", err, "stack", log.Stack(1))
|
|
||||||
if theErr, ok := err.(error); ok {
|
|
||||||
resultChan <- &tsdb.QueryResult{
|
|
||||||
RefId: query.RefId,
|
|
||||||
Error: theErr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
queryRes, err := e.executeQuery(ectx, query, queryContext)
|
|
||||||
if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
resultChan <- &tsdb.QueryResult{
|
|
||||||
RefId: query.RefId,
|
|
||||||
Error: err,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
resultChan <- queryRes
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(getMetricDataQueries) > 0 {
|
|
||||||
for region, getMetricDataQuery := range getMetricDataQueries {
|
|
||||||
q := getMetricDataQuery
|
|
||||||
eg.Go(func() error {
|
|
||||||
defer func() {
|
|
||||||
if err := recover(); err != nil {
|
|
||||||
plog.Error("Execute Get Metric Data Query Panic", "error", err, "stack", log.Stack(1))
|
|
||||||
if theErr, ok := err.(error); ok {
|
|
||||||
resultChan <- &tsdb.QueryResult{
|
|
||||||
Error: theErr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
queryResponses, err := e.executeGetMetricDataQuery(ectx, region, q, queryContext)
|
|
||||||
if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, queryRes := range queryResponses {
|
|
||||||
if err != nil {
|
|
||||||
queryRes.Error = err
|
|
||||||
}
|
|
||||||
resultChan <- queryRes
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := eg.Wait(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
close(resultChan)
|
|
||||||
for result := range resultChan {
|
|
||||||
results.Results[result.RefId] = result
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]string, label string) string {
|
|
||||||
region := query.Region
|
|
||||||
namespace := query.Namespace
|
|
||||||
metricName := query.MetricName
|
|
||||||
period := strconv.Itoa(query.Period)
|
|
||||||
if len(query.Id) > 0 && len(query.Expression) > 0 {
|
|
||||||
if strings.Index(query.Expression, "SEARCH(") == 0 {
|
|
||||||
pIndex := strings.LastIndex(query.Expression, ",")
|
|
||||||
period = strings.Trim(query.Expression[pIndex+1:], " )")
|
|
||||||
sIndex := strings.LastIndex(query.Expression[:pIndex], ",")
|
|
||||||
stat = strings.Trim(query.Expression[sIndex+1:pIndex], " '")
|
|
||||||
} else if len(query.Alias) > 0 {
|
|
||||||
// expand by Alias
|
|
||||||
} else {
|
|
||||||
return query.Id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data := map[string]string{}
|
|
||||||
data["region"] = region
|
|
||||||
data["namespace"] = namespace
|
|
||||||
data["metric"] = metricName
|
|
||||||
data["stat"] = stat
|
|
||||||
data["period"] = period
|
|
||||||
if len(label) != 0 {
|
|
||||||
data["label"] = label
|
|
||||||
}
|
|
||||||
for k, v := range dimensions {
|
|
||||||
data[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
result := aliasFormat.ReplaceAllFunc([]byte(query.Alias), func(in []byte) []byte {
|
|
||||||
labelName := strings.Replace(string(in), "{{", "", 1)
|
|
||||||
labelName = strings.Replace(labelName, "}}", "", 1)
|
|
||||||
labelName = strings.TrimSpace(labelName)
|
|
||||||
if val, exists := data[labelName]; exists {
|
|
||||||
return []byte(val)
|
|
||||||
}
|
|
||||||
|
|
||||||
return in
|
|
||||||
})
|
|
||||||
|
|
||||||
if string(result) == "" {
|
|
||||||
return metricName + "_" + stat
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(result)
|
|
||||||
}
|
|
||||||
|
|||||||
62
pkg/tsdb/cloudwatch/cloudwatch_query.go
Normal file
62
pkg/tsdb/cloudwatch/cloudwatch_query.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cloudWatchQuery struct {
|
||||||
|
RefId string
|
||||||
|
Region string
|
||||||
|
Id string
|
||||||
|
Namespace string
|
||||||
|
MetricName string
|
||||||
|
Stats string
|
||||||
|
Expression string
|
||||||
|
ReturnData bool
|
||||||
|
Dimensions map[string][]string
|
||||||
|
Period int
|
||||||
|
Alias string
|
||||||
|
Identifier string
|
||||||
|
HighResolution bool
|
||||||
|
MatchExact bool
|
||||||
|
UsedExpression string
|
||||||
|
RequestExceededMaxLimit bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *cloudWatchQuery) isMathExpression() bool {
|
||||||
|
return q.Expression != "" && !q.isUserDefinedSearchExpression()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *cloudWatchQuery) isSearchExpression() bool {
|
||||||
|
return q.isUserDefinedSearchExpression() || q.isInferredSearchExpression()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *cloudWatchQuery) isUserDefinedSearchExpression() bool {
|
||||||
|
return strings.Contains(q.Expression, "SEARCH(")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *cloudWatchQuery) isInferredSearchExpression() bool {
|
||||||
|
if len(q.Dimensions) == 0 {
|
||||||
|
return !q.MatchExact
|
||||||
|
}
|
||||||
|
|
||||||
|
if !q.MatchExact {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, values := range q.Dimensions {
|
||||||
|
if len(values) > 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, v := range values {
|
||||||
|
if v == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *cloudWatchQuery) isMetricStat() bool {
|
||||||
|
return !q.isSearchExpression() && !q.isMathExpression()
|
||||||
|
}
|
||||||
175
pkg/tsdb/cloudwatch/cloudwatch_query_test.go
Normal file
175
pkg/tsdb/cloudwatch/cloudwatch_query_test.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCloudWatchQuery(t *testing.T) {
|
||||||
|
Convey("TestCloudWatchQuery", t, func() {
|
||||||
|
Convey("and SEARCH(someexpression) was specified in the query editor", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
RefId: "A",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Expression: "SEARCH(someexpression)",
|
||||||
|
Stats: "Average",
|
||||||
|
Period: 300,
|
||||||
|
Id: "id1",
|
||||||
|
Identifier: "id1",
|
||||||
|
}
|
||||||
|
|
||||||
|
Convey("it is a search expression", func() {
|
||||||
|
So(query.isSearchExpression(), ShouldBeTrue)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it is not math expressions", func() {
|
||||||
|
So(query.isMathExpression(), ShouldBeFalse)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and no expression, no multi dimension key values and no * was used", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
RefId: "A",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Expression: "",
|
||||||
|
Stats: "Average",
|
||||||
|
Period: 300,
|
||||||
|
Id: "id1",
|
||||||
|
Identifier: "id1",
|
||||||
|
MatchExact: true,
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"InstanceId": {"i-12345678"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Convey("it is not a search expression", func() {
|
||||||
|
So(query.isSearchExpression(), ShouldBeFalse)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it is not math expressions", func() {
|
||||||
|
So(query.isMathExpression(), ShouldBeFalse)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and no expression but multi dimension key values exist", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
RefId: "A",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Expression: "",
|
||||||
|
Stats: "Average",
|
||||||
|
Period: 300,
|
||||||
|
Id: "id1",
|
||||||
|
Identifier: "id1",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"InstanceId": {"i-12345678", "i-34562312"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Convey("it is a search expression", func() {
|
||||||
|
So(query.isSearchExpression(), ShouldBeTrue)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it is not math expressions", func() {
|
||||||
|
So(query.isMathExpression(), ShouldBeFalse)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and no expression but dimension values has *", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
RefId: "A",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Expression: "",
|
||||||
|
Stats: "Average",
|
||||||
|
Period: 300,
|
||||||
|
Id: "id1",
|
||||||
|
Identifier: "id1",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"InstanceId": {"i-12345678", "*"},
|
||||||
|
"InstanceType": {"abc", "def"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Convey("it is not a search expression", func() {
|
||||||
|
So(query.isSearchExpression(), ShouldBeTrue)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it is not math expressions", func() {
|
||||||
|
So(query.isMathExpression(), ShouldBeFalse)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and no dimensions were added", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
RefId: "A",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Expression: "",
|
||||||
|
Stats: "Average",
|
||||||
|
Period: 300,
|
||||||
|
Id: "id1",
|
||||||
|
MatchExact: false,
|
||||||
|
Identifier: "id1",
|
||||||
|
Dimensions: make(map[string][]string),
|
||||||
|
}
|
||||||
|
Convey("and match exact is false", func() {
|
||||||
|
query.MatchExact = false
|
||||||
|
Convey("it is a search expression", func() {
|
||||||
|
So(query.isSearchExpression(), ShouldBeTrue)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it is not math expressions", func() {
|
||||||
|
So(query.isMathExpression(), ShouldBeFalse)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it is not metric stat", func() {
|
||||||
|
So(query.isMetricStat(), ShouldBeFalse)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and match exact is true", func() {
|
||||||
|
query.MatchExact = true
|
||||||
|
Convey("it is a search expression", func() {
|
||||||
|
So(query.isSearchExpression(), ShouldBeFalse)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it is not math expressions", func() {
|
||||||
|
So(query.isMathExpression(), ShouldBeFalse)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it is a metric stat", func() {
|
||||||
|
So(query.isMetricStat(), ShouldBeTrue)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and match exact is", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
RefId: "A",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Expression: "",
|
||||||
|
Stats: "Average",
|
||||||
|
Period: 300,
|
||||||
|
Id: "id1",
|
||||||
|
Identifier: "id1",
|
||||||
|
MatchExact: false,
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"InstanceId": {"i-12345678"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Convey("it is a search expression", func() {
|
||||||
|
So(query.isSearchExpression(), ShouldBeTrue)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it is not math expressions", func() {
|
||||||
|
So(query.isMathExpression(), ShouldBeFalse)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it is not metric stat", func() {
|
||||||
|
So(query.isMetricStat(), ShouldBeFalse)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package cloudwatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/models"
|
|
||||||
"github.com/grafana/grafana/pkg/tsdb"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCloudWatch(t *testing.T) {
|
|
||||||
Convey("CloudWatch", t, func() {
|
|
||||||
|
|
||||||
Convey("executeQuery", func() {
|
|
||||||
e := &CloudWatchExecutor{
|
|
||||||
DataSource: &models.DataSource{
|
|
||||||
JsonData: simplejson.New(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
Convey("End time before start time should result in error", func() {
|
|
||||||
_, err := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-2h")})
|
|
||||||
So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time")
|
|
||||||
})
|
|
||||||
|
|
||||||
Convey("End time equals start time should result in error", func() {
|
|
||||||
_, err := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-1h")})
|
|
||||||
So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package cloudwatch
|
|
||||||
|
|
||||||
var cloudwatchUnitMappings = map[string]string{
|
|
||||||
"Seconds": "s",
|
|
||||||
"Microseconds": "µs",
|
|
||||||
"Milliseconds": "ms",
|
|
||||||
"Bytes": "bytes",
|
|
||||||
"Kilobytes": "kbytes",
|
|
||||||
"Megabytes": "mbytes",
|
|
||||||
"Gigabytes": "gbytes",
|
|
||||||
//"Terabytes": "",
|
|
||||||
"Bits": "bits",
|
|
||||||
//"Kilobits": "",
|
|
||||||
//"Megabits": "",
|
|
||||||
//"Gigabits": "",
|
|
||||||
//"Terabits": "",
|
|
||||||
"Percent": "percent",
|
|
||||||
//"Count": "",
|
|
||||||
"Bytes/Second": "Bps",
|
|
||||||
"Kilobytes/Second": "KBs",
|
|
||||||
"Megabytes/Second": "MBs",
|
|
||||||
"Gigabytes/Second": "GBs",
|
|
||||||
//"Terabytes/Second": "",
|
|
||||||
"Bits/Second": "bps",
|
|
||||||
"Kilobits/Second": "Kbits",
|
|
||||||
"Megabits/Second": "Mbits",
|
|
||||||
"Gigabits/Second": "Gbits",
|
|
||||||
//"Terabits/Second": "",
|
|
||||||
//"Count/Second": "",
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,11 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go/aws/credentials/endpointcreds"
|
"github.com/aws/aws-sdk-go/aws/credentials/endpointcreds"
|
||||||
"github.com/aws/aws-sdk-go/aws/defaults"
|
"github.com/aws/aws-sdk-go/aws/defaults"
|
||||||
"github.com/aws/aws-sdk-go/aws/ec2metadata"
|
"github.com/aws/aws-sdk-go/aws/ec2metadata"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/request"
|
||||||
"github.com/aws/aws-sdk-go/aws/session"
|
"github.com/aws/aws-sdk-go/aws/session"
|
||||||
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||||
"github.com/aws/aws-sdk-go/service/sts"
|
"github.com/aws/aws-sdk-go/service/sts"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
type cache struct {
|
type cache struct {
|
||||||
@@ -180,6 +182,7 @@ func (e *CloudWatchExecutor) getAwsConfig(dsInfo *DatasourceInfo) (*aws.Config,
|
|||||||
Region: aws.String(dsInfo.Region),
|
Region: aws.String(dsInfo.Region),
|
||||||
Credentials: creds,
|
Credentials: creds,
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,5 +199,10 @@ func (e *CloudWatchExecutor) getClient(region string) (*cloudwatch.CloudWatch, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := cloudwatch.New(sess, cfg)
|
client := cloudwatch.New(sess, cfg)
|
||||||
|
|
||||||
|
client.Handlers.Send.PushFront(func(r *request.Request) {
|
||||||
|
r.HTTPRequest.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion))
|
||||||
|
})
|
||||||
|
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
package cloudwatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
|
||||||
"github.com/grafana/grafana/pkg/components/null"
|
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
|
||||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
|
||||||
"github.com/grafana/grafana/pkg/tsdb"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *CloudWatchExecutor) executeGetMetricDataQuery(ctx context.Context, region string, queries map[string]*CloudWatchQuery, queryContext *tsdb.TsdbQuery) ([]*tsdb.QueryResult, error) {
|
|
||||||
queryResponses := make([]*tsdb.QueryResult, 0)
|
|
||||||
|
|
||||||
client, err := e.getClient(region)
|
|
||||||
if err != nil {
|
|
||||||
return queryResponses, err
|
|
||||||
}
|
|
||||||
|
|
||||||
params, err := parseGetMetricDataQuery(queries, queryContext)
|
|
||||||
if err != nil {
|
|
||||||
return queryResponses, err
|
|
||||||
}
|
|
||||||
|
|
||||||
nextToken := ""
|
|
||||||
mdr := make(map[string]map[string]*cloudwatch.MetricDataResult)
|
|
||||||
for {
|
|
||||||
if nextToken != "" {
|
|
||||||
params.NextToken = aws.String(nextToken)
|
|
||||||
}
|
|
||||||
resp, err := client.GetMetricDataWithContext(ctx, params)
|
|
||||||
if err != nil {
|
|
||||||
return queryResponses, err
|
|
||||||
}
|
|
||||||
metrics.MAwsCloudWatchGetMetricData.Add(float64(len(params.MetricDataQueries)))
|
|
||||||
|
|
||||||
for _, r := range resp.MetricDataResults {
|
|
||||||
if _, ok := mdr[*r.Id]; !ok {
|
|
||||||
mdr[*r.Id] = make(map[string]*cloudwatch.MetricDataResult)
|
|
||||||
mdr[*r.Id][*r.Label] = r
|
|
||||||
} else if _, ok := mdr[*r.Id][*r.Label]; !ok {
|
|
||||||
mdr[*r.Id][*r.Label] = r
|
|
||||||
} else {
|
|
||||||
mdr[*r.Id][*r.Label].Timestamps = append(mdr[*r.Id][*r.Label].Timestamps, r.Timestamps...)
|
|
||||||
mdr[*r.Id][*r.Label].Values = append(mdr[*r.Id][*r.Label].Values, r.Values...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.NextToken == nil || *resp.NextToken == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
nextToken = *resp.NextToken
|
|
||||||
}
|
|
||||||
|
|
||||||
for id, lr := range mdr {
|
|
||||||
queryRes, err := parseGetMetricDataResponse(lr, queries[id])
|
|
||||||
if err != nil {
|
|
||||||
return queryResponses, err
|
|
||||||
}
|
|
||||||
queryResponses = append(queryResponses, queryRes)
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryResponses, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGetMetricDataQuery(queries map[string]*CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*cloudwatch.GetMetricDataInput, error) {
|
|
||||||
// validate query
|
|
||||||
for _, query := range queries {
|
|
||||||
if !(len(query.Statistics) == 1 && len(query.ExtendedStatistics) == 0) &&
|
|
||||||
!(len(query.Statistics) == 0 && len(query.ExtendedStatistics) == 1) {
|
|
||||||
return nil, errors.New("Statistics count should be 1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startTime, err := queryContext.TimeRange.ParseFrom()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
endTime, err := queryContext.TimeRange.ParseTo()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
params := &cloudwatch.GetMetricDataInput{
|
|
||||||
StartTime: aws.Time(startTime),
|
|
||||||
EndTime: aws.Time(endTime),
|
|
||||||
ScanBy: aws.String("TimestampAscending"),
|
|
||||||
}
|
|
||||||
for _, query := range queries {
|
|
||||||
// 1 minutes resolution metrics is stored for 15 days, 15 * 24 * 60 = 21600
|
|
||||||
if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) {
|
|
||||||
return nil, errors.New("too long query period")
|
|
||||||
}
|
|
||||||
|
|
||||||
mdq := &cloudwatch.MetricDataQuery{
|
|
||||||
Id: aws.String(query.Id),
|
|
||||||
ReturnData: aws.Bool(query.ReturnData),
|
|
||||||
}
|
|
||||||
if query.Expression != "" {
|
|
||||||
mdq.Expression = aws.String(query.Expression)
|
|
||||||
} else {
|
|
||||||
mdq.MetricStat = &cloudwatch.MetricStat{
|
|
||||||
Metric: &cloudwatch.Metric{
|
|
||||||
Namespace: aws.String(query.Namespace),
|
|
||||||
MetricName: aws.String(query.MetricName),
|
|
||||||
},
|
|
||||||
Period: aws.Int64(int64(query.Period)),
|
|
||||||
}
|
|
||||||
for _, d := range query.Dimensions {
|
|
||||||
mdq.MetricStat.Metric.Dimensions = append(mdq.MetricStat.Metric.Dimensions,
|
|
||||||
&cloudwatch.Dimension{
|
|
||||||
Name: d.Name,
|
|
||||||
Value: d.Value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(query.Statistics) == 1 {
|
|
||||||
mdq.MetricStat.Stat = query.Statistics[0]
|
|
||||||
} else {
|
|
||||||
mdq.MetricStat.Stat = query.ExtendedStatistics[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
params.MetricDataQueries = append(params.MetricDataQueries, mdq)
|
|
||||||
}
|
|
||||||
return params, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGetMetricDataResponse(lr map[string]*cloudwatch.MetricDataResult, query *CloudWatchQuery) (*tsdb.QueryResult, error) {
|
|
||||||
queryRes := tsdb.NewQueryResult()
|
|
||||||
queryRes.RefId = query.RefId
|
|
||||||
|
|
||||||
for label, r := range lr {
|
|
||||||
if *r.StatusCode != "Complete" {
|
|
||||||
return queryRes, fmt.Errorf("Part of query is failed: %s", *r.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
series := tsdb.TimeSeries{
|
|
||||||
Tags: map[string]string{},
|
|
||||||
Points: make([]tsdb.TimePoint, 0),
|
|
||||||
}
|
|
||||||
for _, d := range query.Dimensions {
|
|
||||||
series.Tags[*d.Name] = *d.Value
|
|
||||||
}
|
|
||||||
s := ""
|
|
||||||
if len(query.Statistics) == 1 {
|
|
||||||
s = *query.Statistics[0]
|
|
||||||
} else {
|
|
||||||
s = *query.ExtendedStatistics[0]
|
|
||||||
}
|
|
||||||
series.Name = formatAlias(query, s, series.Tags, label)
|
|
||||||
|
|
||||||
for j, t := range r.Timestamps {
|
|
||||||
if j > 0 {
|
|
||||||
expectedTimestamp := r.Timestamps[j-1].Add(time.Duration(query.Period) * time.Second)
|
|
||||||
if expectedTimestamp.Before(*t) {
|
|
||||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), float64(expectedTimestamp.Unix()*1000)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(*r.Values[j]), float64((*t).Unix())*1000))
|
|
||||||
}
|
|
||||||
|
|
||||||
queryRes.Series = append(queryRes.Series, &series)
|
|
||||||
queryRes.Meta = simplejson.New()
|
|
||||||
}
|
|
||||||
return queryRes, nil
|
|
||||||
}
|
|
||||||
34
pkg/tsdb/cloudwatch/get_metric_data_executor.go
Normal file
34
pkg/tsdb/cloudwatch/get_metric_data_executor.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *CloudWatchExecutor) executeRequest(ctx context.Context, client cloudWatchClient, metricDataInput *cloudwatch.GetMetricDataInput) ([]*cloudwatch.GetMetricDataOutput, error) {
|
||||||
|
mdo := make([]*cloudwatch.GetMetricDataOutput, 0)
|
||||||
|
|
||||||
|
nextToken := ""
|
||||||
|
for {
|
||||||
|
if nextToken != "" {
|
||||||
|
metricDataInput.NextToken = aws.String(nextToken)
|
||||||
|
}
|
||||||
|
resp, err := client.GetMetricDataWithContext(ctx, metricDataInput)
|
||||||
|
if err != nil {
|
||||||
|
return mdo, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mdo = append(mdo, resp)
|
||||||
|
metrics.MAwsCloudWatchGetMetricData.Add(float64(len(metricDataInput.MetricDataQueries)))
|
||||||
|
|
||||||
|
if resp.NextToken == nil || *resp.NextToken == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nextToken = *resp.NextToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return mdo, nil
|
||||||
|
}
|
||||||
50
pkg/tsdb/cloudwatch/get_metric_data_executor_test.go
Normal file
50
pkg/tsdb/cloudwatch/get_metric_data_executor_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/request"
|
||||||
|
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
)
|
||||||
|
|
||||||
|
var counter = 1
|
||||||
|
|
||||||
|
type cloudWatchFakeClient struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *cloudWatchFakeClient) GetMetricDataWithContext(ctx aws.Context, input *cloudwatch.GetMetricDataInput, opts ...request.Option) (*cloudwatch.GetMetricDataOutput, error) {
|
||||||
|
nextToken := "next"
|
||||||
|
res := []*cloudwatch.MetricDataResult{{
|
||||||
|
Values: []*float64{aws.Float64(12.3), aws.Float64(23.5)},
|
||||||
|
}}
|
||||||
|
if counter == 0 {
|
||||||
|
nextToken = ""
|
||||||
|
res = []*cloudwatch.MetricDataResult{{
|
||||||
|
Values: []*float64{aws.Float64(100)},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
counter--
|
||||||
|
return &cloudwatch.GetMetricDataOutput{
|
||||||
|
MetricDataResults: res,
|
||||||
|
NextToken: aws.String(nextToken),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMetricDataExecutorTest(t *testing.T) {
|
||||||
|
Convey("TestGetMetricDataExecutorTest", t, func() {
|
||||||
|
Convey("pagination works", func() {
|
||||||
|
executor := &CloudWatchExecutor{}
|
||||||
|
inputs := &cloudwatch.GetMetricDataInput{MetricDataQueries: []*cloudwatch.MetricDataQuery{}}
|
||||||
|
res, err := executor.executeRequest(context.Background(), &cloudWatchFakeClient{}, inputs)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(len(res), ShouldEqual, 2)
|
||||||
|
So(len(res[0].MetricDataResults[0].Values), ShouldEqual, 2)
|
||||||
|
So(*res[0].MetricDataResults[0].Values[1], ShouldEqual, 23.5)
|
||||||
|
So(*res[1].MetricDataResults[0].Values[0], ShouldEqual, 100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package cloudwatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
|
||||||
"github.com/grafana/grafana/pkg/components/null"
|
|
||||||
"github.com/grafana/grafana/pkg/tsdb"
|
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCloudWatchGetMetricData(t *testing.T) {
|
|
||||||
Convey("CloudWatchGetMetricData", t, func() {
|
|
||||||
|
|
||||||
Convey("can parse cloudwatch GetMetricData query", func() {
|
|
||||||
queries := map[string]*CloudWatchQuery{
|
|
||||||
"id1": {
|
|
||||||
RefId: "A",
|
|
||||||
Region: "us-east-1",
|
|
||||||
Namespace: "AWS/EC2",
|
|
||||||
MetricName: "CPUUtilization",
|
|
||||||
Dimensions: []*cloudwatch.Dimension{
|
|
||||||
{
|
|
||||||
Name: aws.String("InstanceId"),
|
|
||||||
Value: aws.String("i-12345678"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Statistics: []*string{aws.String("Average")},
|
|
||||||
Period: 300,
|
|
||||||
Id: "id1",
|
|
||||||
Expression: "",
|
|
||||||
},
|
|
||||||
"id2": {
|
|
||||||
RefId: "B",
|
|
||||||
Region: "us-east-1",
|
|
||||||
Statistics: []*string{aws.String("Average")},
|
|
||||||
Id: "id2",
|
|
||||||
Expression: "id1 * 2",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
queryContext := &tsdb.TsdbQuery{
|
|
||||||
TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()),
|
|
||||||
}
|
|
||||||
res, err := parseGetMetricDataQuery(queries, queryContext)
|
|
||||||
So(err, ShouldBeNil)
|
|
||||||
|
|
||||||
for _, v := range res.MetricDataQueries {
|
|
||||||
if *v.Id == "id1" {
|
|
||||||
So(*v.MetricStat.Metric.Namespace, ShouldEqual, "AWS/EC2")
|
|
||||||
So(*v.MetricStat.Metric.MetricName, ShouldEqual, "CPUUtilization")
|
|
||||||
So(*v.MetricStat.Metric.Dimensions[0].Name, ShouldEqual, "InstanceId")
|
|
||||||
So(*v.MetricStat.Metric.Dimensions[0].Value, ShouldEqual, "i-12345678")
|
|
||||||
So(*v.MetricStat.Period, ShouldEqual, 300)
|
|
||||||
So(*v.MetricStat.Stat, ShouldEqual, "Average")
|
|
||||||
So(*v.Id, ShouldEqual, "id1")
|
|
||||||
} else {
|
|
||||||
So(*v.Id, ShouldEqual, "id2")
|
|
||||||
So(*v.Expression, ShouldEqual, "id1 * 2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Convey("can parse cloudwatch response", func() {
|
|
||||||
timestamp := time.Unix(0, 0)
|
|
||||||
resp := map[string]*cloudwatch.MetricDataResult{
|
|
||||||
"label": {
|
|
||||||
Id: aws.String("id1"),
|
|
||||||
Label: aws.String("label"),
|
|
||||||
Timestamps: []*time.Time{
|
|
||||||
aws.Time(timestamp),
|
|
||||||
aws.Time(timestamp.Add(60 * time.Second)),
|
|
||||||
aws.Time(timestamp.Add(180 * time.Second)),
|
|
||||||
},
|
|
||||||
Values: []*float64{
|
|
||||||
aws.Float64(10),
|
|
||||||
aws.Float64(20),
|
|
||||||
aws.Float64(30),
|
|
||||||
},
|
|
||||||
StatusCode: aws.String("Complete"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
query := &CloudWatchQuery{
|
|
||||||
RefId: "refId1",
|
|
||||||
Region: "us-east-1",
|
|
||||||
Namespace: "AWS/ApplicationELB",
|
|
||||||
MetricName: "TargetResponseTime",
|
|
||||||
Dimensions: []*cloudwatch.Dimension{
|
|
||||||
{
|
|
||||||
Name: aws.String("LoadBalancer"),
|
|
||||||
Value: aws.String("lb"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: aws.String("TargetGroup"),
|
|
||||||
Value: aws.String("tg"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Statistics: []*string{aws.String("Average")},
|
|
||||||
Period: 60,
|
|
||||||
Alias: "{{namespace}}_{{metric}}_{{stat}}",
|
|
||||||
}
|
|
||||||
queryRes, err := parseGetMetricDataResponse(resp, query)
|
|
||||||
So(err, ShouldBeNil)
|
|
||||||
So(queryRes.RefId, ShouldEqual, "refId1")
|
|
||||||
So(queryRes.Series[0].Name, ShouldEqual, "AWS/ApplicationELB_TargetResponseTime_Average")
|
|
||||||
So(queryRes.Series[0].Tags["LoadBalancer"], ShouldEqual, "lb")
|
|
||||||
So(queryRes.Series[0].Tags["TargetGroup"], ShouldEqual, "tg")
|
|
||||||
So(queryRes.Series[0].Points[0][0].String(), ShouldEqual, null.FloatFrom(10.0).String())
|
|
||||||
So(queryRes.Series[0].Points[1][0].String(), ShouldEqual, null.FloatFrom(20.0).String())
|
|
||||||
So(queryRes.Series[0].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String())
|
|
||||||
So(queryRes.Series[0].Points[3][0].String(), ShouldEqual, null.FloatFrom(30.0).String())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
package cloudwatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/aws/aws-sdk-go/aws/request"
|
|
||||||
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
|
||||||
"github.com/grafana/grafana/pkg/components/null"
|
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
|
||||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
|
||||||
"github.com/grafana/grafana/pkg/tsdb"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) {
|
|
||||||
client, err := e.getClient(query.Region)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
startTime, err := queryContext.TimeRange.ParseFrom()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
endTime, err := queryContext.TimeRange.ParseTo()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !startTime.Before(endTime) {
|
|
||||||
return nil, fmt.Errorf("Invalid time range: Start time must be before end time")
|
|
||||||
}
|
|
||||||
|
|
||||||
params := &cloudwatch.GetMetricStatisticsInput{
|
|
||||||
Namespace: aws.String(query.Namespace),
|
|
||||||
MetricName: aws.String(query.MetricName),
|
|
||||||
Dimensions: query.Dimensions,
|
|
||||||
Period: aws.Int64(int64(query.Period)),
|
|
||||||
}
|
|
||||||
if len(query.Statistics) > 0 {
|
|
||||||
params.Statistics = query.Statistics
|
|
||||||
}
|
|
||||||
if len(query.ExtendedStatistics) > 0 {
|
|
||||||
params.ExtendedStatistics = query.ExtendedStatistics
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1 minutes resolution metrics is stored for 15 days, 15 * 24 * 60 = 21600
|
|
||||||
if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) {
|
|
||||||
return nil, errors.New("too long query period")
|
|
||||||
}
|
|
||||||
var resp *cloudwatch.GetMetricStatisticsOutput
|
|
||||||
for startTime.Before(endTime) {
|
|
||||||
params.StartTime = aws.Time(startTime)
|
|
||||||
if query.HighResolution {
|
|
||||||
startTime = startTime.Add(time.Duration(1440*query.Period) * time.Second)
|
|
||||||
} else {
|
|
||||||
startTime = endTime
|
|
||||||
}
|
|
||||||
params.EndTime = aws.Time(startTime)
|
|
||||||
|
|
||||||
if setting.Env == setting.DEV {
|
|
||||||
plog.Debug("CloudWatch query", "raw query", params)
|
|
||||||
}
|
|
||||||
|
|
||||||
partResp, err := client.GetMetricStatisticsWithContext(ctx, params, request.WithResponseReadTimeout(10*time.Second))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if resp != nil {
|
|
||||||
resp.Datapoints = append(resp.Datapoints, partResp.Datapoints...)
|
|
||||||
} else {
|
|
||||||
resp = partResp
|
|
||||||
|
|
||||||
}
|
|
||||||
metrics.MAwsCloudWatchGetMetricStatistics.Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
queryRes, err := parseResponse(resp, query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryRes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
|
|
||||||
region, err := model.Get("region").String()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace, err := model.Get("namespace").String()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
metricName, err := model.Get("metricName").String()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := model.Get("id").MustString("")
|
|
||||||
expression := model.Get("expression").MustString("")
|
|
||||||
|
|
||||||
dimensions, err := parseDimensions(model)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
statistics, extendedStatistics, err := parseStatistics(model)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
p := model.Get("period").MustString("")
|
|
||||||
if p == "" {
|
|
||||||
if namespace == "AWS/EC2" {
|
|
||||||
p = "300"
|
|
||||||
} else {
|
|
||||||
p = "60"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var period int
|
|
||||||
if regexp.MustCompile(`^\d+$`).Match([]byte(p)) {
|
|
||||||
period, err = strconv.Atoi(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
d, err := time.ParseDuration(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
period = int(d.Seconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
alias := model.Get("alias").MustString()
|
|
||||||
|
|
||||||
returnData := !model.Get("hide").MustBool(false)
|
|
||||||
queryType := model.Get("type").MustString()
|
|
||||||
if queryType == "" {
|
|
||||||
// If no type is provided we assume we are called by alerting service, which requires to return data!
|
|
||||||
// Note, this is sort of a hack, but the official Grafana interfaces do not carry the information
|
|
||||||
// who (which service) called the TsdbQueryEndpoint.Query(...) function.
|
|
||||||
returnData = true
|
|
||||||
}
|
|
||||||
highResolution := model.Get("highResolution").MustBool(false)
|
|
||||||
|
|
||||||
return &CloudWatchQuery{
|
|
||||||
Region: region,
|
|
||||||
Namespace: namespace,
|
|
||||||
MetricName: metricName,
|
|
||||||
Dimensions: dimensions,
|
|
||||||
Statistics: aws.StringSlice(statistics),
|
|
||||||
ExtendedStatistics: aws.StringSlice(extendedStatistics),
|
|
||||||
Period: period,
|
|
||||||
Alias: alias,
|
|
||||||
Id: id,
|
|
||||||
Expression: expression,
|
|
||||||
ReturnData: returnData,
|
|
||||||
HighResolution: highResolution,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDimensions(model *simplejson.Json) ([]*cloudwatch.Dimension, error) {
|
|
||||||
var result []*cloudwatch.Dimension
|
|
||||||
|
|
||||||
for k, v := range model.Get("dimensions").MustMap() {
|
|
||||||
kk := k
|
|
||||||
if vv, ok := v.(string); ok {
|
|
||||||
result = append(result, &cloudwatch.Dimension{
|
|
||||||
Name: &kk,
|
|
||||||
Value: &vv,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
return nil, errors.New("failed to parse")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(result, func(i, j int) bool {
|
|
||||||
return *result[i].Name < *result[j].Name
|
|
||||||
})
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseStatistics(model *simplejson.Json) ([]string, []string, error) {
|
|
||||||
var statistics []string
|
|
||||||
var extendedStatistics []string
|
|
||||||
|
|
||||||
for _, s := range model.Get("statistics").MustArray() {
|
|
||||||
if ss, ok := s.(string); ok {
|
|
||||||
if _, isStandard := standardStatistics[ss]; isStandard {
|
|
||||||
statistics = append(statistics, ss)
|
|
||||||
} else {
|
|
||||||
extendedStatistics = append(extendedStatistics, ss)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, nil, errors.New("failed to parse")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return statistics, extendedStatistics, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseResponse(resp *cloudwatch.GetMetricStatisticsOutput, query *CloudWatchQuery) (*tsdb.QueryResult, error) {
|
|
||||||
queryRes := tsdb.NewQueryResult()
|
|
||||||
|
|
||||||
queryRes.RefId = query.RefId
|
|
||||||
var value float64
|
|
||||||
for _, s := range append(query.Statistics, query.ExtendedStatistics...) {
|
|
||||||
series := tsdb.TimeSeries{
|
|
||||||
Tags: map[string]string{},
|
|
||||||
Points: make([]tsdb.TimePoint, 0),
|
|
||||||
}
|
|
||||||
for _, d := range query.Dimensions {
|
|
||||||
series.Tags[*d.Name] = *d.Value
|
|
||||||
}
|
|
||||||
series.Name = formatAlias(query, *s, series.Tags, "")
|
|
||||||
|
|
||||||
lastTimestamp := make(map[string]time.Time)
|
|
||||||
sort.Slice(resp.Datapoints, func(i, j int) bool {
|
|
||||||
return (*resp.Datapoints[i].Timestamp).Before(*resp.Datapoints[j].Timestamp)
|
|
||||||
})
|
|
||||||
for _, v := range resp.Datapoints {
|
|
||||||
switch *s {
|
|
||||||
case "Average":
|
|
||||||
value = *v.Average
|
|
||||||
case "Maximum":
|
|
||||||
value = *v.Maximum
|
|
||||||
case "Minimum":
|
|
||||||
value = *v.Minimum
|
|
||||||
case "Sum":
|
|
||||||
value = *v.Sum
|
|
||||||
case "SampleCount":
|
|
||||||
value = *v.SampleCount
|
|
||||||
default:
|
|
||||||
if strings.Index(*s, "p") == 0 && v.ExtendedStatistics[*s] != nil {
|
|
||||||
value = *v.ExtendedStatistics[*s]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// terminate gap of data points
|
|
||||||
timestamp := *v.Timestamp
|
|
||||||
if _, ok := lastTimestamp[*s]; ok {
|
|
||||||
nextTimestampFromLast := lastTimestamp[*s].Add(time.Duration(query.Period) * time.Second)
|
|
||||||
for timestamp.After(nextTimestampFromLast) {
|
|
||||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), float64(nextTimestampFromLast.Unix()*1000)))
|
|
||||||
nextTimestampFromLast = nextTimestampFromLast.Add(time.Duration(query.Period) * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastTimestamp[*s] = timestamp
|
|
||||||
|
|
||||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(value), float64(timestamp.Unix()*1000)))
|
|
||||||
}
|
|
||||||
|
|
||||||
queryRes.Series = append(queryRes.Series, &series)
|
|
||||||
queryRes.Meta = simplejson.New()
|
|
||||||
if len(resp.Datapoints) > 0 && resp.Datapoints[0].Unit != nil {
|
|
||||||
if unit, ok := cloudwatchUnitMappings[*resp.Datapoints[0].Unit]; ok {
|
|
||||||
queryRes.Meta.Set("unit", unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryRes, nil
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
package cloudwatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
|
||||||
"github.com/grafana/grafana/pkg/components/null"
|
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCloudWatchGetMetricStatistics(t *testing.T) {
|
|
||||||
Convey("CloudWatchGetMetricStatistics", t, func() {
|
|
||||||
|
|
||||||
Convey("can parse cloudwatch json model", func() {
|
|
||||||
json := `
|
|
||||||
{
|
|
||||||
"region": "us-east-1",
|
|
||||||
"namespace": "AWS/ApplicationELB",
|
|
||||||
"metricName": "TargetResponseTime",
|
|
||||||
"dimensions": {
|
|
||||||
"LoadBalancer": "lb",
|
|
||||||
"TargetGroup": "tg"
|
|
||||||
},
|
|
||||||
"statistics": [
|
|
||||||
"Average",
|
|
||||||
"Maximum",
|
|
||||||
"p50.00",
|
|
||||||
"p90.00"
|
|
||||||
],
|
|
||||||
"period": "60",
|
|
||||||
"highResolution": false,
|
|
||||||
"alias": "{{metric}}_{{stat}}"
|
|
||||||
}
|
|
||||||
`
|
|
||||||
modelJson, err := simplejson.NewJson([]byte(json))
|
|
||||||
So(err, ShouldBeNil)
|
|
||||||
|
|
||||||
res, err := parseQuery(modelJson)
|
|
||||||
So(err, ShouldBeNil)
|
|
||||||
So(res.Region, ShouldEqual, "us-east-1")
|
|
||||||
So(res.Namespace, ShouldEqual, "AWS/ApplicationELB")
|
|
||||||
So(res.MetricName, ShouldEqual, "TargetResponseTime")
|
|
||||||
So(len(res.Dimensions), ShouldEqual, 2)
|
|
||||||
So(*res.Dimensions[0].Name, ShouldEqual, "LoadBalancer")
|
|
||||||
So(*res.Dimensions[0].Value, ShouldEqual, "lb")
|
|
||||||
So(*res.Dimensions[1].Name, ShouldEqual, "TargetGroup")
|
|
||||||
So(*res.Dimensions[1].Value, ShouldEqual, "tg")
|
|
||||||
So(len(res.Statistics), ShouldEqual, 2)
|
|
||||||
So(*res.Statistics[0], ShouldEqual, "Average")
|
|
||||||
So(*res.Statistics[1], ShouldEqual, "Maximum")
|
|
||||||
So(len(res.ExtendedStatistics), ShouldEqual, 2)
|
|
||||||
So(*res.ExtendedStatistics[0], ShouldEqual, "p50.00")
|
|
||||||
So(*res.ExtendedStatistics[1], ShouldEqual, "p90.00")
|
|
||||||
So(res.Period, ShouldEqual, 60)
|
|
||||||
So(res.Alias, ShouldEqual, "{{metric}}_{{stat}}")
|
|
||||||
})
|
|
||||||
|
|
||||||
Convey("can parse cloudwatch response", func() {
|
|
||||||
timestamp := time.Unix(0, 0)
|
|
||||||
resp := &cloudwatch.GetMetricStatisticsOutput{
|
|
||||||
Label: aws.String("TargetResponseTime"),
|
|
||||||
Datapoints: []*cloudwatch.Datapoint{
|
|
||||||
{
|
|
||||||
Timestamp: aws.Time(timestamp),
|
|
||||||
Average: aws.Float64(10.0),
|
|
||||||
Maximum: aws.Float64(20.0),
|
|
||||||
ExtendedStatistics: map[string]*float64{
|
|
||||||
"p50.00": aws.Float64(30.0),
|
|
||||||
"p90.00": aws.Float64(40.0),
|
|
||||||
},
|
|
||||||
Unit: aws.String("Seconds"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
query := &CloudWatchQuery{
|
|
||||||
Region: "us-east-1",
|
|
||||||
Namespace: "AWS/ApplicationELB",
|
|
||||||
MetricName: "TargetResponseTime",
|
|
||||||
Dimensions: []*cloudwatch.Dimension{
|
|
||||||
{
|
|
||||||
Name: aws.String("LoadBalancer"),
|
|
||||||
Value: aws.String("lb"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: aws.String("TargetGroup"),
|
|
||||||
Value: aws.String("tg"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Statistics: []*string{aws.String("Average"), aws.String("Maximum")},
|
|
||||||
ExtendedStatistics: []*string{aws.String("p50.00"), aws.String("p90.00")},
|
|
||||||
Period: 60,
|
|
||||||
Alias: "{{namespace}}_{{metric}}_{{stat}}",
|
|
||||||
}
|
|
||||||
|
|
||||||
queryRes, err := parseResponse(resp, query)
|
|
||||||
So(err, ShouldBeNil)
|
|
||||||
So(queryRes.Series[0].Name, ShouldEqual, "AWS/ApplicationELB_TargetResponseTime_Average")
|
|
||||||
So(queryRes.Series[0].Tags["LoadBalancer"], ShouldEqual, "lb")
|
|
||||||
So(queryRes.Series[0].Tags["TargetGroup"], ShouldEqual, "tg")
|
|
||||||
So(queryRes.Series[0].Points[0][0].String(), ShouldEqual, null.FloatFrom(10.0).String())
|
|
||||||
So(queryRes.Series[1].Points[0][0].String(), ShouldEqual, null.FloatFrom(20.0).String())
|
|
||||||
So(queryRes.Series[2].Points[0][0].String(), ShouldEqual, null.FloatFrom(30.0).String())
|
|
||||||
So(queryRes.Series[3].Points[0][0].String(), ShouldEqual, null.FloatFrom(40.0).String())
|
|
||||||
So(queryRes.Meta.Get("unit").MustString(), ShouldEqual, "s")
|
|
||||||
})
|
|
||||||
|
|
||||||
Convey("terminate gap of data points", func() {
|
|
||||||
timestamp := time.Unix(0, 0)
|
|
||||||
resp := &cloudwatch.GetMetricStatisticsOutput{
|
|
||||||
Label: aws.String("TargetResponseTime"),
|
|
||||||
Datapoints: []*cloudwatch.Datapoint{
|
|
||||||
{
|
|
||||||
Timestamp: aws.Time(timestamp),
|
|
||||||
Average: aws.Float64(10.0),
|
|
||||||
Maximum: aws.Float64(20.0),
|
|
||||||
ExtendedStatistics: map[string]*float64{
|
|
||||||
"p50.00": aws.Float64(30.0),
|
|
||||||
"p90.00": aws.Float64(40.0),
|
|
||||||
},
|
|
||||||
Unit: aws.String("Seconds"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Timestamp: aws.Time(timestamp.Add(60 * time.Second)),
|
|
||||||
Average: aws.Float64(20.0),
|
|
||||||
Maximum: aws.Float64(30.0),
|
|
||||||
ExtendedStatistics: map[string]*float64{
|
|
||||||
"p50.00": aws.Float64(40.0),
|
|
||||||
"p90.00": aws.Float64(50.0),
|
|
||||||
},
|
|
||||||
Unit: aws.String("Seconds"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Timestamp: aws.Time(timestamp.Add(180 * time.Second)),
|
|
||||||
Average: aws.Float64(30.0),
|
|
||||||
Maximum: aws.Float64(40.0),
|
|
||||||
ExtendedStatistics: map[string]*float64{
|
|
||||||
"p50.00": aws.Float64(50.0),
|
|
||||||
"p90.00": aws.Float64(60.0),
|
|
||||||
},
|
|
||||||
Unit: aws.String("Seconds"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
query := &CloudWatchQuery{
|
|
||||||
Region: "us-east-1",
|
|
||||||
Namespace: "AWS/ApplicationELB",
|
|
||||||
MetricName: "TargetResponseTime",
|
|
||||||
Dimensions: []*cloudwatch.Dimension{
|
|
||||||
{
|
|
||||||
Name: aws.String("LoadBalancer"),
|
|
||||||
Value: aws.String("lb"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: aws.String("TargetGroup"),
|
|
||||||
Value: aws.String("tg"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Statistics: []*string{aws.String("Average"), aws.String("Maximum")},
|
|
||||||
ExtendedStatistics: []*string{aws.String("p50.00"), aws.String("p90.00")},
|
|
||||||
Period: 60,
|
|
||||||
Alias: "{{namespace}}_{{metric}}_{{stat}}",
|
|
||||||
}
|
|
||||||
|
|
||||||
queryRes, err := parseResponse(resp, query)
|
|
||||||
So(err, ShouldBeNil)
|
|
||||||
So(queryRes.Series[0].Points[0][0].String(), ShouldEqual, null.FloatFrom(10.0).String())
|
|
||||||
So(queryRes.Series[1].Points[0][0].String(), ShouldEqual, null.FloatFrom(20.0).String())
|
|
||||||
So(queryRes.Series[2].Points[0][0].String(), ShouldEqual, null.FloatFrom(30.0).String())
|
|
||||||
So(queryRes.Series[3].Points[0][0].String(), ShouldEqual, null.FloatFrom(40.0).String())
|
|
||||||
So(queryRes.Series[0].Points[1][0].String(), ShouldEqual, null.FloatFrom(20.0).String())
|
|
||||||
So(queryRes.Series[1].Points[1][0].String(), ShouldEqual, null.FloatFrom(30.0).String())
|
|
||||||
So(queryRes.Series[2].Points[1][0].String(), ShouldEqual, null.FloatFrom(40.0).String())
|
|
||||||
So(queryRes.Series[3].Points[1][0].String(), ShouldEqual, null.FloatFrom(50.0).String())
|
|
||||||
So(queryRes.Series[0].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String())
|
|
||||||
So(queryRes.Series[1].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String())
|
|
||||||
So(queryRes.Series[2].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String())
|
|
||||||
So(queryRes.Series[3].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String())
|
|
||||||
So(queryRes.Series[0].Points[3][0].String(), ShouldEqual, null.FloatFrom(30.0).String())
|
|
||||||
So(queryRes.Series[1].Points[3][0].String(), ShouldEqual, null.FloatFrom(40.0).String())
|
|
||||||
So(queryRes.Series[2].Points[3][0].String(), ShouldEqual, null.FloatFrom(50.0).String())
|
|
||||||
So(queryRes.Series[3].Points[3][0].String(), ShouldEqual, null.FloatFrom(60.0).String())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
46
pkg/tsdb/cloudwatch/metric_data_input_builder.go
Normal file
46
pkg/tsdb/cloudwatch/metric_data_input_builder.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||||
|
"github.com/grafana/grafana/pkg/tsdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *CloudWatchExecutor) buildMetricDataInput(queryContext *tsdb.TsdbQuery, queries map[string]*cloudWatchQuery) (*cloudwatch.GetMetricDataInput, error) {
|
||||||
|
startTime, err := queryContext.TimeRange.ParseFrom()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
endTime, err := queryContext.TimeRange.ParseTo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !startTime.Before(endTime) {
|
||||||
|
return nil, fmt.Errorf("Invalid time range: Start time must be before end time")
|
||||||
|
}
|
||||||
|
|
||||||
|
metricDataInput := &cloudwatch.GetMetricDataInput{
|
||||||
|
StartTime: aws.Time(startTime),
|
||||||
|
EndTime: aws.Time(endTime),
|
||||||
|
ScanBy: aws.String("TimestampAscending"),
|
||||||
|
}
|
||||||
|
for _, query := range queries {
|
||||||
|
// 1 minutes resolution metrics is stored for 15 days, 15 * 24 * 60 = 21600
|
||||||
|
if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) {
|
||||||
|
return nil, &queryError{errors.New("too long query period"), query.RefId}
|
||||||
|
}
|
||||||
|
|
||||||
|
metricDataQuery, err := e.buildMetricDataQuery(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &queryError{err, query.RefId}
|
||||||
|
}
|
||||||
|
metricDataInput.MetricDataQueries = append(metricDataInput.MetricDataQueries, metricDataQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
return metricDataInput, nil
|
||||||
|
}
|
||||||
28
pkg/tsdb/cloudwatch/metric_data_input_builder_test.go
Normal file
28
pkg/tsdb/cloudwatch/metric_data_input_builder_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/tsdb"
|
||||||
|
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMetricDataInputBuilder(t *testing.T) {
|
||||||
|
Convey("TestMetricDataInputBuilder", t, func() {
|
||||||
|
executor := &CloudWatchExecutor{}
|
||||||
|
query := make(map[string]*cloudWatchQuery)
|
||||||
|
|
||||||
|
Convey("Time range is valid", func() {
|
||||||
|
Convey("End time before start time should result in error", func() {
|
||||||
|
_, err := executor.buildMetricDataInput(&tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-2h")}, query)
|
||||||
|
So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time")
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("End time equals start time should result in error", func() {
|
||||||
|
_, err := executor.buildMetricDataInput(&tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-1h")}, query)
|
||||||
|
So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
139
pkg/tsdb/cloudwatch/metric_data_query_builder.go
Normal file
139
pkg/tsdb/cloudwatch/metric_data_query_builder.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *CloudWatchExecutor) buildMetricDataQuery(query *cloudWatchQuery) (*cloudwatch.MetricDataQuery, error) {
|
||||||
|
mdq := &cloudwatch.MetricDataQuery{
|
||||||
|
Id: aws.String(query.Id),
|
||||||
|
ReturnData: aws.Bool(query.ReturnData),
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.Expression != "" {
|
||||||
|
mdq.Expression = aws.String(query.Expression)
|
||||||
|
} else {
|
||||||
|
if query.isSearchExpression() {
|
||||||
|
mdq.Expression = aws.String(buildSearchExpression(query, query.Stats))
|
||||||
|
} else {
|
||||||
|
mdq.MetricStat = &cloudwatch.MetricStat{
|
||||||
|
Metric: &cloudwatch.Metric{
|
||||||
|
Namespace: aws.String(query.Namespace),
|
||||||
|
MetricName: aws.String(query.MetricName),
|
||||||
|
Dimensions: make([]*cloudwatch.Dimension, 0),
|
||||||
|
},
|
||||||
|
Period: aws.Int64(int64(query.Period)),
|
||||||
|
}
|
||||||
|
for key, values := range query.Dimensions {
|
||||||
|
mdq.MetricStat.Metric.Dimensions = append(mdq.MetricStat.Metric.Dimensions,
|
||||||
|
&cloudwatch.Dimension{
|
||||||
|
Name: aws.String(key),
|
||||||
|
Value: aws.String(values[0]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mdq.MetricStat.Stat = aws.String(query.Stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mdq.Expression != nil {
|
||||||
|
query.UsedExpression = *mdq.Expression
|
||||||
|
} else {
|
||||||
|
query.UsedExpression = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return mdq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSearchExpression(query *cloudWatchQuery, stat string) string {
|
||||||
|
knownDimensions := make(map[string][]string)
|
||||||
|
dimensionNames := []string{}
|
||||||
|
dimensionNamesWithoutKnownValues := []string{}
|
||||||
|
|
||||||
|
for key, values := range query.Dimensions {
|
||||||
|
dimensionNames = append(dimensionNames, key)
|
||||||
|
hasWildcard := false
|
||||||
|
for _, value := range values {
|
||||||
|
if value == "*" {
|
||||||
|
hasWildcard = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasWildcard {
|
||||||
|
dimensionNamesWithoutKnownValues = append(dimensionNamesWithoutKnownValues, key)
|
||||||
|
} else {
|
||||||
|
knownDimensions[key] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTerm := fmt.Sprintf(`MetricName="%s"`, query.MetricName)
|
||||||
|
keys := []string{}
|
||||||
|
for k := range knownDimensions {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
values := escape(knownDimensions[key])
|
||||||
|
valueExpression := join(values, " OR ", `"`, `"`)
|
||||||
|
if len(knownDimensions[key]) > 1 {
|
||||||
|
valueExpression = fmt.Sprintf(`(%s)`, valueExpression)
|
||||||
|
}
|
||||||
|
keyFilter := fmt.Sprintf(`"%s"=%s`, key, valueExpression)
|
||||||
|
searchTerm = appendSearch(searchTerm, keyFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.MatchExact {
|
||||||
|
schema := query.Namespace
|
||||||
|
if len(dimensionNames) > 0 {
|
||||||
|
sort.Strings(dimensionNames)
|
||||||
|
schema += fmt.Sprintf(",%s", join(dimensionNames, ",", "", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("REMOVE_EMPTY(SEARCH('{%s} %s', '%s', %s))", schema, searchTerm, stat, strconv.Itoa(query.Period))
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(dimensionNamesWithoutKnownValues)
|
||||||
|
searchTerm = appendSearch(searchTerm, join(dimensionNamesWithoutKnownValues, " ", `"`, `"`))
|
||||||
|
return fmt.Sprintf(`REMOVE_EMPTY(SEARCH('Namespace="%s" %s', '%s', %s))`, query.Namespace, searchTerm, stat, strconv.Itoa(query.Period))
|
||||||
|
}
|
||||||
|
|
||||||
|
func escape(arr []string) []string {
|
||||||
|
result := []string{}
|
||||||
|
for _, value := range arr {
|
||||||
|
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||||
|
value = strings.ReplaceAll(value, ")", `\)`)
|
||||||
|
value = strings.ReplaceAll(value, "(", `\(`)
|
||||||
|
value = strings.ReplaceAll(value, `"`, `\"`)
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func join(arr []string, delimiter string, valuePrefix string, valueSuffix string) string {
|
||||||
|
result := ""
|
||||||
|
for index, value := range arr {
|
||||||
|
result += valuePrefix + value + valueSuffix
|
||||||
|
if index+1 != len(arr) {
|
||||||
|
result += delimiter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendSearch(target string, value string) string {
|
||||||
|
if value != "" {
|
||||||
|
if target == "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v %v", target, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return target
|
||||||
|
}
|
||||||
215
pkg/tsdb/cloudwatch/metric_data_query_builder_test.go
Normal file
215
pkg/tsdb/cloudwatch/metric_data_query_builder_test.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMetricDataQueryBuilder(t *testing.T) {
|
||||||
|
Convey("TestMetricDataQueryBuilder", t, func() {
|
||||||
|
Convey("buildSearchExpression", func() {
|
||||||
|
Convey("and query should be matched exact", func() {
|
||||||
|
matchExact := true
|
||||||
|
Convey("and query has three dimension values for a given dimension key", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
Namespace: "AWS/EC2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"LoadBalancer": {"lb1", "lb2", "lb3"},
|
||||||
|
},
|
||||||
|
Period: 300,
|
||||||
|
Identifier: "id1",
|
||||||
|
Expression: "",
|
||||||
|
MatchExact: matchExact,
|
||||||
|
}
|
||||||
|
|
||||||
|
res := buildSearchExpression(query, "Average")
|
||||||
|
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('{AWS/EC2,LoadBalancer} MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and query has three dimension values for two given dimension keys", func() {
|
||||||
|
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
Namespace: "AWS/EC2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"LoadBalancer": {"lb1", "lb2", "lb3"},
|
||||||
|
"InstanceId": {"i-123", "i-456", "i-789"},
|
||||||
|
},
|
||||||
|
Period: 300,
|
||||||
|
Identifier: "id1",
|
||||||
|
Expression: "",
|
||||||
|
MatchExact: matchExact,
|
||||||
|
}
|
||||||
|
|
||||||
|
res := buildSearchExpression(query, "Average")
|
||||||
|
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId,LoadBalancer} MetricName="CPUUtilization" "InstanceId"=("i-123" OR "i-456" OR "i-789") "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and no OR operator was added if a star was used for dimension value", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
Namespace: "AWS/EC2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"LoadBalancer": {"*"},
|
||||||
|
},
|
||||||
|
Period: 300,
|
||||||
|
Identifier: "id1",
|
||||||
|
Expression: "",
|
||||||
|
MatchExact: matchExact,
|
||||||
|
}
|
||||||
|
|
||||||
|
res := buildSearchExpression(query, "Average")
|
||||||
|
So(res, ShouldNotContainSubstring, "OR")
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and query has one dimension key with a * value", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
Namespace: "AWS/EC2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"LoadBalancer": {"*"},
|
||||||
|
},
|
||||||
|
Period: 300,
|
||||||
|
Identifier: "id1",
|
||||||
|
Expression: "",
|
||||||
|
MatchExact: matchExact,
|
||||||
|
}
|
||||||
|
|
||||||
|
res := buildSearchExpression(query, "Average")
|
||||||
|
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('{AWS/EC2,LoadBalancer} MetricName="CPUUtilization"', 'Average', 300))`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and query has three dimension values for two given dimension keys, and one value is a star", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
Namespace: "AWS/EC2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"LoadBalancer": {"lb1", "lb2", "lb3"},
|
||||||
|
"InstanceId": {"i-123", "*", "i-789"},
|
||||||
|
},
|
||||||
|
Period: 300,
|
||||||
|
Identifier: "id1",
|
||||||
|
Expression: "",
|
||||||
|
MatchExact: matchExact,
|
||||||
|
}
|
||||||
|
|
||||||
|
res := buildSearchExpression(query, "Average")
|
||||||
|
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId,LoadBalancer} MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and query should not be matched exact", func() {
|
||||||
|
matchExact := false
|
||||||
|
Convey("and query has three dimension values for a given dimension key", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
Namespace: "AWS/EC2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"LoadBalancer": {"lb1", "lb2", "lb3"},
|
||||||
|
},
|
||||||
|
Period: 300,
|
||||||
|
Identifier: "id1",
|
||||||
|
Expression: "",
|
||||||
|
MatchExact: matchExact,
|
||||||
|
}
|
||||||
|
|
||||||
|
res := buildSearchExpression(query, "Average")
|
||||||
|
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and query has three dimension values for two given dimension keys", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
Namespace: "AWS/EC2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"LoadBalancer": {"lb1", "lb2", "lb3"},
|
||||||
|
"InstanceId": {"i-123", "i-456", "i-789"},
|
||||||
|
},
|
||||||
|
Period: 300,
|
||||||
|
Identifier: "id1",
|
||||||
|
Expression: "",
|
||||||
|
MatchExact: matchExact,
|
||||||
|
}
|
||||||
|
|
||||||
|
res := buildSearchExpression(query, "Average")
|
||||||
|
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "InstanceId"=("i-123" OR "i-456" OR "i-789") "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and query has one dimension key with a * value", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
Namespace: "AWS/EC2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"LoadBalancer": {"*"},
|
||||||
|
},
|
||||||
|
Period: 300,
|
||||||
|
Identifier: "id1",
|
||||||
|
Expression: "",
|
||||||
|
MatchExact: matchExact,
|
||||||
|
}
|
||||||
|
|
||||||
|
res := buildSearchExpression(query, "Average")
|
||||||
|
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"', 'Average', 300))`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and query has three dimension values for two given dimension keys, and one value is a star", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
Namespace: "AWS/EC2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"LoadBalancer": {"lb1", "lb2", "lb3"},
|
||||||
|
"InstanceId": {"i-123", "*", "i-789"},
|
||||||
|
},
|
||||||
|
Period: 300,
|
||||||
|
Identifier: "id1",
|
||||||
|
Expression: "",
|
||||||
|
MatchExact: matchExact,
|
||||||
|
}
|
||||||
|
|
||||||
|
res := buildSearchExpression(query, "Average")
|
||||||
|
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3") "InstanceId"', 'Average', 300))`)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and query has has invalid characters in dimension values", func() {
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
Namespace: "AWS/EC2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"lb1": {`lb\1\`},
|
||||||
|
"lb2": {`)lb2`},
|
||||||
|
"lb3": {`l(b3`},
|
||||||
|
"lb4": {`lb4""`},
|
||||||
|
"lb5": {`l\(b5"`},
|
||||||
|
"lb6": {`l\\(b5"`},
|
||||||
|
},
|
||||||
|
Period: 300,
|
||||||
|
Identifier: "id1",
|
||||||
|
Expression: "",
|
||||||
|
MatchExact: true,
|
||||||
|
}
|
||||||
|
res := buildSearchExpression(query, "Average")
|
||||||
|
|
||||||
|
Convey("it should escape backslash", func() {
|
||||||
|
So(res, ShouldContainSubstring, `"lb1"="lb\\1\\"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it should escape closing parenthesis", func() {
|
||||||
|
So(res, ShouldContainSubstring, `"lb2"="\)lb2"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it should escape open parenthesis", func() {
|
||||||
|
So(res, ShouldContainSubstring, `"lb3"="l\(b3"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("it should escape double quotes", func() {
|
||||||
|
So(res, ShouldContainSubstring, `"lb6"="l\\\\\(b5\""`)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -637,10 +637,13 @@ func (e *CloudWatchExecutor) cloudwatchListMetrics(region string, namespace stri
|
|||||||
|
|
||||||
params := &cloudwatch.ListMetricsInput{
|
params := &cloudwatch.ListMetricsInput{
|
||||||
Namespace: aws.String(namespace),
|
Namespace: aws.String(namespace),
|
||||||
MetricName: aws.String(metricName),
|
|
||||||
Dimensions: dimensions,
|
Dimensions: dimensions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if metricName != "" {
|
||||||
|
params.MetricName = aws.String(metricName)
|
||||||
|
}
|
||||||
|
|
||||||
var resp cloudwatch.ListMetricsOutput
|
var resp cloudwatch.ListMetricsOutput
|
||||||
err = svc.ListMetricsPages(params,
|
err = svc.ListMetricsPages(params,
|
||||||
func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool {
|
func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool {
|
||||||
|
|||||||
103
pkg/tsdb/cloudwatch/query_transformer.go
Normal file
103
pkg/tsdb/cloudwatch/query_transformer.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
|
"github.com/grafana/grafana/pkg/tsdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// returns a map of queries with query id as key. In the case a q request query
|
||||||
|
// has more than one statistic defined, one cloudwatchQuery will be created for each statistic.
|
||||||
|
// If the query doesn't have an Id defined by the user, we'll give it an with format `query[RefId]`. In the case
|
||||||
|
// the incoming query had more than one stat, it will ge an id like `query[RefId]_[StatName]`, eg queryC_Average
|
||||||
|
func (e *CloudWatchExecutor) transformRequestQueriesToCloudWatchQueries(requestQueries []*requestQuery) (map[string]*cloudWatchQuery, error) {
|
||||||
|
cloudwatchQueries := make(map[string]*cloudWatchQuery)
|
||||||
|
for _, requestQuery := range requestQueries {
|
||||||
|
for _, stat := range requestQuery.Statistics {
|
||||||
|
id := requestQuery.Id
|
||||||
|
if id == "" {
|
||||||
|
id = fmt.Sprintf("query%s", requestQuery.RefId)
|
||||||
|
}
|
||||||
|
if len(requestQuery.Statistics) > 1 {
|
||||||
|
id = fmt.Sprintf("%s_%v", id, strings.ReplaceAll(*stat, ".", "_"))
|
||||||
|
}
|
||||||
|
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
Id: id,
|
||||||
|
RefId: requestQuery.RefId,
|
||||||
|
Region: requestQuery.Region,
|
||||||
|
Namespace: requestQuery.Namespace,
|
||||||
|
MetricName: requestQuery.MetricName,
|
||||||
|
Dimensions: requestQuery.Dimensions,
|
||||||
|
Stats: *stat,
|
||||||
|
Period: requestQuery.Period,
|
||||||
|
Alias: requestQuery.Alias,
|
||||||
|
Expression: requestQuery.Expression,
|
||||||
|
ReturnData: requestQuery.ReturnData,
|
||||||
|
HighResolution: requestQuery.HighResolution,
|
||||||
|
MatchExact: requestQuery.MatchExact,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := cloudwatchQueries[id]; ok {
|
||||||
|
return nil, fmt.Errorf("Error in query %s. Query id %s is not unique", query.RefId, query.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
cloudwatchQueries[id] = query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloudwatchQueries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *CloudWatchExecutor) transformQueryResponseToQueryResult(cloudwatchResponses []*cloudwatchResponse) map[string]*tsdb.QueryResult {
|
||||||
|
results := make(map[string]*tsdb.QueryResult)
|
||||||
|
responsesByRefID := make(map[string][]*cloudwatchResponse)
|
||||||
|
|
||||||
|
for _, res := range cloudwatchResponses {
|
||||||
|
if _, ok := responsesByRefID[res.RefId]; ok {
|
||||||
|
responsesByRefID[res.RefId] = append(responsesByRefID[res.RefId], res)
|
||||||
|
} else {
|
||||||
|
responsesByRefID[res.RefId] = []*cloudwatchResponse{res}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for refID, responses := range responsesByRefID {
|
||||||
|
queryResult := tsdb.NewQueryResult()
|
||||||
|
queryResult.RefId = refID
|
||||||
|
queryResult.Meta = simplejson.New()
|
||||||
|
queryResult.Series = tsdb.TimeSeriesSlice{}
|
||||||
|
timeSeries := make(tsdb.TimeSeriesSlice, 0)
|
||||||
|
|
||||||
|
requestExceededMaxLimit := false
|
||||||
|
queryMeta := []struct {
|
||||||
|
Expression, ID string
|
||||||
|
}{}
|
||||||
|
|
||||||
|
for _, response := range responses {
|
||||||
|
timeSeries = append(timeSeries, *response.series...)
|
||||||
|
requestExceededMaxLimit = requestExceededMaxLimit || response.RequestExceededMaxLimit
|
||||||
|
queryMeta = append(queryMeta, struct {
|
||||||
|
Expression, ID string
|
||||||
|
}{
|
||||||
|
Expression: response.Expression,
|
||||||
|
ID: response.Id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(timeSeries, func(i, j int) bool {
|
||||||
|
return timeSeries[i].Name < timeSeries[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
if requestExceededMaxLimit {
|
||||||
|
queryResult.ErrorString = "Cloudwatch GetMetricData error: Maximum number of allowed metrics exceeded. Your search may have been limited."
|
||||||
|
}
|
||||||
|
queryResult.Series = append(queryResult.Series, timeSeries...)
|
||||||
|
queryResult.Meta.Set("gmdMeta", queryMeta)
|
||||||
|
results[refID] = queryResult
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
167
pkg/tsdb/cloudwatch/query_transformer_test.go
Normal file
167
pkg/tsdb/cloudwatch/query_transformer_test.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQueryTransformer(t *testing.T) {
|
||||||
|
Convey("TestQueryTransformer", t, func() {
|
||||||
|
Convey("when transforming queries", func() {
|
||||||
|
|
||||||
|
executor := &CloudWatchExecutor{}
|
||||||
|
Convey("one cloudwatchQuery is generated when its request query has one stat", func() {
|
||||||
|
requestQueries := []*requestQuery{
|
||||||
|
{
|
||||||
|
RefId: "D",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Namespace: "ec2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Statistics: aws.StringSlice([]string{"Average"}),
|
||||||
|
Period: 600,
|
||||||
|
Id: "",
|
||||||
|
HighResolution: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(len(res), ShouldEqual, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("two cloudwatchQuery is generated when there's two stats", func() {
|
||||||
|
requestQueries := []*requestQuery{
|
||||||
|
{
|
||||||
|
RefId: "D",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Namespace: "ec2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Statistics: aws.StringSlice([]string{"Average", "Sum"}),
|
||||||
|
Period: 600,
|
||||||
|
Id: "",
|
||||||
|
HighResolution: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(len(res), ShouldEqual, 2)
|
||||||
|
})
|
||||||
|
Convey("and id is given by user", func() {
|
||||||
|
Convey("that id will be used in the cloudwatch query", func() {
|
||||||
|
requestQueries := []*requestQuery{
|
||||||
|
{
|
||||||
|
RefId: "D",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Namespace: "ec2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Statistics: aws.StringSlice([]string{"Average"}),
|
||||||
|
Period: 600,
|
||||||
|
Id: "myid",
|
||||||
|
HighResolution: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(len(res), ShouldEqual, 1)
|
||||||
|
So(res, ShouldContainKey, "myid")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("and id is not given by user", func() {
|
||||||
|
Convey("id will be generated based on ref id if query only has one stat", func() {
|
||||||
|
requestQueries := []*requestQuery{
|
||||||
|
{
|
||||||
|
RefId: "D",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Namespace: "ec2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Statistics: aws.StringSlice([]string{"Average"}),
|
||||||
|
Period: 600,
|
||||||
|
Id: "",
|
||||||
|
HighResolution: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(len(res), ShouldEqual, 1)
|
||||||
|
So(res, ShouldContainKey, "queryD")
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("id will be generated based on ref and stat name if query has two stats", func() {
|
||||||
|
requestQueries := []*requestQuery{
|
||||||
|
{
|
||||||
|
RefId: "D",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Namespace: "ec2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Statistics: aws.StringSlice([]string{"Average", "Sum"}),
|
||||||
|
Period: 600,
|
||||||
|
Id: "",
|
||||||
|
HighResolution: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(len(res), ShouldEqual, 2)
|
||||||
|
So(res, ShouldContainKey, "queryD_Sum")
|
||||||
|
So(res, ShouldContainKey, "queryD_Average")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("dot should be removed when query has more than one stat and one of them is a percentile", func() {
|
||||||
|
requestQueries := []*requestQuery{
|
||||||
|
{
|
||||||
|
RefId: "D",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Namespace: "ec2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Statistics: aws.StringSlice([]string{"Average", "p46.32"}),
|
||||||
|
Period: 600,
|
||||||
|
Id: "",
|
||||||
|
HighResolution: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(len(res), ShouldEqual, 2)
|
||||||
|
So(res, ShouldContainKey, "queryD_p46_32")
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("should return an error if two queries have the same id", func() {
|
||||||
|
requestQueries := []*requestQuery{
|
||||||
|
{
|
||||||
|
RefId: "D",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Namespace: "ec2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Statistics: aws.StringSlice([]string{"Average", "p46.32"}),
|
||||||
|
Period: 600,
|
||||||
|
Id: "myId",
|
||||||
|
HighResolution: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
RefId: "E",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Namespace: "ec2",
|
||||||
|
MetricName: "CPUUtilization",
|
||||||
|
Statistics: aws.StringSlice([]string{"Average", "p46.32"}),
|
||||||
|
Period: 600,
|
||||||
|
Id: "myId",
|
||||||
|
HighResolution: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
|
||||||
|
So(res, ShouldBeNil)
|
||||||
|
So(err, ShouldNotBeNil)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
162
pkg/tsdb/cloudwatch/request_parser.go
Normal file
162
pkg/tsdb/cloudwatch/request_parser.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
|
"github.com/grafana/grafana/pkg/tsdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parses the json queries and returns a requestQuery. The requstQuery has a 1 to 1 mapping to a query editor row
|
||||||
|
func (e *CloudWatchExecutor) parseQueries(queryContext *tsdb.TsdbQuery) (map[string][]*requestQuery, error) {
|
||||||
|
requestQueries := make(map[string][]*requestQuery)
|
||||||
|
|
||||||
|
for i, model := range queryContext.Queries {
|
||||||
|
queryType := model.Model.Get("type").MustString()
|
||||||
|
if queryType != "timeSeriesQuery" && queryType != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
RefID := queryContext.Queries[i].RefId
|
||||||
|
query, err := parseRequestQuery(queryContext.Queries[i].Model, RefID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &queryError{err, RefID}
|
||||||
|
}
|
||||||
|
if _, exist := requestQueries[query.Region]; !exist {
|
||||||
|
requestQueries[query.Region] = make([]*requestQuery, 0)
|
||||||
|
}
|
||||||
|
requestQueries[query.Region] = append(requestQueries[query.Region], query)
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestQueries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRequestQuery(model *simplejson.Json, refId string) (*requestQuery, error) {
|
||||||
|
region, err := model.Get("region").String()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace, err := model.Get("namespace").String()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
metricName, err := model.Get("metricName").String()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dimensions, err := parseDimensions(model)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
statistics, err := parseStatistics(model)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p := model.Get("period").MustString("")
|
||||||
|
if p == "" {
|
||||||
|
if namespace == "AWS/EC2" {
|
||||||
|
p = "300"
|
||||||
|
} else {
|
||||||
|
p = "60"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var period int
|
||||||
|
if regexp.MustCompile(`^\d+$`).Match([]byte(p)) {
|
||||||
|
period, err = strconv.Atoi(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
d, err := time.ParseDuration(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
period = int(d.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
id := model.Get("id").MustString("")
|
||||||
|
expression := model.Get("expression").MustString("")
|
||||||
|
alias := model.Get("alias").MustString()
|
||||||
|
returnData := !model.Get("hide").MustBool(false)
|
||||||
|
queryType := model.Get("type").MustString()
|
||||||
|
if queryType == "" {
|
||||||
|
// If no type is provided we assume we are called by alerting service, which requires to return data!
|
||||||
|
// Note, this is sort of a hack, but the official Grafana interfaces do not carry the information
|
||||||
|
// who (which service) called the TsdbQueryEndpoint.Query(...) function.
|
||||||
|
returnData = true
|
||||||
|
}
|
||||||
|
|
||||||
|
highResolution := model.Get("highResolution").MustBool(false)
|
||||||
|
matchExact := model.Get("matchExact").MustBool(true)
|
||||||
|
|
||||||
|
return &requestQuery{
|
||||||
|
RefId: refId,
|
||||||
|
Region: region,
|
||||||
|
Namespace: namespace,
|
||||||
|
MetricName: metricName,
|
||||||
|
Dimensions: dimensions,
|
||||||
|
Statistics: aws.StringSlice(statistics),
|
||||||
|
Period: period,
|
||||||
|
Alias: alias,
|
||||||
|
Id: id,
|
||||||
|
Expression: expression,
|
||||||
|
ReturnData: returnData,
|
||||||
|
HighResolution: highResolution,
|
||||||
|
MatchExact: matchExact,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStatistics(model *simplejson.Json) ([]string, error) {
|
||||||
|
var statistics []string
|
||||||
|
|
||||||
|
for _, s := range model.Get("statistics").MustArray() {
|
||||||
|
statistics = append(statistics, s.(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
return statistics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDimensions(model *simplejson.Json) (map[string][]string, error) {
|
||||||
|
parsedDimensions := make(map[string][]string)
|
||||||
|
for k, v := range model.Get("dimensions").MustMap() {
|
||||||
|
// This is for backwards compatibility. Before 6.5 dimensions values were stored as strings and not arrays
|
||||||
|
if value, ok := v.(string); ok {
|
||||||
|
parsedDimensions[k] = []string{value}
|
||||||
|
} else if values, ok := v.([]interface{}); ok {
|
||||||
|
for _, value := range values {
|
||||||
|
parsedDimensions[k] = append(parsedDimensions[k], value.(string))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("failed to parse dimensions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortedDimensions := sortDimensions(parsedDimensions)
|
||||||
|
|
||||||
|
return sortedDimensions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortDimensions(dimensions map[string][]string) map[string][]string {
|
||||||
|
sortedDimensions := make(map[string][]string)
|
||||||
|
var keys []string
|
||||||
|
for k := range dimensions {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
sortedDimensions[k] = dimensions[k]
|
||||||
|
}
|
||||||
|
return sortedDimensions
|
||||||
|
}
|
||||||
87
pkg/tsdb/cloudwatch/request_parser_test.go
Normal file
87
pkg/tsdb/cloudwatch/request_parser_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestParser(t *testing.T) {
|
||||||
|
Convey("TestRequestParser", t, func() {
|
||||||
|
Convey("when parsing query editor row json", func() {
|
||||||
|
Convey("using new dimensions structure", func() {
|
||||||
|
query := simplejson.NewFromAny(map[string]interface{}{
|
||||||
|
"refId": "ref1",
|
||||||
|
"region": "us-east-1",
|
||||||
|
"namespace": "ec2",
|
||||||
|
"metricName": "CPUUtilization",
|
||||||
|
"id": "",
|
||||||
|
"expression": "",
|
||||||
|
"dimensions": map[string]interface{}{
|
||||||
|
"InstanceId": []interface{}{"test"},
|
||||||
|
"InstanceType": []interface{}{"test2", "test3"},
|
||||||
|
},
|
||||||
|
"statistics": []interface{}{"Average"},
|
||||||
|
"period": "600",
|
||||||
|
"hide": false,
|
||||||
|
"highResolution": false,
|
||||||
|
})
|
||||||
|
|
||||||
|
res, err := parseRequestQuery(query, "ref1")
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(res.Region, ShouldEqual, "us-east-1")
|
||||||
|
So(res.RefId, ShouldEqual, "ref1")
|
||||||
|
So(res.Namespace, ShouldEqual, "ec2")
|
||||||
|
So(res.MetricName, ShouldEqual, "CPUUtilization")
|
||||||
|
So(res.Id, ShouldEqual, "")
|
||||||
|
So(res.Expression, ShouldEqual, "")
|
||||||
|
So(res.Period, ShouldEqual, 600)
|
||||||
|
So(res.ReturnData, ShouldEqual, true)
|
||||||
|
So(res.HighResolution, ShouldEqual, false)
|
||||||
|
So(len(res.Dimensions), ShouldEqual, 2)
|
||||||
|
So(len(res.Dimensions["InstanceId"]), ShouldEqual, 1)
|
||||||
|
So(len(res.Dimensions["InstanceType"]), ShouldEqual, 2)
|
||||||
|
So(res.Dimensions["InstanceType"][1], ShouldEqual, "test3")
|
||||||
|
So(len(res.Statistics), ShouldEqual, 1)
|
||||||
|
So(*res.Statistics[0], ShouldEqual, "Average")
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("using old dimensions structure (backwards compatibility)", func() {
|
||||||
|
query := simplejson.NewFromAny(map[string]interface{}{
|
||||||
|
"refId": "ref1",
|
||||||
|
"region": "us-east-1",
|
||||||
|
"namespace": "ec2",
|
||||||
|
"metricName": "CPUUtilization",
|
||||||
|
"id": "",
|
||||||
|
"expression": "",
|
||||||
|
"dimensions": map[string]interface{}{
|
||||||
|
"InstanceId": "test",
|
||||||
|
"InstanceType": "test2",
|
||||||
|
},
|
||||||
|
"statistics": []interface{}{"Average"},
|
||||||
|
"period": "600",
|
||||||
|
"hide": false,
|
||||||
|
"highResolution": false,
|
||||||
|
})
|
||||||
|
|
||||||
|
res, err := parseRequestQuery(query, "ref1")
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(res.Region, ShouldEqual, "us-east-1")
|
||||||
|
So(res.RefId, ShouldEqual, "ref1")
|
||||||
|
So(res.Namespace, ShouldEqual, "ec2")
|
||||||
|
So(res.MetricName, ShouldEqual, "CPUUtilization")
|
||||||
|
So(res.Id, ShouldEqual, "")
|
||||||
|
So(res.Expression, ShouldEqual, "")
|
||||||
|
So(res.Period, ShouldEqual, 600)
|
||||||
|
So(res.ReturnData, ShouldEqual, true)
|
||||||
|
So(res.HighResolution, ShouldEqual, false)
|
||||||
|
So(len(res.Dimensions), ShouldEqual, 2)
|
||||||
|
So(len(res.Dimensions["InstanceId"]), ShouldEqual, 1)
|
||||||
|
So(len(res.Dimensions["InstanceType"]), ShouldEqual, 1)
|
||||||
|
So(res.Dimensions["InstanceType"][0], ShouldEqual, "test2")
|
||||||
|
So(*res.Statistics[0], ShouldEqual, "Average")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
155
pkg/tsdb/cloudwatch/response_parser.go
Normal file
155
pkg/tsdb/cloudwatch/response_parser.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||||
|
"github.com/grafana/grafana/pkg/components/null"
|
||||||
|
"github.com/grafana/grafana/pkg/tsdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *CloudWatchExecutor) parseResponse(metricDataOutputs []*cloudwatch.GetMetricDataOutput, queries map[string]*cloudWatchQuery) ([]*cloudwatchResponse, error) {
|
||||||
|
mdr := make(map[string]map[string]*cloudwatch.MetricDataResult)
|
||||||
|
|
||||||
|
for _, mdo := range metricDataOutputs {
|
||||||
|
requestExceededMaxLimit := false
|
||||||
|
for _, message := range mdo.Messages {
|
||||||
|
if *message.Code == "MaxMetricsExceeded" {
|
||||||
|
requestExceededMaxLimit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range mdo.MetricDataResults {
|
||||||
|
if _, exists := mdr[*r.Id]; !exists {
|
||||||
|
mdr[*r.Id] = make(map[string]*cloudwatch.MetricDataResult)
|
||||||
|
mdr[*r.Id][*r.Label] = r
|
||||||
|
} else if _, exists := mdr[*r.Id][*r.Label]; !exists {
|
||||||
|
mdr[*r.Id][*r.Label] = r
|
||||||
|
} else {
|
||||||
|
mdr[*r.Id][*r.Label].Timestamps = append(mdr[*r.Id][*r.Label].Timestamps, r.Timestamps...)
|
||||||
|
mdr[*r.Id][*r.Label].Values = append(mdr[*r.Id][*r.Label].Values, r.Values...)
|
||||||
|
}
|
||||||
|
queries[*r.Id].RequestExceededMaxLimit = requestExceededMaxLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloudWatchResponses := make([]*cloudwatchResponse, 0)
|
||||||
|
for id, lr := range mdr {
|
||||||
|
response := &cloudwatchResponse{}
|
||||||
|
series, err := parseGetMetricDataTimeSeries(lr, queries[id])
|
||||||
|
if err != nil {
|
||||||
|
return cloudWatchResponses, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response.series = series
|
||||||
|
response.Expression = queries[id].UsedExpression
|
||||||
|
response.RefId = queries[id].RefId
|
||||||
|
response.Id = queries[id].Id
|
||||||
|
response.RequestExceededMaxLimit = queries[id].RequestExceededMaxLimit
|
||||||
|
|
||||||
|
cloudWatchResponses = append(cloudWatchResponses, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloudWatchResponses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGetMetricDataTimeSeries(metricDataResults map[string]*cloudwatch.MetricDataResult, query *cloudWatchQuery) (*tsdb.TimeSeriesSlice, error) {
|
||||||
|
result := tsdb.TimeSeriesSlice{}
|
||||||
|
for label, metricDataResult := range metricDataResults {
|
||||||
|
if *metricDataResult.StatusCode != "Complete" {
|
||||||
|
return nil, fmt.Errorf("too many datapoint requested in query %s. Please try to reduce the time range", query.RefId)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, message := range metricDataResult.Messages {
|
||||||
|
if *message.Code == "ArithmeticError" {
|
||||||
|
return nil, fmt.Errorf("ArithmeticError in query %s: %s", query.RefId, *message.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
series := tsdb.TimeSeries{
|
||||||
|
Tags: make(map[string]string),
|
||||||
|
Points: make([]tsdb.TimePoint, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, values := range query.Dimensions {
|
||||||
|
if len(values) == 1 && values[0] != "*" {
|
||||||
|
series.Tags[key] = values[0]
|
||||||
|
} else {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == label || value == "*" {
|
||||||
|
series.Tags[key] = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
series.Name = formatAlias(query, query.Stats, series.Tags, label)
|
||||||
|
|
||||||
|
for j, t := range metricDataResult.Timestamps {
|
||||||
|
if j > 0 {
|
||||||
|
expectedTimestamp := metricDataResult.Timestamps[j-1].Add(time.Duration(query.Period) * time.Second)
|
||||||
|
if expectedTimestamp.Before(*t) {
|
||||||
|
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), float64(expectedTimestamp.Unix()*1000)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(*metricDataResult.Values[j]), float64((*t).Unix())*1000))
|
||||||
|
}
|
||||||
|
result = append(result, &series)
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAlias(query *cloudWatchQuery, stat string, dimensions map[string]string, label string) string {
|
||||||
|
region := query.Region
|
||||||
|
namespace := query.Namespace
|
||||||
|
metricName := query.MetricName
|
||||||
|
period := strconv.Itoa(query.Period)
|
||||||
|
|
||||||
|
if query.isUserDefinedSearchExpression() {
|
||||||
|
pIndex := strings.LastIndex(query.Expression, ",")
|
||||||
|
period = strings.Trim(query.Expression[pIndex+1:], " )")
|
||||||
|
sIndex := strings.LastIndex(query.Expression[:pIndex], ",")
|
||||||
|
stat = strings.Trim(query.Expression[sIndex+1:pIndex], " '")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(query.Alias) == 0 && query.isMathExpression() {
|
||||||
|
return query.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(query.Alias) == 0 && query.isInferredSearchExpression() {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]string{}
|
||||||
|
data["region"] = region
|
||||||
|
data["namespace"] = namespace
|
||||||
|
data["metric"] = metricName
|
||||||
|
data["stat"] = stat
|
||||||
|
data["period"] = period
|
||||||
|
if len(label) != 0 {
|
||||||
|
data["label"] = label
|
||||||
|
}
|
||||||
|
for k, v := range dimensions {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
result := aliasFormat.ReplaceAllFunc([]byte(query.Alias), func(in []byte) []byte {
|
||||||
|
labelName := strings.Replace(string(in), "{{", "", 1)
|
||||||
|
labelName = strings.Replace(labelName, "}}", "", 1)
|
||||||
|
labelName = strings.TrimSpace(labelName)
|
||||||
|
if val, exists := data[labelName]; exists {
|
||||||
|
return []byte(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return in
|
||||||
|
})
|
||||||
|
|
||||||
|
if string(result) == "" {
|
||||||
|
return metricName + "_" + stat
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
61
pkg/tsdb/cloudwatch/response_parser_test.go
Normal file
61
pkg/tsdb/cloudwatch/response_parser_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||||
|
"github.com/grafana/grafana/pkg/components/null"
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCloudWatchResponseParser(t *testing.T) {
|
||||||
|
Convey("TestCloudWatchResponseParser", t, func() {
|
||||||
|
|
||||||
|
Convey("can parse cloudwatch response", func() {
|
||||||
|
timestamp := time.Unix(0, 0)
|
||||||
|
resp := map[string]*cloudwatch.MetricDataResult{
|
||||||
|
"lb": {
|
||||||
|
Id: aws.String("id1"),
|
||||||
|
Label: aws.String("lb"),
|
||||||
|
Timestamps: []*time.Time{
|
||||||
|
aws.Time(timestamp),
|
||||||
|
aws.Time(timestamp.Add(60 * time.Second)),
|
||||||
|
aws.Time(timestamp.Add(180 * time.Second)),
|
||||||
|
},
|
||||||
|
Values: []*float64{
|
||||||
|
aws.Float64(10),
|
||||||
|
aws.Float64(20),
|
||||||
|
aws.Float64(30),
|
||||||
|
},
|
||||||
|
StatusCode: aws.String("Complete"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
query := &cloudWatchQuery{
|
||||||
|
RefId: "refId1",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Namespace: "AWS/ApplicationELB",
|
||||||
|
MetricName: "TargetResponseTime",
|
||||||
|
Dimensions: map[string][]string{
|
||||||
|
"LoadBalancer": {"lb"},
|
||||||
|
"TargetGroup": {"tg"},
|
||||||
|
},
|
||||||
|
Stats: "Average",
|
||||||
|
Period: 60,
|
||||||
|
Alias: "{{namespace}}_{{metric}}_{{stat}}",
|
||||||
|
}
|
||||||
|
series, err := parseGetMetricDataTimeSeries(resp, query)
|
||||||
|
timeSeries := (*series)[0]
|
||||||
|
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(timeSeries.Name, ShouldEqual, "AWS/ApplicationELB_TargetResponseTime_Average")
|
||||||
|
So(timeSeries.Tags["LoadBalancer"], ShouldEqual, "lb")
|
||||||
|
So(timeSeries.Points[0][0].String(), ShouldEqual, null.FloatFrom(10.0).String())
|
||||||
|
So(timeSeries.Points[1][0].String(), ShouldEqual, null.FloatFrom(20.0).String())
|
||||||
|
So(timeSeries.Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String())
|
||||||
|
So(timeSeries.Points[3][0].String(), ShouldEqual, null.FloatFrom(30.0).String())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
101
pkg/tsdb/cloudwatch/time_series_query.go
Normal file
101
pkg/tsdb/cloudwatch/time_series_query.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package cloudwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/tsdb"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) {
|
||||||
|
results := &tsdb.Response{
|
||||||
|
Results: make(map[string]*tsdb.QueryResult),
|
||||||
|
}
|
||||||
|
|
||||||
|
requestQueriesByRegion, err := e.parseQueries(queryContext)
|
||||||
|
if err != nil {
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
resultChan := make(chan *tsdb.QueryResult, len(queryContext.Queries))
|
||||||
|
eg, ectx := errgroup.WithContext(ctx)
|
||||||
|
|
||||||
|
if len(requestQueriesByRegion) > 0 {
|
||||||
|
for r, q := range requestQueriesByRegion {
|
||||||
|
requestQueries := q
|
||||||
|
region := r
|
||||||
|
eg.Go(func() error {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
plog.Error("Execute Get Metric Data Query Panic", "error", err, "stack", log.Stack(1))
|
||||||
|
if theErr, ok := err.(error); ok {
|
||||||
|
resultChan <- &tsdb.QueryResult{
|
||||||
|
Error: theErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := e.getClient(region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
queries, err := e.transformRequestQueriesToCloudWatchQueries(requestQueries)
|
||||||
|
if err != nil {
|
||||||
|
for _, query := range requestQueries {
|
||||||
|
resultChan <- &tsdb.QueryResult{
|
||||||
|
RefId: query.RefId,
|
||||||
|
Error: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
metricDataInput, err := e.buildMetricDataInput(queryContext, queries)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cloudwatchResponses := make([]*cloudwatchResponse, 0)
|
||||||
|
mdo, err := e.executeRequest(ectx, client, metricDataInput)
|
||||||
|
if err != nil {
|
||||||
|
for _, query := range requestQueries {
|
||||||
|
resultChan <- &tsdb.QueryResult{
|
||||||
|
RefId: query.RefId,
|
||||||
|
Error: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
responses, err := e.parseResponse(mdo, queries)
|
||||||
|
if err != nil {
|
||||||
|
for _, query := range requestQueries {
|
||||||
|
resultChan <- &tsdb.QueryResult{
|
||||||
|
RefId: query.RefId,
|
||||||
|
Error: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloudwatchResponses = append(cloudwatchResponses, responses...)
|
||||||
|
res := e.transformQueryResponseToQueryResult(cloudwatchResponses)
|
||||||
|
for _, queryRes := range res {
|
||||||
|
resultChan <- queryRes
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := eg.Wait(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
close(resultChan)
|
||||||
|
for result := range resultChan {
|
||||||
|
results.Results[result.RefId] = result
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -1,21 +1,49 @@
|
|||||||
package cloudwatch
|
package cloudwatch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/request"
|
||||||
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||||
|
"github.com/grafana/grafana/pkg/tsdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CloudWatchQuery struct {
|
type cloudWatchClient interface {
|
||||||
|
GetMetricDataWithContext(ctx aws.Context, input *cloudwatch.GetMetricDataInput, opts ...request.Option) (*cloudwatch.GetMetricDataOutput, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type requestQuery struct {
|
||||||
RefId string
|
RefId string
|
||||||
Region string
|
Region string
|
||||||
|
Id string
|
||||||
Namespace string
|
Namespace string
|
||||||
MetricName string
|
MetricName string
|
||||||
Dimensions []*cloudwatch.Dimension
|
|
||||||
Statistics []*string
|
Statistics []*string
|
||||||
|
QueryType string
|
||||||
|
Expression string
|
||||||
|
ReturnData bool
|
||||||
|
Dimensions map[string][]string
|
||||||
ExtendedStatistics []*string
|
ExtendedStatistics []*string
|
||||||
Period int
|
Period int
|
||||||
Alias string
|
Alias string
|
||||||
Id string
|
|
||||||
Expression string
|
|
||||||
ReturnData bool
|
|
||||||
HighResolution bool
|
HighResolution bool
|
||||||
|
MatchExact bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type cloudwatchResponse struct {
|
||||||
|
series *tsdb.TimeSeriesSlice
|
||||||
|
Id string
|
||||||
|
RefId string
|
||||||
|
Expression string
|
||||||
|
RequestExceededMaxLimit bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryError struct {
|
||||||
|
err error
|
||||||
|
RefID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *queryError) Error() string {
|
||||||
|
return fmt.Sprintf("Error parsing query %s, %s", e.RefID, e.err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
|
|||||||
|
|
||||||
cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&parseTime=true&loc=UTC&allowNativePasswords=true",
|
cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&parseTime=true&loc=UTC&allowNativePasswords=true",
|
||||||
characterEscape(datasource.User, ":"),
|
characterEscape(datasource.User, ":"),
|
||||||
characterEscape(datasource.DecryptedPassword(), "@"),
|
datasource.DecryptedPassword(),
|
||||||
protocol,
|
protocol,
|
||||||
characterEscape(datasource.Url, ")"),
|
characterEscape(datasource.Url, ")"),
|
||||||
characterEscape(datasource.Database, "?"),
|
characterEscape(datasource.Database, "?"),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default class AppNotificationItem extends Component<Props> {
|
|||||||
<Alert
|
<Alert
|
||||||
severity={appNotification.severity}
|
severity={appNotification.severity}
|
||||||
title={appNotification.title}
|
title={appNotification.title}
|
||||||
children={appNotification.text}
|
children={appNotification.component || appNotification.text}
|
||||||
onRemove={() => onClearNotification(appNotification.id)}
|
onRemove={() => onClearNotification(appNotification.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { Tooltip } from '@grafana/ui';
|
import { Tooltip } from '@grafana/ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -21,8 +21,12 @@ export const Footer: FC<Props> = React.memo(
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://grafana.com/services/support" target="_blank" rel="noopener">
|
<a
|
||||||
<i className="fa fa-support" /> Support Plans
|
href="https://grafana.com/products/enterprise/?utm_source=grafana_footer"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<i className="fa fa-support" /> Support & Enterprise
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ export const createSuccessNotification = (title: string, text = ''): AppNotifica
|
|||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createErrorNotification = (title: string, text = ''): AppNotification => {
|
export const createErrorNotification = (title: string, text = '', component?: React.ReactElement): AppNotification => {
|
||||||
return {
|
return {
|
||||||
...defaultErrorNotification,
|
...defaultErrorNotification,
|
||||||
title: title,
|
|
||||||
text: getMessageFromError(text),
|
text: getMessageFromError(text),
|
||||||
|
title,
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
|
component,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default class TableModel implements TableData {
|
|||||||
rows: any[];
|
rows: any[];
|
||||||
type: string;
|
type: string;
|
||||||
columnMap: any;
|
columnMap: any;
|
||||||
|
refId: string;
|
||||||
|
|
||||||
constructor(table?: any) {
|
constructor(table?: any) {
|
||||||
this.columns = [];
|
this.columns = [];
|
||||||
|
|||||||
@@ -171,14 +171,14 @@ exports[`ServerStats Should render table with stats 1`] = `
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://grafana.com/services/support"
|
href="https://grafana.com/products/enterprise/?utm_source=grafana_footer"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
className="fa fa-support"
|
className="fa fa-support"
|
||||||
/>
|
/>
|
||||||
Support Plans
|
Support & Enterprise
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
DataQuery,
|
DataQuery,
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
PanelData,
|
PanelData,
|
||||||
DataQueryRequest,
|
|
||||||
PanelEvents,
|
PanelEvents,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
@@ -316,10 +315,6 @@ export function filterPanelDataToQuery(data: PanelData, refId: string): PanelDat
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't pass the request if all requests are the same
|
|
||||||
const request: DataQueryRequest = undefined;
|
|
||||||
// TODO: look in sub-requets to match the info
|
|
||||||
|
|
||||||
// Only say this is an error if the error links to the query
|
// Only say this is an error if the error links to the query
|
||||||
let state = LoadingState.Done;
|
let state = LoadingState.Done;
|
||||||
const error = data.error && data.error.refId === refId ? data.error : undefined;
|
const error = data.error && data.error.refId === refId ? data.error : undefined;
|
||||||
@@ -330,9 +325,9 @@ export function filterPanelDataToQuery(data: PanelData, refId: string): PanelDat
|
|||||||
const timeRange = data.timeRange;
|
const timeRange = data.timeRange;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...data,
|
||||||
state,
|
state,
|
||||||
series,
|
series,
|
||||||
request,
|
|
||||||
error,
|
error,
|
||||||
timeRange,
|
timeRange,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ describe('DashboardModel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should slugify dashboard name', () => {
|
it('should slugify dashboard name', () => {
|
||||||
expect(model.panels[0].links[3].url).toBe(`/dashboard/db/my-other-dashboard`);
|
expect(model.panels[0].links[3].url).toBe(`dashboard/db/my-other-dashboard`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -685,11 +685,11 @@ function upgradePanelLink(link: any): DataLink {
|
|||||||
let url = link.url;
|
let url = link.url;
|
||||||
|
|
||||||
if (!url && link.dashboard) {
|
if (!url && link.dashboard) {
|
||||||
url = `/dashboard/db/${kbn.slugifyForUrl(link.dashboard)}`;
|
url = `dashboard/db/${kbn.slugifyForUrl(link.dashboard)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!url && link.dashUri) {
|
if (!url && link.dashUri) {
|
||||||
url = `/dashboard/${link.dashUri}`;
|
url = `dashboard/${link.dashUri}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// some models are incomplete and have no dashboard or dashUri
|
// some models are incomplete and have no dashboard or dashUri
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ function getGrafanaCloudPhantomPlugin(): DataSourcePluginMeta {
|
|||||||
author: { name: 'Grafana Labs' },
|
author: { name: 'Grafana Labs' },
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
url: 'https://grafana.com/cloud',
|
url: 'https://grafana.com/products/cloud/',
|
||||||
name: 'Learn more',
|
name: 'Learn more',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
|
|||||||
'navbar-button navbar-button--border-right-0': originDashboardIsEditable,
|
'navbar-button navbar-button--border-right-0': originDashboardIsEditable,
|
||||||
});
|
});
|
||||||
|
|
||||||
const showSmallDataSourcePicker = (splitted ? containerWidth < 690 : containerWidth < 800) || false;
|
const showSmallDataSourcePicker = (splitted ? containerWidth < 700 : containerWidth < 800) || false;
|
||||||
const showSmallTimePicker = splitted || containerWidth < 1210;
|
const showSmallTimePicker = splitted || containerWidth < 1210;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { StoreState } from 'app/types';
|
|||||||
import {
|
import {
|
||||||
DataQuery,
|
DataQuery,
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
QueryFixAction,
|
|
||||||
PanelData,
|
PanelData,
|
||||||
HistoryItem,
|
HistoryItem,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
@@ -97,14 +96,6 @@ export class QueryRow extends PureComponent<QueryRowProps, QueryRowState> {
|
|||||||
this.props.changeQuery(exploreId, newQuery, index, true);
|
this.props.changeQuery(exploreId, newQuery, index, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
onClickHintFix = (action: QueryFixAction) => {
|
|
||||||
const { datasourceInstance, exploreId, index } = this.props;
|
|
||||||
if (datasourceInstance && datasourceInstance.modifyQuery) {
|
|
||||||
const modifier = (queries: DataQuery, action: QueryFixAction) => datasourceInstance.modifyQuery(queries, action);
|
|
||||||
this.props.modifyQueries(exploreId, action, index, modifier);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onClickRemoveButton = () => {
|
onClickRemoveButton = () => {
|
||||||
const { exploreId, index } = this.props;
|
const { exploreId, index } = this.props;
|
||||||
this.props.removeQueryRowAction({ exploreId, index });
|
this.props.removeQueryRowAction({ exploreId, index });
|
||||||
@@ -161,7 +152,6 @@ export class QueryRow extends PureComponent<QueryRowProps, QueryRowState> {
|
|||||||
query={query}
|
query={query}
|
||||||
history={history}
|
history={history}
|
||||||
onRunQuery={this.onRunQuery}
|
onRunQuery={this.onRunQuery}
|
||||||
onHint={this.onClickHintFix}
|
|
||||||
onBlur={noopOnBlur}
|
onBlur={noopOnBlur}
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
data={queryResponse}
|
data={queryResponse}
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ class MetricsPanelCtrl extends PanelCtrl {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loading = false;
|
|
||||||
this.error = err.message || 'Request Error';
|
this.error = err.message || 'Request Error';
|
||||||
|
|
||||||
if (err.data) {
|
if (err.data) {
|
||||||
@@ -116,10 +115,6 @@ class MetricsPanelCtrl extends PanelCtrl {
|
|||||||
this.error = err.data.error;
|
this.error = err.data.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.$timeout(() => {
|
|
||||||
this.events.emit(PanelEvents.dataError, err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updates the response with information from the stream
|
// Updates the response with information from the stream
|
||||||
@@ -128,10 +123,6 @@ class MetricsPanelCtrl extends PanelCtrl {
|
|||||||
if (data.state === LoadingState.Error) {
|
if (data.state === LoadingState.Error) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.processDataError(data.error);
|
this.processDataError(data.error);
|
||||||
if (!data.series) {
|
|
||||||
// keep current data if the response is empty
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore data in loading state
|
// Ignore data in loading state
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function addTeamGroup(groupId: string): ThunkResult<void> {
|
|||||||
export function removeTeamGroup(groupId: string): ThunkResult<void> {
|
export function removeTeamGroup(groupId: string): ThunkResult<void> {
|
||||||
return async (dispatch, getStore) => {
|
return async (dispatch, getStore) => {
|
||||||
const team = getStore().team.team;
|
const team = getStore().team.team;
|
||||||
await getBackendSrv().delete(`/api/teams/${team.id}/groups/${groupId}`);
|
await getBackendSrv().delete(`/api/teams/${team.id}/groups/${encodeURIComponent(groupId)}`);
|
||||||
dispatch(loadTeamGroups());
|
dispatch(loadTeamGroups());
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import { Input } from '@grafana/ui';
|
|
||||||
import { VariableQueryProps } from 'app/types/plugins';
|
import { VariableQueryProps } from 'app/types/plugins';
|
||||||
|
|
||||||
export default class DefaultVariableQueryEditor extends PureComponent<VariableQueryProps, any> {
|
export default class DefaultVariableQueryEditor extends PureComponent<VariableQueryProps, any> {
|
||||||
@@ -8,20 +7,30 @@ export default class DefaultVariableQueryEditor extends PureComponent<VariableQu
|
|||||||
this.state = { value: props.query };
|
this.state = { value: props.query };
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange = (event: React.FormEvent<HTMLInputElement>) => {
|
onChange = (event: React.FormEvent<HTMLTextAreaElement>) => {
|
||||||
this.setState({ value: event.currentTarget.value });
|
this.setState({ value: event.currentTarget.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
onBlur = (event: React.FormEvent<HTMLInputElement>) => {
|
onBlur = (event: React.FormEvent<HTMLTextAreaElement>) => {
|
||||||
this.props.onChange(event.currentTarget.value, event.currentTarget.value);
|
this.props.onChange(event.currentTarget.value, event.currentTarget.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getLineCount() {
|
||||||
|
const { value } = this.state;
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.split('\n').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className="gf-form">
|
<div className="gf-form">
|
||||||
<span className="gf-form-label width-10">Query</span>
|
<span className="gf-form-label width-10">Query</span>
|
||||||
<Input
|
<textarea
|
||||||
type="text"
|
rows={this.getLineCount()}
|
||||||
className="gf-form-input"
|
className="gf-form-input"
|
||||||
value={this.state.value}
|
value={this.state.value}
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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} />;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]: '' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React, { FunctionComponent } from 'react';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThrottlingErrorMessage: FunctionComponent<Props> = ({ region }) => (
|
||||||
|
<p>
|
||||||
|
Please visit the
|
||||||
|
<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>
|
||||||
|
to request a quota increase or see our
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
className="text-link"
|
||||||
|
href={`https://grafana.com/docs/features/datasources/cloudwatch/#service-quotas`}
|
||||||
|
>
|
||||||
|
documentation
|
||||||
|
</a>
|
||||||
|
to learn more.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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>,
|
||||||
|
]
|
||||||
|
`;
|
||||||
@@ -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>,
|
||||||
|
]
|
||||||
|
`;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { Stats } from './Stats';
|
||||||
|
export { Dimensions } from './Dimensions';
|
||||||
|
export { QueryInlineField, QueryField } from './Forms';
|
||||||
|
export { Alias } from './Alias';
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1024
public/app/plugins/datasource/cloudwatch/dashboards/EBS.json
Normal file
1024
public/app/plugins/datasource/cloudwatch/dashboards/EBS.json
Normal file
File diff suppressed because it is too large
Load Diff
545
public/app/plugins/datasource/cloudwatch/dashboards/Lambda.json
Normal file
545
public/app/plugins/datasource/cloudwatch/dashboards/Lambda.json
Normal 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
|
||||||
|
}
|
||||||
1220
public/app/plugins/datasource/cloudwatch/dashboards/ec2.json
Normal file
1220
public/app/plugins/datasource/cloudwatch/dashboards/ec2.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
import angular, { IQService } from 'angular';
|
import angular, { IQService } from 'angular';
|
||||||
import _ from 'lodash';
|
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 {
|
import {
|
||||||
dateMath,
|
dateMath,
|
||||||
ScopedVars,
|
ScopedVars,
|
||||||
@@ -9,22 +15,39 @@ import {
|
|||||||
DataQueryRequest,
|
DataQueryRequest,
|
||||||
DataSourceInstanceSettings,
|
DataSourceInstanceSettings,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import kbn from 'app/core/utils/kbn';
|
|
||||||
import { CloudWatchQuery } from './types';
|
|
||||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||||
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
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;
|
type: any;
|
||||||
proxyUrl: any;
|
proxyUrl: any;
|
||||||
defaultRegion: any;
|
defaultRegion: any;
|
||||||
standardStatistics: any;
|
standardStatistics: any;
|
||||||
|
datasourceName: string;
|
||||||
|
debouncedAlert: (datasourceName: string, region: string) => void;
|
||||||
|
debouncedCustomAlert: (title: string, message: string) => void;
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
constructor(
|
constructor(
|
||||||
private instanceSettings: DataSourceInstanceSettings,
|
instanceSettings: DataSourceInstanceSettings<CloudWatchJsonData>,
|
||||||
private $q: IQService,
|
private $q: IQService,
|
||||||
private backendSrv: BackendSrv,
|
private backendSrv: BackendSrv,
|
||||||
private templateSrv: TemplateSrv,
|
private templateSrv: TemplateSrv,
|
||||||
@@ -34,13 +57,14 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
this.type = 'cloudwatch';
|
this.type = 'cloudwatch';
|
||||||
this.proxyUrl = instanceSettings.url;
|
this.proxyUrl = instanceSettings.url;
|
||||||
this.defaultRegion = instanceSettings.jsonData.defaultRegion;
|
this.defaultRegion = instanceSettings.jsonData.defaultRegion;
|
||||||
this.instanceSettings = instanceSettings;
|
this.datasourceName = instanceSettings.name;
|
||||||
this.standardStatistics = ['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount'];
|
this.standardStatistics = ['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount'];
|
||||||
|
this.debouncedAlert = memoizedDebounce(displayAlert, AppNotificationTimeout.Error);
|
||||||
|
this.debouncedCustomAlert = memoizedDebounce(displayCustomError, AppNotificationTimeout.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
query(options: DataQueryRequest<CloudWatchQuery>) {
|
query(options: DataQueryRequest<CloudWatchQuery>) {
|
||||||
options = angular.copy(options);
|
options = angular.copy(options);
|
||||||
options.targets = this.expandTemplateVariable(options.targets, options.scopedVars, this.templateSrv);
|
|
||||||
|
|
||||||
const queries = _.filter(options.targets, item => {
|
const queries = _.filter(options.targets, item => {
|
||||||
return (
|
return (
|
||||||
@@ -49,16 +73,14 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
item.expression.length > 0)
|
item.expression.length > 0)
|
||||||
);
|
);
|
||||||
}).map(item => {
|
}).map(item => {
|
||||||
item.region = this.templateSrv.replace(this.getActualRegion(item.region), options.scopedVars);
|
item.region = this.replace(this.getActualRegion(item.region), options.scopedVars, true, 'region');
|
||||||
item.namespace = this.templateSrv.replace(item.namespace, options.scopedVars);
|
item.namespace = this.replace(item.namespace, options.scopedVars, true, 'namespace');
|
||||||
item.metricName = this.templateSrv.replace(item.metricName, options.scopedVars);
|
item.metricName = this.replace(item.metricName, options.scopedVars, true, 'metric name');
|
||||||
item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars);
|
item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars);
|
||||||
item.statistics = item.statistics.map(s => {
|
item.statistics = item.statistics.map(stat => this.replace(stat, options.scopedVars, true, 'statistics'));
|
||||||
return this.templateSrv.replace(s, options.scopedVars);
|
|
||||||
});
|
|
||||||
item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting
|
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.id = this.replace(item.id, options.scopedVars, true, 'id');
|
||||||
item.expression = this.templateSrv.replace(item.expression, options.scopedVars);
|
item.expression = this.replace(item.expression, options.scopedVars, true, 'expression');
|
||||||
|
|
||||||
// valid ExtendedStatistics is like p90.00, check the pattern
|
// valid ExtendedStatistics is like p90.00, check the pattern
|
||||||
const hasInvalidStatistics = item.statistics.some(s => {
|
const hasInvalidStatistics = item.statistics.some(s => {
|
||||||
@@ -79,7 +101,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
refId: item.refId,
|
refId: item.refId,
|
||||||
intervalMs: options.intervalMs,
|
intervalMs: options.intervalMs,
|
||||||
maxDataPoints: options.maxDataPoints,
|
maxDataPoints: options.maxDataPoints,
|
||||||
datasourceId: this.instanceSettings.id,
|
datasourceId: this.id,
|
||||||
type: 'timeSeriesQuery',
|
type: 'timeSeriesQuery',
|
||||||
},
|
},
|
||||||
item
|
item
|
||||||
@@ -102,6 +124,10 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
return this.performTimeSeriesQuery(request, options.range);
|
return this.performTimeSeriesQuery(request, options.range);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get variables() {
|
||||||
|
return this.templateSrv.variables.map(v => `$${v.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
getPeriod(target: any, options: any, now?: number) {
|
getPeriod(target: any, options: any, now?: number) {
|
||||||
const start = this.convertToCloudWatchTime(options.range.from, false);
|
const start = this.convertToCloudWatchTime(options.range.from, false);
|
||||||
const end = this.convertToCloudWatchTime(options.range.to, true);
|
const end = this.convertToCloudWatchTime(options.range.to, true);
|
||||||
@@ -149,30 +175,50 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildCloudwatchConsoleUrl(
|
buildCloudwatchConsoleUrl(
|
||||||
{ region, namespace, metricName, dimensions, statistics, period }: CloudWatchQuery,
|
{ region, namespace, metricName, dimensions, statistics, period, expression }: CloudWatchQuery,
|
||||||
start: string,
|
start: string,
|
||||||
end: string,
|
end: string,
|
||||||
title: string
|
title: string,
|
||||||
|
gmdMeta: Array<{ Expression: string }>
|
||||||
) {
|
) {
|
||||||
const conf = {
|
region = this.getActualRegion(region);
|
||||||
|
let conf = {
|
||||||
view: 'timeSeries',
|
view: 'timeSeries',
|
||||||
stacked: false,
|
stacked: false,
|
||||||
title,
|
title,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
region,
|
region,
|
||||||
metrics: [
|
} as any;
|
||||||
...statistics.map(stat => [
|
|
||||||
namespace,
|
const isSearchExpression =
|
||||||
metricName,
|
gmdMeta && gmdMeta.length && gmdMeta.every(({ Expression: expression }) => /SEARCH().*/.test(expression));
|
||||||
...Object.entries(dimensions).reduce((acc, [key, value]) => [...acc, key, value], []),
|
const isMathExpression = !isSearchExpression && expression;
|
||||||
{
|
|
||||||
stat,
|
if (isMathExpression) {
|
||||||
period,
|
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(
|
return `https://${region}.console.aws.amazon.com/cloudwatch/deeplink.js?region=${region}#metricsV2:graph=${encodeURIComponent(
|
||||||
JSON.stringify(conf)
|
JSON.stringify(conf)
|
||||||
@@ -180,44 +226,70 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
}
|
}
|
||||||
|
|
||||||
performTimeSeriesQuery(request: any, { from, to }: TimeRange) {
|
performTimeSeriesQuery(request: any, { from, to }: TimeRange) {
|
||||||
return this.awsRequest('/api/tsdb/query', request).then((res: any) => {
|
return this.awsRequest('/api/tsdb/query', request)
|
||||||
if (!res.results) {
|
.then((res: any) => {
|
||||||
return { data: [] };
|
if (!res.results) {
|
||||||
}
|
return { data: [] };
|
||||||
const dataFrames = Object.values(request.queries).reduce((acc: any, queryRequest: any) => {
|
}
|
||||||
const queryResult = res.results[queryRequest.refId];
|
return Object.values(request.queries).reduce(
|
||||||
if (!queryResult) {
|
({ data, error }: any, queryRequest: any) => {
|
||||||
return acc;
|
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(
|
throw err;
|
||||||
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 };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
transformSuggestDataFromTable(suggestData: any) {
|
transformSuggestDataFromTable(suggestData: any) {
|
||||||
@@ -225,6 +297,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
return {
|
return {
|
||||||
text: v[0],
|
text: v[0],
|
||||||
value: v[1],
|
value: v[1],
|
||||||
|
label: v[1],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -240,7 +313,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
refId: 'metricFindQuery',
|
refId: 'metricFindQuery',
|
||||||
intervalMs: 1, // dummy
|
intervalMs: 1, // dummy
|
||||||
maxDataPoints: 1, // dummy
|
maxDataPoints: 1, // dummy
|
||||||
datasourceId: this.instanceSettings.id,
|
datasourceId: this.id,
|
||||||
type: 'metricFindQuery',
|
type: 'metricFindQuery',
|
||||||
subtype: subtype,
|
subtype: subtype,
|
||||||
},
|
},
|
||||||
@@ -260,34 +333,48 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
return this.doMetricQueryRequest('namespaces', null);
|
return this.doMetricQueryRequest('namespaces', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMetrics(namespace: string, region: string) {
|
async getMetrics(namespace: string, region: string) {
|
||||||
|
if (!namespace || !region) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return this.doMetricQueryRequest('metrics', {
|
return this.doMetricQueryRequest('metrics', {
|
||||||
region: this.templateSrv.replace(this.getActualRegion(region)),
|
region: this.templateSrv.replace(this.getActualRegion(region)),
|
||||||
namespace: this.templateSrv.replace(namespace),
|
namespace: this.templateSrv.replace(namespace),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getDimensionKeys(namespace: string, region: string) {
|
async getDimensionKeys(namespace: string, region: string) {
|
||||||
|
if (!namespace) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return this.doMetricQueryRequest('dimension_keys', {
|
return this.doMetricQueryRequest('dimension_keys', {
|
||||||
region: this.templateSrv.replace(this.getActualRegion(region)),
|
region: this.templateSrv.replace(this.getActualRegion(region)),
|
||||||
namespace: this.templateSrv.replace(namespace),
|
namespace: this.templateSrv.replace(namespace),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getDimensionValues(
|
async getDimensionValues(
|
||||||
region: string,
|
region: string,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
metricName: string,
|
metricName: string,
|
||||||
dimensionKey: string,
|
dimensionKey: string,
|
||||||
filterDimensions: {}
|
filterDimensions: {}
|
||||||
) {
|
) {
|
||||||
return this.doMetricQueryRequest('dimension_values', {
|
if (!namespace || !metricName) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = await this.doMetricQueryRequest('dimension_values', {
|
||||||
region: this.templateSrv.replace(this.getActualRegion(region)),
|
region: this.templateSrv.replace(this.getActualRegion(region)),
|
||||||
namespace: this.templateSrv.replace(namespace),
|
namespace: this.templateSrv.replace(namespace),
|
||||||
metricName: this.templateSrv.replace(metricName),
|
metricName: this.templateSrv.replace(metricName.trim()),
|
||||||
dimensionKey: this.templateSrv.replace(dimensionKey),
|
dimensionKey: this.templateSrv.replace(dimensionKey),
|
||||||
dimensions: this.convertDimensionFormat(filterDimensions, {}),
|
dimensions: this.convertDimensionFormat(filterDimensions, {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return values.length ? [{ value: '*', text: '*', label: '*' }, ...values] : values;
|
||||||
}
|
}
|
||||||
|
|
||||||
getEbsVolumeIds(region: string, instanceId: string) {
|
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 region;
|
||||||
let namespace;
|
let namespace;
|
||||||
let metricName;
|
let metricName;
|
||||||
@@ -382,6 +469,11 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
return this.getResourceARNs(region, resourceType, tagsJSON);
|
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([]);
|
return this.$q.when([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +506,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
refId: 'annotationQuery',
|
refId: 'annotationQuery',
|
||||||
intervalMs: 1, // dummy
|
intervalMs: 1, // dummy
|
||||||
maxDataPoints: 1, // dummy
|
maxDataPoints: 1, // dummy
|
||||||
datasourceId: this.instanceSettings.id,
|
datasourceId: this.id,
|
||||||
type: 'annotationQuery',
|
type: 'annotationQuery',
|
||||||
},
|
},
|
||||||
parameters
|
parameters
|
||||||
@@ -445,7 +537,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
}
|
}
|
||||||
|
|
||||||
testDatasource() {
|
testDatasource() {
|
||||||
/* use billing metrics for test */
|
// use billing metrics for test
|
||||||
const region = this.defaultRegion;
|
const region = this.defaultRegion;
|
||||||
const namespace = 'AWS/Billing';
|
const namespace = 'AWS/Billing';
|
||||||
const metricName = 'EstimatedCharges';
|
const metricName = 'EstimatedCharges';
|
||||||
@@ -479,68 +571,6 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
return region;
|
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) {
|
convertToCloudWatchTime(date: any, roundUp: any) {
|
||||||
if (_.isString(date)) {
|
if (_.isString(date)) {
|
||||||
date = dateMath.parse(date, roundUp);
|
date = dateMath.parse(date, roundUp);
|
||||||
@@ -548,11 +578,38 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery>
|
|||||||
return Math.round(date.valueOf() / 1000);
|
return Math.round(date.valueOf() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertDimensionFormat(dimensions: any, scopedVars: ScopedVars) {
|
convertDimensionFormat(dimensions: { [key: string]: string | string[] }, scopedVars: ScopedVars) {
|
||||||
const convertedDimensions: any = {};
|
return Object.entries(dimensions).reduce((result, [key, value]) => {
|
||||||
_.each(dimensions, (value, key) => {
|
key = this.replace(key, scopedVars, true, 'dimension keys');
|
||||||
convertedDimensions[this.templateSrv.replace(key, scopedVars)] = this.templateSrv.replace(value, scopedVars);
|
|
||||||
});
|
if (Array.isArray(value)) {
|
||||||
return convertedDimensions;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
public/app/plugins/datasource/cloudwatch/memoizedDebounce.ts
Normal file
13
public/app/plugins/datasource/cloudwatch/memoizedDebounce.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
16
public/app/plugins/datasource/cloudwatch/module.tsx
Normal file
16
public/app/plugins/datasource/cloudwatch/module.tsx
Normal 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);
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -1,92 +1,143 @@
|
|||||||
<div class="gf-form-inline">
|
<div class="gf-form-inline">
|
||||||
<div class="gf-form">
|
<div class="gf-form">
|
||||||
<label class="gf-form-label query-keyword width-8">Region</label>
|
<label class="gf-form-label query-keyword width-8">Region</label>
|
||||||
<metric-segment segment="regionSegment" get-options="getRegions()" on-change="regionChanged()"></metric-segment>
|
<metric-segment segment="regionSegment" get-options="getRegions()" on-change="regionChanged()"></metric-segment>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gf-form gf-form--grow">
|
<div class="gf-form gf-form--grow">
|
||||||
<div class="gf-form-label gf-form-label--grow"></div>
|
<div class="gf-form-label gf-form-label--grow"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gf-form-inline" ng-if="target.expression.length === 0">
|
<div class="gf-form-inline" ng-if="target.expression.length === 0">
|
||||||
<div class="gf-form">
|
<div class="gf-form">
|
||||||
<label class="gf-form-label query-keyword width-8">Metric</label>
|
<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
|
||||||
<metric-segment segment="metricSegment" get-options="getMetrics()" on-change="metricChanged()"></metric-segment>
|
segment="namespaceSegment"
|
||||||
</div>
|
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">
|
<div class="gf-form">
|
||||||
<label class="gf-form-label query-keyword">Stats</label>
|
<label class="gf-form-label query-keyword">Stats</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gf-form" ng-repeat="segment in statSegments">
|
<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>
|
<metric-segment
|
||||||
</div>
|
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 gf-form--grow">
|
||||||
<div class="gf-form-label gf-form-label--grow"></div>
|
<div class="gf-form-label gf-form-label--grow"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gf-form-inline" ng-if="target.expression.length === 0">
|
<div class="gf-form-inline" ng-if="target.expression.length === 0">
|
||||||
<div class="gf-form">
|
<div class="gf-form">
|
||||||
<label class="gf-form-label query-keyword width-8">Dimensions</label>
|
<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>
|
<metric-segment
|
||||||
</div>
|
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 gf-form--grow">
|
||||||
<div class="gf-form-label gf-form-label--grow"></div>
|
<div class="gf-form-label gf-form-label--grow"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gf-form-inline" ng-if="target.statistics.length === 1">
|
<div class="gf-form-inline" ng-if="target.statistics.length === 1">
|
||||||
<div class="gf-form">
|
<div class="gf-form">
|
||||||
<label class=" gf-form-label query-keyword width-8 ">
|
<label class=" gf-form-label query-keyword width-8 ">
|
||||||
Id
|
Id
|
||||||
<info-popover mode="right-normal ">Id can include numbers, letters, and underscore, and must start with a lowercase letter.</info-popover>
|
<info-popover mode="right-normal "
|
||||||
</label>
|
>Id can include numbers, letters, and underscore, and must start with a lowercase letter.</info-popover
|
||||||
<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>
|
</label>
|
||||||
<div class="gf-form max-width-30 ">
|
<input
|
||||||
<label class="gf-form-label query-keyword width-7 ">Expression</label>
|
type="text "
|
||||||
<input type="text " class="gf-form-input " ng-model="target.expression
|
class="gf-form-input "
|
||||||
" spellcheck='false' ng-model-onblur ng-change="onChange() ">
|
ng-model="target.id "
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div class="gf-form-inline ">
|
<div class="gf-form-inline ">
|
||||||
<div class="gf-form ">
|
<div class="gf-form ">
|
||||||
<label class="gf-form-label query-keyword width-8 ">
|
<label class="gf-form-label query-keyword width-8 ">
|
||||||
Min period
|
Min period
|
||||||
<info-popover mode="right-normal ">Minimum interval between points in seconds</info-popover>
|
<info-popover mode="right-normal ">Minimum interval between points in seconds</info-popover>
|
||||||
</label>
|
</label>
|
||||||
<input type="text " class="gf-form-input " ng-model="target.period " spellcheck='false' placeholder="auto
|
<input
|
||||||
" ng-model-onblur ng-change="onChange() " />
|
type="text "
|
||||||
</div>
|
class="gf-form-input "
|
||||||
<div class="gf-form max-width-30 ">
|
ng-model="target.period "
|
||||||
<label class="gf-form-label query-keyword width-7 ">Alias</label>
|
spellcheck="false"
|
||||||
<input type="text " class="gf-form-input " ng-model="target.alias " spellcheck='false' ng-model-onblur ng-change="onChange() ">
|
placeholder="auto
|
||||||
<info-popover mode="right-absolute ">
|
"
|
||||||
Alias replacement variables:
|
ng-model-onblur
|
||||||
<ul ng-non-bindable>
|
ng-change="onChange() "
|
||||||
<li>{{metric}}</li>
|
/>
|
||||||
<li>{{stat}}</li>
|
</div>
|
||||||
<li>{{namespace}}</li>
|
<div class="gf-form max-width-30 ">
|
||||||
<li>{{region}}</li>
|
<label class="gf-form-label query-keyword width-7 ">Alias</label>
|
||||||
<li>{{period}}</li>
|
<input
|
||||||
<li>{{label}}</li>
|
type="text "
|
||||||
<li>{{YOUR_DIMENSION_NAME}}</li>
|
class="gf-form-input "
|
||||||
</ul>
|
ng-model="target.alias "
|
||||||
</info-popover>
|
spellcheck="false"
|
||||||
</div>
|
ng-model-onblur
|
||||||
<div class="gf-form ">
|
ng-change="onChange() "
|
||||||
<gf-form-switch class="gf-form " label="HighRes " label-class="width-5 " checked="target.highResolution " on-change="onChange() ">
|
/>
|
||||||
</gf-form-switch>
|
<info-popover mode="right-absolute ">
|
||||||
</div>
|
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 gf-form--grow ">
|
||||||
<div class="gf-form-label gf-form-label--grow "></div>
|
<div class="gf-form-label gf-form-label--grow "></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"metrics": true,
|
"metrics": true,
|
||||||
"alerting": true,
|
"alerting": true,
|
||||||
"annotations": true,
|
"annotations": true,
|
||||||
|
|
||||||
"info": {
|
"info": {
|
||||||
"description": "Data source for Amazon AWS monitoring service",
|
"description": "Data source for Amazon AWS monitoring service",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -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>}}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import '../datasource';
|
import '../datasource';
|
||||||
import CloudWatchDatasource from '../datasource';
|
import CloudWatchDatasource from '../datasource';
|
||||||
|
import * as redux from 'app/store/store';
|
||||||
import { dateMath } from '@grafana/data';
|
import { dateMath } from '@grafana/data';
|
||||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||||
import { CustomVariable } from 'app/features/templating/all';
|
import { CustomVariable } from 'app/features/templating/all';
|
||||||
@@ -12,6 +13,7 @@ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
|||||||
describe('CloudWatchDatasource', () => {
|
describe('CloudWatchDatasource', () => {
|
||||||
const instanceSettings = {
|
const instanceSettings = {
|
||||||
jsonData: { defaultRegion: 'us-east-1' },
|
jsonData: { defaultRegion: 'us-east-1' },
|
||||||
|
name: 'TestDatasource',
|
||||||
} as DataSourceInstanceSettings;
|
} as DataSourceInstanceSettings;
|
||||||
|
|
||||||
const templateSrv = new TemplateSrv();
|
const templateSrv = new TemplateSrv();
|
||||||
@@ -45,6 +47,7 @@ describe('CloudWatchDatasource', () => {
|
|||||||
rangeRaw: { from: 1483228800, to: 1483232400 },
|
rangeRaw: { from: 1483228800, to: 1483232400 },
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
|
expression: '',
|
||||||
refId: 'A',
|
refId: 'A',
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
namespace: 'AWS/EC2',
|
namespace: 'AWS/EC2',
|
||||||
@@ -90,7 +93,7 @@ describe('CloudWatchDatasource', () => {
|
|||||||
const params = requestParams.queries[0];
|
const params = requestParams.queries[0];
|
||||||
expect(params.namespace).toBe(query.targets[0].namespace);
|
expect(params.namespace).toBe(query.targets[0].namespace);
|
||||||
expect(params.metricName).toBe(query.targets[0].metricName);
|
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.statistics).toEqual(query.targets[0].statistics);
|
||||||
expect(params.period).toBe(query.targets[0].period);
|
expect(params.period).toBe(query.targets[0].period);
|
||||||
done();
|
done();
|
||||||
@@ -164,6 +167,142 @@ describe('CloudWatchDatasource', () => {
|
|||||||
done();
|
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"', () => {
|
describe('When query region is "default"', () => {
|
||||||
@@ -308,6 +447,21 @@ describe('CloudWatchDatasource', () => {
|
|||||||
},
|
},
|
||||||
{} as any
|
{} 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 => {
|
ctx.backendSrv.datasourceRequest = jest.fn(params => {
|
||||||
@@ -336,12 +490,12 @@ describe('CloudWatchDatasource', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ctx.ds.query(query).then(() => {
|
ctx.ds.query(query).then(() => {
|
||||||
expect(requestParams.queries[0].dimensions['dim2']).toBe('var2-foo');
|
expect(requestParams.queries[0].dimensions['dim2']).toStrictEqual(['var2-foo']);
|
||||||
done();
|
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 = {
|
const query = {
|
||||||
range: defaultTimeRange,
|
range: defaultTimeRange,
|
||||||
rangeRaw: { from: 1483228800, to: 1483232400 },
|
rangeRaw: { from: 1483228800, to: 1483232400 },
|
||||||
@@ -367,12 +521,38 @@ describe('CloudWatchDatasource', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ctx.ds.query(query).then(() => {
|
ctx.ds.query(query).then(() => {
|
||||||
expect(requestParams.queries[0].dimensions['dim1']).toBe('var1-foo');
|
expect(requestParams.queries[0].dimensions['dim1']).toStrictEqual(['var1-foo']);
|
||||||
expect(requestParams.queries[0].dimensions['dim2']).toBe('var2-foo');
|
expect(requestParams.queries[0].dimensions['dim2']).toStrictEqual(['var2-foo']);
|
||||||
expect(requestParams.queries[0].dimensions['dim3']).toBe('var3-foo');
|
expect(requestParams.queries[0].dimensions['dim3']).toStrictEqual(['var3-foo', 'var3-baz']);
|
||||||
expect(requestParams.queries[1].dimensions['dim1']).toBe('var1-foo');
|
done();
|
||||||
expect(requestParams.queries[1].dimensions['dim2']).toBe('var2-foo');
|
});
|
||||||
expect(requestParams.queries[1].dimensions['dim3']).toBe('var3-baz');
|
});
|
||||||
|
|
||||||
|
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();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -402,67 +582,9 @@ describe('CloudWatchDatasource', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ctx.ds.query(query).then(() => {
|
ctx.ds.query(query).then(() => {
|
||||||
expect(requestParams.queries[0].dimensions['dim1']).toBe('var1-foo');
|
expect(requestParams.queries[0].dimensions['dim1']).toStrictEqual(['var1-foo']);
|
||||||
expect(requestParams.queries[0].dimensions['dim2']).toBe('var2-foo');
|
expect(requestParams.queries[0].dimensions['dim2']).toStrictEqual(['var2-foo']);
|
||||||
expect(requestParams.queries[0].dimensions['dim3']).toBe('var3-foo');
|
expect(requestParams.queries[0].dimensions['dim3']).toStrictEqual(['var3-foo', 'var3-baz']);
|
||||||
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');
|
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -471,9 +593,9 @@ describe('CloudWatchDatasource', () => {
|
|||||||
function describeMetricFindQuery(query: any, func: any) {
|
function describeMetricFindQuery(query: any, func: any) {
|
||||||
describe('metricFindQuery ' + query, () => {
|
describe('metricFindQuery ' + query, () => {
|
||||||
const scenario: any = {};
|
const scenario: any = {};
|
||||||
scenario.setup = (setupCallback: any) => {
|
scenario.setup = async (setupCallback: any) => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
setupCallback();
|
await setupCallback();
|
||||||
ctx.backendSrv.datasourceRequest = jest.fn(args => {
|
ctx.backendSrv.datasourceRequest = jest.fn(args => {
|
||||||
scenario.request = args.data;
|
scenario.request = args.data;
|
||||||
return Promise.resolve({ data: scenario.requestResponse });
|
return Promise.resolve({ data: scenario.requestResponse });
|
||||||
@@ -488,8 +610,8 @@ describe('CloudWatchDatasource', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describeMetricFindQuery('regions()', (scenario: any) => {
|
describeMetricFindQuery('regions()', async (scenario: any) => {
|
||||||
scenario.setup(() => {
|
await scenario.setup(() => {
|
||||||
scenario.requestResponse = {
|
scenario.requestResponse = {
|
||||||
results: {
|
results: {
|
||||||
metricFindQuery: {
|
metricFindQuery: {
|
||||||
@@ -506,8 +628,8 @@ describe('CloudWatchDatasource', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describeMetricFindQuery('namespaces()', (scenario: any) => {
|
describeMetricFindQuery('namespaces()', async (scenario: any) => {
|
||||||
scenario.setup(() => {
|
await scenario.setup(() => {
|
||||||
scenario.requestResponse = {
|
scenario.requestResponse = {
|
||||||
results: {
|
results: {
|
||||||
metricFindQuery: {
|
metricFindQuery: {
|
||||||
@@ -524,8 +646,8 @@ describe('CloudWatchDatasource', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describeMetricFindQuery('metrics(AWS/EC2)', (scenario: any) => {
|
describeMetricFindQuery('metrics(AWS/EC2, us-east-2)', async (scenario: any) => {
|
||||||
scenario.setup(() => {
|
await scenario.setup(() => {
|
||||||
scenario.requestResponse = {
|
scenario.requestResponse = {
|
||||||
results: {
|
results: {
|
||||||
metricFindQuery: {
|
metricFindQuery: {
|
||||||
@@ -542,8 +664,8 @@ describe('CloudWatchDatasource', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describeMetricFindQuery('dimension_keys(AWS/EC2)', (scenario: any) => {
|
describeMetricFindQuery('dimension_keys(AWS/EC2)', async (scenario: any) => {
|
||||||
scenario.setup(() => {
|
await scenario.setup(() => {
|
||||||
scenario.requestResponse = {
|
scenario.requestResponse = {
|
||||||
results: {
|
results: {
|
||||||
metricFindQuery: {
|
metricFindQuery: {
|
||||||
@@ -554,14 +676,15 @@ describe('CloudWatchDatasource', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call __GetDimensions and return result', () => {
|
it('should call __GetDimensions and return result', () => {
|
||||||
|
console.log({ a: scenario.requestResponse.results });
|
||||||
expect(scenario.result[0].text).toBe('InstanceId');
|
expect(scenario.result[0].text).toBe('InstanceId');
|
||||||
expect(scenario.request.queries[0].type).toBe('metricFindQuery');
|
expect(scenario.request.queries[0].type).toBe('metricFindQuery');
|
||||||
expect(scenario.request.queries[0].subtype).toBe('dimension_keys');
|
expect(scenario.request.queries[0].subtype).toBe('dimension_keys');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describeMetricFindQuery('dimension_values(us-east-1,AWS/EC2,CPUUtilization,InstanceId)', (scenario: any) => {
|
describeMetricFindQuery('dimension_values(us-east-1,AWS/EC2,CPUUtilization,InstanceId)', async (scenario: any) => {
|
||||||
scenario.setup(() => {
|
await scenario.setup(() => {
|
||||||
scenario.requestResponse = {
|
scenario.requestResponse = {
|
||||||
results: {
|
results: {
|
||||||
metricFindQuery: {
|
metricFindQuery: {
|
||||||
@@ -578,8 +701,8 @@ describe('CloudWatchDatasource', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describeMetricFindQuery('dimension_values(default,AWS/EC2,CPUUtilization,InstanceId)', (scenario: any) => {
|
describeMetricFindQuery('dimension_values(default,AWS/EC2,CPUUtilization,InstanceId)', async (scenario: any) => {
|
||||||
scenario.setup(() => {
|
await scenario.setup(() => {
|
||||||
scenario.requestResponse = {
|
scenario.requestResponse = {
|
||||||
results: {
|
results: {
|
||||||
metricFindQuery: {
|
metricFindQuery: {
|
||||||
@@ -596,32 +719,35 @@ describe('CloudWatchDatasource', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describeMetricFindQuery('resource_arns(default,ec2:instance,{"environment":["production"]})', (scenario: any) => {
|
describeMetricFindQuery(
|
||||||
scenario.setup(() => {
|
'resource_arns(default,ec2:instance,{"environment":["production"]})',
|
||||||
scenario.requestResponse = {
|
async (scenario: any) => {
|
||||||
results: {
|
await scenario.setup(() => {
|
||||||
metricFindQuery: {
|
scenario.requestResponse = {
|
||||||
tables: [
|
results: {
|
||||||
{
|
metricFindQuery: {
|
||||||
rows: [
|
tables: [
|
||||||
[
|
{
|
||||||
'arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567',
|
rows: [
|
||||||
'arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321',
|
[
|
||||||
|
'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', () => {
|
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.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].type).toBe('metricFindQuery');
|
||||||
expect(scenario.request.queries[0].subtype).toBe('resource_arns');
|
expect(scenario.request.queries[0].subtype).toBe('resource_arns');
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('should caclculate the correct period', () => {
|
it('should caclculate the correct period', () => {
|
||||||
const hourSec = 60 * 60;
|
const hourSec = 60 * 60;
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { DataQuery } from '@grafana/data';
|
import { DataQuery, SelectableValue, DataSourceJsonData } from '@grafana/data';
|
||||||
|
|
||||||
export interface CloudWatchQuery extends DataQuery {
|
export interface CloudWatchQuery extends DataQuery {
|
||||||
id: string;
|
id: string;
|
||||||
region: string;
|
region: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
metricName: string;
|
metricName: string;
|
||||||
dimensions: { [key: string]: string };
|
dimensions: { [key: string]: string | string[] };
|
||||||
statistics: string[];
|
statistics: string[];
|
||||||
period: string;
|
period: string;
|
||||||
expression: 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class ElasticsearchQueryField extends React.PureComponent<Props, State> {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{data && data.error ? <div className="prom-query-field-info text-error"> data.error.message}</div> : null}
|
{data && data.error ? <div className="prom-query-field-info text-error">{data.error.message}</div> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
|||||||
const expandedQuery = {
|
const expandedQuery = {
|
||||||
...query,
|
...query,
|
||||||
datasource: this.name,
|
datasource: this.name,
|
||||||
query: this.templateSrv.replace(query.query),
|
query: this.templateSrv.replace(query.query, {}, 'lucene'),
|
||||||
};
|
};
|
||||||
return expandedQuery;
|
return expandedQuery;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export class InfluxLogsQueryField extends React.PureComponent<Props, State> {
|
|||||||
const { datasource } = this.props;
|
const { datasource } = this.props;
|
||||||
const { measurements, measurement, field, error } = this.state;
|
const { measurements, measurement, field, error } = this.state;
|
||||||
const cascadeText = getChooserText({ measurement, field, error });
|
const cascadeText = getChooserText({ measurement, field, error });
|
||||||
|
const hasMeasurement = measurements && measurements.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gf-form-inline gf-form-inline--nowrap">
|
<div className="gf-form-inline gf-form-inline--nowrap">
|
||||||
@@ -143,7 +144,7 @@ export class InfluxLogsQueryField extends React.PureComponent<Props, State> {
|
|||||||
onChange={this.onMeasurementsChange}
|
onChange={this.onMeasurementsChange}
|
||||||
expandIcon={null}
|
expandIcon={null}
|
||||||
>
|
>
|
||||||
<button className="gf-form-label gf-form-label--btn" disabled={!measurement}>
|
<button className="gf-form-label gf-form-label--btn" disabled={!hasMeasurement}>
|
||||||
{cascadeText} <i className="fa fa-caret-down" />
|
{cascadeText} <i className="fa fa-caret-down" />
|
||||||
</button>
|
</button>
|
||||||
</Cascader>
|
</Cascader>
|
||||||
|
|||||||
@@ -221,11 +221,11 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
|||||||
};
|
};
|
||||||
|
|
||||||
onClickHintFix = () => {
|
onClickHintFix = () => {
|
||||||
|
const { datasource, query, onChange, onRunQuery } = this.props;
|
||||||
const { hint } = this.state;
|
const { hint } = this.state;
|
||||||
const { onHint } = this.props;
|
|
||||||
if (onHint && hint && hint.fix) {
|
onChange(datasource.modifyQuery(query, hint.fix.action));
|
||||||
onHint(hint.fix.action);
|
onRunQuery();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onUpdateLanguage = () => {
|
onUpdateLanguage = () => {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ class PrometheusAnnotationsQueryCtrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const plugin = new DataSourcePlugin(PrometheusDatasource)
|
export const plugin = new DataSourcePlugin(PrometheusDatasource)
|
||||||
.setQueryCtrl(PromQueryEditor)
|
.setQueryEditor(PromQueryEditor)
|
||||||
.setConfigEditor(ConfigEditor)
|
.setConfigEditor(ConfigEditor)
|
||||||
.setExploreLogsQueryField(PromQueryField)
|
.setExploreMetricsQueryField(PromQueryField)
|
||||||
.setAnnotationQueryCtrl(PrometheusAnnotationsQueryCtrl)
|
.setAnnotationQueryCtrl(PrometheusAnnotationsQueryCtrl)
|
||||||
.setExploreStartPage(PromCheatSheet);
|
.setExploreStartPage(PromCheatSheet);
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export class ResultTransformer {
|
|||||||
return {
|
return {
|
||||||
datapoints: dps,
|
datapoints: dps,
|
||||||
query: options.query,
|
query: options.query,
|
||||||
|
refId: options.refId,
|
||||||
target: metricLabel,
|
target: metricLabel,
|
||||||
tags: metricData.metric,
|
tags: metricData.metric,
|
||||||
};
|
};
|
||||||
@@ -82,6 +83,8 @@ export class ResultTransformer {
|
|||||||
|
|
||||||
transformMetricDataToTable(md: any, resultCount: number, refId: string, valueWithRefId?: boolean): TableModel {
|
transformMetricDataToTable(md: any, resultCount: number, refId: string, valueWithRefId?: boolean): TableModel {
|
||||||
const table = new TableModel();
|
const table = new TableModel();
|
||||||
|
table.refId = refId;
|
||||||
|
|
||||||
let i: number, j: number;
|
let i: number, j: number;
|
||||||
const metricLabels: { [key: string]: number } = {};
|
const metricLabels: { [key: string]: number } = {};
|
||||||
|
|
||||||
@@ -141,7 +144,7 @@ export class ResultTransformer {
|
|||||||
let metricLabel = null;
|
let metricLabel = null;
|
||||||
metricLabel = this.createMetricLabel(md.metric, options);
|
metricLabel = this.createMetricLabel(md.metric, options);
|
||||||
dps.push([parseFloat(md.value[1]), md.value[0] * 1000]);
|
dps.push([parseFloat(md.value[1]), md.value[0] * 1000]);
|
||||||
return { target: metricLabel, datapoints: dps, tags: md.metric };
|
return { target: metricLabel, datapoints: dps, tags: md.metric, refId: options.refId };
|
||||||
}
|
}
|
||||||
|
|
||||||
createMetricLabel(labelData: { [key: string]: string }, options: any) {
|
createMetricLabel(labelData: { [key: string]: string }, options: any) {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { plugin as PrometheusDatasourcePlugin } from '../module';
|
||||||
|
|
||||||
|
describe('module', () => {
|
||||||
|
it('should have metrics query field in panels and Explore', () => {
|
||||||
|
expect(PrometheusDatasourcePlugin.components.ExploreMetricsQueryField).toBeDefined();
|
||||||
|
expect(PrometheusDatasourcePlugin.components.QueryEditor).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,7 +59,7 @@ describe('Prometheus Result Transformer', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it('should return table model', () => {
|
it('should return table model', () => {
|
||||||
const table = ctx.resultTransformer.transformMetricDataToTable(response.data.result);
|
const table = ctx.resultTransformer.transformMetricDataToTable(response.data.result, 0, 'A');
|
||||||
expect(table.type).toBe('table');
|
expect(table.type).toBe('table');
|
||||||
expect(table.rows).toEqual([
|
expect(table.rows).toEqual([
|
||||||
[1443454528000, 'test', '', 'testjob', 3846],
|
[1443454528000, 'test', '', 'testjob', 3846],
|
||||||
@@ -73,6 +73,7 @@ describe('Prometheus Result Transformer', () => {
|
|||||||
{ text: 'Value' },
|
{ text: 'Value' },
|
||||||
]);
|
]);
|
||||||
expect(table.columns[4].filterable).toBeUndefined();
|
expect(table.columns[4].filterable).toBeUndefined();
|
||||||
|
expect(table.refId).toBe('A');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should column title include refId if response count is more than 2', () => {
|
it('should column title include refId if response count is more than 2', () => {
|
||||||
@@ -217,6 +218,7 @@ describe('Prometheus Result Transformer', () => {
|
|||||||
format: 'timeseries',
|
format: 'timeseries',
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 2,
|
end: 2,
|
||||||
|
refId: 'B',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = ctx.resultTransformer.transform({ data: response }, options);
|
const result = ctx.resultTransformer.transform({ data: response }, options);
|
||||||
@@ -226,6 +228,7 @@ describe('Prometheus Result Transformer', () => {
|
|||||||
query: undefined,
|
query: undefined,
|
||||||
datapoints: [[10, 0], [10, 1000], [0, 2000]],
|
datapoints: [[10, 0], [10, 1000], [0, 2000]],
|
||||||
tags: { job: 'testjob' },
|
tags: { job: 'testjob' },
|
||||||
|
refId: 'B',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user