Compare commits

...

22 Commits

Author SHA1 Message Date
Sofia Papagiannaki
ab9c0da30e MySql: Fix password regression in MySQL datasource (#20376)
(cherry picked from commit 2ca1cc5645)
2019-11-14 08:33:19 -05:00
Erik Sundell
1f3c557dfd CloudWatch: Datasource improvements (#20268)
* CloudWatch: Datasource improvements

* Add statistic as template variale

* Add wildcard to list of values

* Template variable intercept dimension key

* Return row specific errors when transformation error occured

* Add meta feedback

* Make it possible to retrieve values without known metrics

* Add curated dashboard for EC2

* Fix broken tests

* Use correct dashboard name

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

* Minor fixes after feedback

* Update dashboard json

* Update snapshot test

* Make sure region default is intercepted in cloudwatch link

* Update dashboards

* Include ec2 dashboard in ds

* Do not include ec2 dashboard in beta1

* Display actual region

(cherry picked from commit 00bef917ee)
2019-11-14 08:33:19 -05:00
Ryan McKinley
6686611369 grafana/toolkit: remove aws-sdk and upload to grafana.com API endpoint (#20372)
* remove aws-sdk and upload directly

* remove unused imports

* put the plugin file in the root directory

(cherry picked from commit 1f018adbf3)
2019-11-14 08:33:19 -05:00
Leonard Gram
e19d43ef2d LDAP: last org admin can login but wont be removed (#20326)
* LDAP: last org admin (that's going to be removed) can login
Previously, if you tried to login with LDAP but were that last org admin
of an org that you would no longer be an admin of after sync (which
happens at login), you wouldn't be able to login due to an error.

(cherry picked from commit e9668fd251)
2019-11-14 08:33:19 -05:00
Šimon Podlipský
a8f13bb0c1 DataFrame processing: Require table rows to be array (#20357)
(cherry picked from commit 4260cd548f)
2019-11-14 08:33:19 -05:00
Marcus Efraimsson
0773ae80ea Telegram: Check error before adding defer close of image (#20331)
Properly handles file opening error and returns before deferring 
close of file.

Fixes #20156

(cherry picked from commit 5b42bb58f6)
2019-11-14 08:33:19 -05:00
Lukas Siatka
79bfdcb122 Explore: updates breakpoint used to collapse datasource picker
(cherry picked from commit adc84c6ac5)
2019-11-14 08:33:19 -05:00
Ivana Huckova
4d7edd3cd8 Elastic: Fix Elastic template variables interpolation when redirecting to Explore (#20314)
(cherry picked from commit 822b0b2708)
2019-11-14 08:33:19 -05:00
Torkel Ödegaard
8fa29f2497 Links: Updated links to grafana.com (#20320)
* Links: Updated links to grafana.com

* Updated snapshot

(cherry picked from commit 6959cf77ca)
2019-11-14 08:33:19 -05:00
Arve Knudsen
8cb1af2b21 Avatar: Don't log failure to add existing item to cache (#19947)
Checks if avatar was found in cache before trying to add it to cache.

Fixes #19946

(cherry picked from commit 3a8cd7b76c)
2019-11-14 08:33:19 -05:00
Arve Knudsen
33d84abf2c Build: Fix Docker builds (#20312)
(cherry picked from commit 08fcff107d)
2019-11-14 08:33:19 -05:00
Arve Knudsen
9a584bc798 Build: Build Ubuntu based Docker images also for ARM (#20267)
Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>
(cherry picked from commit ff47238b26)
2019-11-14 08:33:19 -05:00
Torkel Ödegaard
fd491c39a3 Prometheus: Adds hint support to dashboard and fixes prometheus link in query editor (#20275)
* Prometheus: moved hints into query editor, and fixed missing refIds in responses

* Minor fix

* Removed unused type import

(cherry picked from commit b756aa0bb1)
2019-11-14 08:33:19 -05:00
Ivana Huckova
47a199a731 Explore: Fix always disabled QueryField for InfluxDB (#20299)
(cherry picked from commit 78520ac3d1)
2019-11-14 08:33:19 -05:00
Andrej Ocenas
4647c48427 Explore: Fix interpolation of error message (#20301)
(cherry picked from commit a08c2c43db)
2019-11-14 08:33:19 -05:00
Torkel Ödegaard
d6f352cdf5 PanelLinks: fixed issue with old panel links and grafana behind a subpath (#20298)
(cherry picked from commit 6f3f0bf3e0)
2019-11-14 08:33:19 -05:00
Dominik Prokop
7b517bcb10 ColorPicker: Fixes issue with ColorPicker disappearing too quickly (#20289)
(cherry picked from commit 422a94707d)
2019-11-14 08:33:19 -05:00
Torkel Ödegaard
6243776004 Templating: Made default template variable query editor field a text area with dynamic automatic height (#20288)
(cherry picked from commit dd6f5efabe)
2019-11-14 08:33:19 -05:00
Torkel Ödegaard
0cc17c384a PanelData: Support showing data and errors in angular panels (#20286)
(cherry picked from commit 767c672a2f)
2019-11-14 08:33:19 -05:00
gotjosh
1033687df6 Fix: URL Encode Groupd IDs for external team sync (#20280)
* Fix: URL Encode Group IDs for external team sync

External Group IDs can have special characters. Encode them to make them
URL-safe.

(cherry picked from commit 7e96a57c37)
2019-11-14 08:33:19 -05:00
Lukas Siatka
3abca7a820 Datasource: fixes prometheus metrics query query field definition (#20273)
* Datasource: fixes prometheus metrics query query field definition

* Fix query editor for panels

(cherry picked from commit 26c030667a)
2019-11-14 08:33:19 -05:00
Arve Knudsen
ece9015afe Start version 6.5
Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>
2019-11-08 11:42:32 +01:00
108 changed files with 7886 additions and 2075 deletions

View File

@@ -2,5 +2,5 @@
"npmClient": "yarn",
"useWorkspaces": true,
"packages": ["packages/*"],
"version": "6.5.0-pre"
"version": "6.5.0-beta.1"
}

View File

@@ -3,7 +3,7 @@
"license": "Apache-2.0",
"private": true,
"name": "grafana",
"version": "6.5.0-pre",
"version": "6.5.0-beta1",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/data",
"version": "6.4.0-pre",
"version": "6.5.0-beta.1",
"description": "Grafana Data Library",
"keywords": [
"typescript"

View File

@@ -59,6 +59,15 @@ describe('toDataFrame', () => {
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', () => {
const oldDataFrame = {
fields: [{ name: 'A' }, { name: 'B' }, { name: 'C' }],

View File

@@ -1,7 +1,5 @@
// Libraries
import isNumber from 'lodash/isNumber';
import isString from 'lodash/isString';
import isBoolean from 'lodash/isBoolean';
import { isArray, isBoolean, isNumber, isString } from 'lodash';
// Types
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 (let i = 0; i < fields.length; i++) {
fields[i].values.buffer.push(row[i]);

View File

@@ -284,7 +284,6 @@ export interface ExploreQueryFieldProps<
> extends QueryEditorProps<DSType, TQuery, TOptions> {
history: any[];
onBlur?: () => void;
onHint?: (action: QueryFixAction) => void;
}
export interface ExploreStartPageProps {

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/runtime",
"version": "6.4.0-pre",
"version": "6.5.0-beta.1",
"description": "Grafana Runtime Library",
"keywords": [
"grafana",
@@ -21,8 +21,8 @@
"build": "grafana-toolkit package:build --scope=runtime"
},
"dependencies": {
"@grafana/data": "^6.4.0-alpha",
"@grafana/ui": "^6.4.0-alpha",
"@grafana/data": "6.5.0-beta.1",
"@grafana/ui": "6.5.0-beta.1",
"systemjs": "0.20.19",
"systemjs-plugin-css": "0.1.37"
},

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/toolkit",
"version": "6.4.0-pre",
"version": "6.5.0-beta.1",
"description": "Grafana Toolkit",
"keywords": [
"grafana",
@@ -28,8 +28,8 @@
"dependencies": {
"@babel/core": "7.6.4",
"@babel/preset-env": "7.6.3",
"@grafana/data": "^6.4.0-alpha",
"@grafana/ui": "^6.4.0-alpha",
"@grafana/data": "6.5.0-beta.1",
"@grafana/ui": "6.5.0-beta.1",
"@types/command-exists": "^1.2.0",
"@types/execa": "^0.9.0",
"@types/expect-puppeteer": "3.3.1",
@@ -42,7 +42,6 @@
"@types/semver": "^6.0.0",
"@types/tmp": "^0.1.0",
"@types/webpack": "4.4.34",
"aws-sdk": "^2.495.0",
"axios": "0.19.0",
"babel-jest": "24.8.0",
"babel-loader": "8.0.6",

View File

@@ -1,7 +1,6 @@
import { Task, TaskRunner } from './task';
import { pluginBuildRunner } from './plugin.build';
import { restoreCwd } from '../utils/cwd';
import { S3Client } from '../../plugins/aws';
import { getPluginJson } from '../../config/utils/pluginValidation';
import { getPluginId } from '../../config/utils/getPluginId';
import { PluginMeta } from '@grafana/data';
@@ -10,28 +9,18 @@ import { PluginMeta } from '@grafana/data';
import execa = require('execa');
import path = require('path');
import fs from 'fs';
import { getPackageDetails, findImagesInFolder, appendPluginHistory, getGrafanaVersions } from '../../plugins/utils';
import { getPackageDetails, findImagesInFolder, getGrafanaVersions } from '../../plugins/utils';
import {
job,
getJobFolder,
writeJobStats,
getCiFolder,
getPluginBuildInfo,
getBuildNumber,
getPullRequestNumber,
getCircleDownloadBaseURL,
} from '../../plugins/env';
import { agregateWorkflowInfo, agregateCoverageInfo, agregateTestInfo } from '../../plugins/workflow';
import {
PluginPackageDetails,
PluginBuildReport,
PluginHistory,
defaultPluginHistory,
TestResultsInfo,
PluginDevInfo,
PluginDevSummary,
DevSummary,
} from '../../plugins/types';
import { PluginPackageDetails, PluginBuildReport, TestResultsInfo } from '../../plugins/types';
import { runEndToEndTests } from '../../plugins/e2e/launcher';
import { getEndToEndSettings } from '../../plugins/index';
@@ -185,6 +174,9 @@ const packagePluginRunner: TaskRunner<PluginCIOptions> = async () => {
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 = {
plugin: await getPackageDetails(zipFile, distDir),
};
@@ -346,88 +338,19 @@ const pluginReportRunner: TaskRunner<PluginCIOptions> = async ({ upload }) => {
}
});
console.log('Initalizing S3 Client');
const s3 = new S3Client();
const build = pluginMeta.info.build;
if (!build) {
throw new Error('Metadata missing build info');
const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY;
if (!GRAFANA_API_KEY) {
console.log('Enter a GRAFANA_API_KEY to upload the plugin report');
return;
}
const url = `https://grafana.com/api/plugins/${report.plugin.id}/ci`;
const version = pluginMeta.info.version || 'unknown';
const branch = build.branch || 'unknown';
const buildNumber = getBuildNumber();
const root = `dev/${pluginMeta.id}`;
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('Sending report to:', url);
const axios = require('axios');
const info = await axios.post(url, report, {
headers: { Authorization: 'bearer ' + GRAFANA_API_KEY },
});
// 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');
console.log('RESULT: ', info);
};
export const ciPluginReportTask = new Task<PluginCIOptions>('Generate Plugin Report', pluginReportRunner);

View File

@@ -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;
}

View File

@@ -1,4 +1,3 @@
export * from './aws';
export * from './env';
export * from './utils';
export * from './workflow';

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/ui",
"version": "6.4.0-pre",
"version": "6.5.0-beta.1",
"description": "Grafana Components Library",
"keywords": [
"grafana",
@@ -25,7 +25,7 @@
"build": "grafana-toolkit package:build --scope=ui"
},
"dependencies": {
"@grafana/data": "^6.4.0-alpha",
"@grafana/data": "6.5.0-beta.1",
"@grafana/slate-react": "0.22.9-grafana",
"@torkelo/react-select": "2.1.1",
"@types/react-color": "2.17.0",

View File

@@ -2,6 +2,9 @@ $arrowSize: 15px;
.ColorPicker {
@extend .popper;
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 {
@@ -75,32 +78,19 @@ $arrowSize: 15px;
border-color: #1e2028;
}
// Top
.ColorPicker[data-placement^='top'] {
padding-bottom: $arrowSize;
}
// Bottom
// !important because these styles are also provided to popper via .popper classes from Tooltip component
// hope to get rid of those soon
.ColorPicker[data-placement^='top'],
.ColorPicker[data-placement^='bottom'] {
padding-top: $arrowSize;
padding-left: 0 !important;
padding-right: 0 !important;
}
.ColorPicker[data-placement^='bottom-start'] {
padding-top: $arrowSize;
}
.ColorPicker[data-placement^='bottom-end'] {
padding-top: $arrowSize;
}
// Right
// !important because these styles are also provided to popper via .popper classes from Tooltip component
// hope to get rid of those soon
.ColorPicker[data-placement^='left'],
.ColorPicker[data-placement^='right'] {
padding-left: $arrowSize;
}
// Left
.ColorPicker[data-placement^='left'] {
padding-right: $arrowSize;
padding-top: 0 !important;
}
.ColorPickerPopover {

View File

@@ -3,6 +3,7 @@ set -e
BUILD_FAST=0
UBUNTU_BASE=0
TAG_SUFFIX=""
while [ "$1" != "" ]; do
case "$1" in
@@ -13,6 +14,7 @@ while [ "$1" != "" ]; do
;;
"--ubuntu")
UBUNTU_BASE=1
TAG_SUFFIX="-ubuntu"
echo "Ubuntu base image enabled"
shift
;;
@@ -33,20 +35,40 @@ else
_grafana_version=$_grafana_tag
fi
if [ $UBUNTU_BASE = "0" ]; then
echo "Building ${_docker_repo}:${_grafana_version}"
else
echo "Building ${_docker_repo}:${_grafana_version}-ubuntu"
fi
echo "Building ${_docker_repo}:${_grafana_version}${TAG_SUFFIX}"
export DOCKER_CLI_EXPERIMENTAL=enabled
# Build grafana image for a specific arch
docker_build () {
base_image=$1
grafana_tgz=$2
tag=$3
dockerfile=${4:-Dockerfile}
arch=$1
case "$arch" in
"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 \
--build-arg BASE_IMAGE=${base_image} \
@@ -58,48 +80,32 @@ docker_build () {
}
docker_tag_linux_amd64 () {
repo=$1
tag=$2
docker tag "${_docker_repo}:${_grafana_version}" "${repo}:${tag}"
tag=$1
docker tag "${_docker_repo}:${_grafana_version}${TAG_SUFFIX}" "${_docker_repo}:${tag}${TAG_SUFFIX}"
}
# Tag docker images of all architectures
docker_tag_all () {
repo=$1
tag=$2
docker_tag_linux_amd64 $1 $2
tag=$1
docker_tag_linux_amd64 $1
if [ $BUILD_FAST = "0" ]; then
docker tag "${_docker_repo}-arm32v7-linux:${_grafana_version}" "${repo}-arm32v7-linux:${tag}"
docker tag "${_docker_repo}-arm64v8-linux:${_grafana_version}" "${repo}-arm64v8-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}${TAG_SUFFIX}" "${_docker_repo}-arm64v8-linux:${tag}${TAG_SUFFIX}"
fi
}
if [ $UBUNTU_BASE = "0" ]; then
docker_build "alpine:3.10" "grafana-latest.linux-x64-musl.tar.gz" "${_docker_repo}:${_grafana_version}"
if [ $BUILD_FAST = "0" ]; then
docker_build "arm32v7/alpine:3.10" "grafana-latest.linux-armv7-musl.tar.gz" "${_docker_repo}-arm32v7-linux:${_grafana_version}"
docker_build "arm64v8/alpine:3.10" "grafana-latest.linux-arm64-musl.tar.gz" "${_docker_repo}-arm64v8-linux:${_grafana_version}"
fi
# Tag as 'latest' for official release; otherwise tag as grafana/grafana:master
if echo "$_grafana_tag" | grep -q "^v"; then
docker_tag_all "${_docker_repo}" "latest"
# Create the expected tag for running the end to end tests successfully
docker tag "${_docker_repo}:${_grafana_version}" "grafana/grafana-dev:${_grafana_tag}"
else
docker_tag_all "${_docker_repo}" "master"
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
docker_build "x64"
if [ $BUILD_FAST = "0" ]; then
docker_build "armv7"
docker_build "arm64"
fi
# Tag as 'latest' for official release; otherwise tag as grafana/grafana:master
if echo "$_grafana_tag" | grep -q "^v"; then
docker_tag_all "latest"
# 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}"
else
docker_tag_all "master"
docker tag "${_docker_repo}:${_grafana_version}${TAG_SUFFIX}" "grafana/grafana-dev:${_grafana_version}${TAG_SUFFIX}"
fi

View File

@@ -2,11 +2,13 @@
set -e
UBUNTU_BASE=0
TAG_SUFFIX=""
while [ "$1" != "" ]; do
case "$1" in
"--ubuntu")
UBUNTU_BASE=1
TAG_SUFFIX="-ubuntu"
echo "Ubuntu base image enabled"
shift
;;
@@ -29,60 +31,39 @@ fi
export DOCKER_CLI_EXPERIMENTAL=enabled
if [ $UBUNTU_BASE = "0" ]; then
echo "pushing ${_docker_repo}:${_grafana_version}"
else
echo "pushing ${_docker_repo}:${_grafana_version}-ubuntu"
fi
echo "pushing ${_docker_repo}:${_grafana_version}${TAG_SUFFIX}"
docker_push_all () {
repo=$1
tag=$2
if [ $UBUNTU_BASE = "0" ]; then
# Push each image individually
docker push "${repo}:${tag}"
docker push "${repo}-arm32v7-linux:${tag}"
docker push "${repo}-arm64v8-linux:${tag}"
# Push each image individually
docker push "${repo}:${tag}${TAG_SUFFIX}"
docker push "${repo}-arm32v7-linux:${tag}${TAG_SUFFIX}"
docker push "${repo}-arm64v8-linux:${tag}${TAG_SUFFIX}"
# Create and push a multi-arch manifest
docker manifest create "${repo}:${tag}" \
"${repo}:${tag}" \
"${repo}-arm32v7-linux:${tag}" \
"${repo}-arm64v8-linux:${tag}"
# Create and push a multi-arch manifest
docker manifest create "${repo}:${tag}${TAG_SUFFIX}" \
"${repo}:${tag}${TAG_SUFFIX}" \
"${repo}-arm32v7-linux:${tag}${TAG_SUFFIX}" \
"${repo}-arm64v8-linux:${tag}${TAG_SUFFIX}"
docker manifest push "${repo}:${tag}"
else
docker push "${repo}:${tag}-ubuntu"
fi
docker manifest push "${repo}:${tag}${TAG_SUFFIX}"
}
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}" "${_grafana_version}"
# Push to the grafana-dev repository with the expected tag
# for running the end to end tests successfully
if [ ${UBUNTU_BASE} = "0" ]; then
docker push "grafana/grafana-dev:${_grafana_tag}"
else
docker push "grafana/grafana-dev:${_grafana_tag}-ubuntu"
fi
docker push "grafana/grafana-dev:${_grafana_tag}${TAG_SUFFIX}"
elif echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -q "beta"; then
docker_push_all "${_docker_repo}" "${_grafana_version}"
# Push to the grafana-dev repository with the expected tag
# for running the end to end tests successfully
if [ ${UBUNTU_BASE} = "0" ]; then
docker push "grafana/grafana-dev:${_grafana_tag}"
else
docker push "grafana/grafana-dev:${_grafana_tag}-ubuntu"
fi
docker push "grafana/grafana-dev:${_grafana_tag}${TAG_SUFFIX}"
elif echo "$_grafana_tag" | grep -q "master"; then
docker_push_all "${_docker_repo}" "master"
if [ ${UBUNTU_BASE} = "0" ]; then
docker push "grafana/grafana-dev:${_grafana_version}"
else
docker push "grafana/grafana-dev:${_grafana_version}-ubuntu"
fi
docker push "grafana/grafana-dev:${_grafana_version}${TAG_SUFFIX}"
fi

View File

@@ -88,14 +88,15 @@ func (this *CacheServer) Handler(ctx *macaron.Context) {
hash := urlPath[strings.LastIndex(urlPath, "/")+1:]
var avatar *Avatar
if obj, exist := this.cache.Get(hash); exist {
obj, exists := this.cache.Get(hash)
if exists {
avatar = obj.(*Avatar)
} else {
avatar = New(hash)
}
if avatar.Expired() {
// The cache item is either expired or newly created, update it from the server
if err := avatar.Update(); err != nil {
log.Trace("avatar update error: %v", err)
avatar = this.notFound
@@ -104,9 +105,9 @@ func (this *CacheServer) Handler(ctx *macaron.Context) {
if avatar.notFound {
avatar = this.notFound
} else {
} else if !exists {
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("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)
if err != nil {
this.Avatar.notFound = true
return fmt.Errorf("gravatar unreachable, %v", err)

View File

@@ -137,6 +137,10 @@ func (tn *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.EvalCo
var err error
imageFile, err = os.Open(evalContext.ImageOnDiskPath)
if err != nil {
return nil, err
}
defer func() {
err := imageFile.Close()
if err != nil {
@@ -144,10 +148,6 @@ func (tn *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.EvalCo
}
}()
if err != nil {
return nil, err
}
ruleURL, err := evalContext.GetRuleURL()
if err != nil {
return nil, err

View File

@@ -230,7 +230,12 @@ func syncOrgRoles(user *models.User, extUser *models.ExternalUserInfo) error {
// delete any removed org roles
for _, orgId := range deleteOrgIds {
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
}
}

View 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
}

View File

@@ -24,7 +24,7 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo
namespace := parameters.Get("namespace").MustString("")
metricName := parameters.Get("metricName").MustString("")
dimensions := parameters.Get("dimensions").MustMap()
statistics, extendedStatistics, err := parseStatistics(parameters)
statistics, err := parseStatistics(parameters)
if err != nil {
return nil, err
}
@@ -51,7 +51,7 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo
if err != nil {
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 {
if region == "" || namespace == "" || metricName == "" || len(statistics) == 0 {
return result, nil
@@ -82,22 +82,6 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo
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()
@@ -158,7 +142,7 @@ func transformAnnotationToTable(data []map[string]string, result *tsdb.QueryResu
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)
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 {
continue
}

View File

@@ -2,18 +2,13 @@ package cloudwatch
import (
"context"
"fmt"
"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/resourcegroupstaggingapi/resourcegroupstaggingapiiface"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/tsdb"
"golang.org/x/sync/errgroup"
)
type CloudWatchExecutor struct {
@@ -38,21 +33,13 @@ func NewCloudWatchExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, e
}
var (
plog log.Logger
standardStatistics map[string]bool
aliasFormat *regexp.Regexp
plog log.Logger
aliasFormat *regexp.Regexp
)
func init() {
plog = log.New("tsdb.cloudwatch")
tsdb.RegisterTsdbQueryEndpoint("cloudwatch", NewCloudWatchExecutor)
standardStatistics = map[string]bool{
"Average": true,
"Maximum": true,
"Minimum": true,
"Sum": true,
"SampleCount": true,
}
aliasFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`)
}
@@ -75,162 +62,3 @@ func (e *CloudWatchExecutor) Query(ctx context.Context, dsInfo *models.DataSourc
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)
}

View 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()
}

View 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)
})
})
})
}

View File

@@ -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")
})
})
})
}

View File

@@ -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": "",
}

View File

@@ -12,9 +12,11 @@ import (
"github.com/aws/aws-sdk-go/aws/credentials/endpointcreds"
"github.com/aws/aws-sdk-go/aws/defaults"
"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/service/cloudwatch"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/grafana/grafana/pkg/setting"
)
type cache struct {
@@ -180,6 +182,7 @@ func (e *CloudWatchExecutor) getAwsConfig(dsInfo *DatasourceInfo) (*aws.Config,
Region: aws.String(dsInfo.Region),
Credentials: creds,
}
return cfg, nil
}
@@ -196,5 +199,10 @@ func (e *CloudWatchExecutor) getClient(region string) (*cloudwatch.CloudWatch, e
}
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
}

View File

@@ -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
}

View 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
}

View 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)
})
})
}

View File

@@ -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())
})
})
}

View File

@@ -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
}

View File

@@ -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())
})
})
}

View 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
}

View 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")
})
})
})
}

View 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
}

View 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\""`)
})
})
})
}

View File

@@ -637,10 +637,13 @@ func (e *CloudWatchExecutor) cloudwatchListMetrics(region string, namespace stri
params := &cloudwatch.ListMetricsInput{
Namespace: aws.String(namespace),
MetricName: aws.String(metricName),
Dimensions: dimensions,
}
if metricName != "" {
params.MetricName = aws.String(metricName)
}
var resp cloudwatch.ListMetricsOutput
err = svc.ListMetricsPages(params,
func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool {

View 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
}

View 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)
})
})
})
}

View 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
}

View 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")
})
})
})
}

View 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)
}

View 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())
})
})
}

View 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
}

View File

@@ -1,21 +1,49 @@
package cloudwatch
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/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
Region string
Id string
Namespace string
MetricName string
Dimensions []*cloudwatch.Dimension
Statistics []*string
QueryType string
Expression string
ReturnData bool
Dimensions map[string][]string
ExtendedStatistics []*string
Period int
Alias string
Id string
Expression string
ReturnData 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)
}

View File

@@ -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",
characterEscape(datasource.User, ":"),
characterEscape(datasource.DecryptedPassword(), "@"),
datasource.DecryptedPassword(),
protocol,
characterEscape(datasource.Url, ")"),
characterEscape(datasource.Database, "?"),

View File

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

View File

@@ -1,4 +1,4 @@
import React, { FC } from 'react';
import React, { FC } from 'react';
import { Tooltip } from '@grafana/ui';
interface Props {
@@ -21,8 +21,12 @@ export const Footer: FC<Props> = React.memo(
</a>
</li>
<li>
<a href="https://grafana.com/services/support" target="_blank" rel="noopener">
<i className="fa fa-support" /> Support Plans
<a
href="https://grafana.com/products/enterprise/?utm_source=grafana_footer"
target="_blank"
rel="noopener"
>
<i className="fa fa-support" /> Support & Enterprise
</a>
</li>
<li>

View File

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

View File

@@ -17,6 +17,7 @@ export default class TableModel implements TableData {
rows: any[];
type: string;
columnMap: any;
refId: string;
constructor(table?: any) {
this.columns = [];

View File

@@ -171,14 +171,14 @@ exports[`ServerStats Should render table with stats 1`] = `
</li>
<li>
<a
href="https://grafana.com/services/support"
href="https://grafana.com/products/enterprise/?utm_source=grafana_footer"
rel="noopener"
target="_blank"
>
<i
className="fa fa-support"
/>
Support Plans
Support & Enterprise
</a>
</li>
<li>

View File

@@ -14,7 +14,6 @@ import {
DataQuery,
DataSourceApi,
PanelData,
DataQueryRequest,
PanelEvents,
TimeRange,
LoadingState,
@@ -316,10 +315,6 @@ export function filterPanelDataToQuery(data: PanelData, refId: string): PanelDat
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
let state = LoadingState.Done;
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;
return {
...data,
state,
series,
request,
error,
timeRange,
};

View File

@@ -438,7 +438,7 @@ describe('DashboardModel', () => {
});
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`);
});
});

View File

@@ -685,11 +685,11 @@ function upgradePanelLink(link: any): DataLink {
let url = link.url;
if (!url && link.dashboard) {
url = `/dashboard/db/${kbn.slugifyForUrl(link.dashboard)}`;
url = `dashboard/db/${kbn.slugifyForUrl(link.dashboard)}`;
}
if (!url && link.dashUri) {
url = `/dashboard/${link.dashUri}`;
url = `dashboard/${link.dashUri}`;
}
// some models are incomplete and have no dashboard or dashUri

View File

@@ -224,7 +224,7 @@ function getGrafanaCloudPhantomPlugin(): DataSourcePluginMeta {
author: { name: 'Grafana Labs' },
links: [
{
url: 'https://grafana.com/cloud',
url: 'https://grafana.com/products/cloud/',
name: 'Learn more',
},
],

View File

@@ -169,7 +169,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
'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;
return (

View File

@@ -15,7 +15,6 @@ import { StoreState } from 'app/types';
import {
DataQuery,
DataSourceApi,
QueryFixAction,
PanelData,
HistoryItem,
TimeRange,
@@ -97,14 +96,6 @@ export class QueryRow extends PureComponent<QueryRowProps, QueryRowState> {
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 = () => {
const { exploreId, index } = this.props;
this.props.removeQueryRowAction({ exploreId, index });
@@ -161,7 +152,6 @@ export class QueryRow extends PureComponent<QueryRowProps, QueryRowState> {
query={query}
history={history}
onRunQuery={this.onRunQuery}
onHint={this.onClickHintFix}
onBlur={noopOnBlur}
onChange={this.onChange}
data={queryResponse}

View File

@@ -106,7 +106,6 @@ class MetricsPanelCtrl extends PanelCtrl {
return;
}
this.loading = false;
this.error = err.message || 'Request Error';
if (err.data) {
@@ -116,10 +115,6 @@ class MetricsPanelCtrl extends PanelCtrl {
this.error = err.data.error;
}
}
return this.$timeout(() => {
this.events.emit(PanelEvents.dataError, err);
});
}
// Updates the response with information from the stream
@@ -128,10 +123,6 @@ class MetricsPanelCtrl extends PanelCtrl {
if (data.state === LoadingState.Error) {
this.loading = false;
this.processDataError(data.error);
if (!data.series) {
// keep current data if the response is empty
return;
}
}
// Ignore data in loading state

View File

@@ -149,7 +149,7 @@ export function addTeamGroup(groupId: string): ThunkResult<void> {
export function removeTeamGroup(groupId: string): ThunkResult<void> {
return async (dispatch, getStore) => {
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());
};
}

View File

@@ -1,5 +1,4 @@
import React, { PureComponent } from 'react';
import { Input } from '@grafana/ui';
import { VariableQueryProps } from 'app/types/plugins';
export default class DefaultVariableQueryEditor extends PureComponent<VariableQueryProps, any> {
@@ -8,20 +7,30 @@ export default class DefaultVariableQueryEditor extends PureComponent<VariableQu
this.state = { value: props.query };
}
onChange = (event: React.FormEvent<HTMLInputElement>) => {
onChange = (event: React.FormEvent<HTMLTextAreaElement>) => {
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);
};
getLineCount() {
const { value } = this.state;
if (typeof value === 'string') {
return value.split('\n').length;
}
return 1;
}
render() {
return (
<div className="gf-form">
<span className="gf-form-label width-10">Query</span>
<Input
type="text"
<textarea
rows={this.getLineCount()}
className="gf-form-input"
value={this.state.value}
onChange={this.onChange}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,7 +77,7 @@ class ElasticsearchQueryField extends React.PureComponent<Props, State> {
/>
</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}
</>
);
}

View File

@@ -264,7 +264,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
const expandedQuery = {
...query,
datasource: this.name,
query: this.templateSrv.replace(query.query),
query: this.templateSrv.replace(query.query, {}, 'lucene'),
};
return expandedQuery;
});

View File

@@ -133,6 +133,7 @@ export class InfluxLogsQueryField extends React.PureComponent<Props, State> {
const { datasource } = this.props;
const { measurements, measurement, field, error } = this.state;
const cascadeText = getChooserText({ measurement, field, error });
const hasMeasurement = measurements && measurements.length > 0;
return (
<div className="gf-form-inline gf-form-inline--nowrap">
@@ -143,7 +144,7 @@ export class InfluxLogsQueryField extends React.PureComponent<Props, State> {
onChange={this.onMeasurementsChange}
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" />
</button>
</Cascader>

View File

@@ -221,11 +221,11 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
};
onClickHintFix = () => {
const { datasource, query, onChange, onRunQuery } = this.props;
const { hint } = this.state;
const { onHint } = this.props;
if (onHint && hint && hint.fix) {
onHint(hint.fix.action);
}
onChange(datasource.modifyQuery(query, hint.fix.action));
onRunQuery();
};
onUpdateLanguage = () => {

View File

@@ -12,8 +12,8 @@ class PrometheusAnnotationsQueryCtrl {
}
export const plugin = new DataSourcePlugin(PrometheusDatasource)
.setQueryCtrl(PromQueryEditor)
.setQueryEditor(PromQueryEditor)
.setConfigEditor(ConfigEditor)
.setExploreLogsQueryField(PromQueryField)
.setExploreMetricsQueryField(PromQueryField)
.setAnnotationQueryCtrl(PrometheusAnnotationsQueryCtrl)
.setExploreStartPage(PromCheatSheet);

View File

@@ -75,6 +75,7 @@ export class ResultTransformer {
return {
datapoints: dps,
query: options.query,
refId: options.refId,
target: metricLabel,
tags: metricData.metric,
};
@@ -82,6 +83,8 @@ export class ResultTransformer {
transformMetricDataToTable(md: any, resultCount: number, refId: string, valueWithRefId?: boolean): TableModel {
const table = new TableModel();
table.refId = refId;
let i: number, j: number;
const metricLabels: { [key: string]: number } = {};
@@ -141,7 +144,7 @@ export class ResultTransformer {
let metricLabel = null;
metricLabel = this.createMetricLabel(md.metric, options);
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) {

View File

@@ -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();
});
});

View File

@@ -59,7 +59,7 @@ describe('Prometheus Result Transformer', () => {
};
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.rows).toEqual([
[1443454528000, 'test', '', 'testjob', 3846],
@@ -73,6 +73,7 @@ describe('Prometheus Result Transformer', () => {
{ text: 'Value' },
]);
expect(table.columns[4].filterable).toBeUndefined();
expect(table.refId).toBe('A');
});
it('should column title include refId if response count is more than 2', () => {
@@ -217,6 +218,7 @@ describe('Prometheus Result Transformer', () => {
format: 'timeseries',
start: 0,
end: 2,
refId: 'B',
};
const result = ctx.resultTransformer.transform({ data: response }, options);
@@ -226,6 +228,7 @@ describe('Prometheus Result Transformer', () => {
query: undefined,
datapoints: [[10, 0], [10, 1000], [0, 2000]],
tags: { job: 'testjob' },
refId: 'B',
},
]);
});

Some files were not shown because too many files have changed in this diff Show More