Postgres Data Source (#9475)

* add postgresql datasource

* add rest of files for postgres datasource

* fix timeseries query, remove unused code

* consistent naming, refactoring

* s/mysql/postgres/

* s/mysql/postgres/

* couple more tests

* tests for more datatypes

* fix macros for postgres

* add __timeSec macro

* add frontend for postgres datasource

* adjust documentation

* fix formatting

* add proper plugin description

* merge editor changes from mysql

* port changes from mysql datasource

* set proper defaultQuery for postgres

* add time_sec to timeseries query
accept int for value for timeseries query

* revert allowing time_sec and handle int or float values as unix
timestamp for "time" column

* fix tslint error

* handle decimal values in timeseries query

* allow setting sslmode for postgres datasource

* use type switch for handling data types

* fix value for timeseries query

* refactor timeseries queries to make them more flexible

* remove debug statement from inner loop in type conversion

* use plain for loop in getTypedRowData

* fix timeseries queries

* adjust postgres datasource to tsdb refactoring

* adjust postgres datasource to frontend changes

* update lib/pq to latest version

* move type conversion to getTypedRowData

* handle address types cidr, inet and macaddr

* adjust response parser and docs for annotations

* convert unknown types to string

* add documentation for postgres datasource

* add another example query with metric column

* set more helpful default query

* update help text in query editor

* handle NULL in value column of timeseries query

* add __timeGroup macro

* add test for __timeGroup macro

* document __timeGroup and set proper default query for annotations

* fix typos in docs

* add postgres to list of datasources

* add postgres to builtInPlugins

* mysql: refactoring as prep for merging postgres

Refactors out the initialization of the xorm engine and the query logic
for an sql data source.

* mysql: rename refactoring + test update

* postgres:refactor to use SqlEngine(same as mysql)

Refactored to use a common base class with the MySql data source.

Other changes from the original PR:
- Changed time column to be time_sec to allow other time units in the
future and to be the same as MySQL
- Changed integration test to test the main Query method rather than
the private transformToTable method
- Changed the __timeSec macro name to __timeEpoch
- Renamed PostgresExecutor to PostgresQueryEndpoint

Fixes #9209 (the original PR)

* postgres: encrypt password on config page

With some other cosmetic changes to the config page:
- placeholder texts
- reset button for the password after it has been encrypted.
- default value for the sslmode field.

* postgres: change back col name to time from time_sec

* postgres mysql: remove annotation title

Title has been removed from annotations

* postgres: fix images for docs page

* postgres mysql: fix specs
This commit is contained in:
Daniel Lee
2017-10-10 15:19:14 +02:00
committed by GitHub
parent 630e6f5da6
commit d1c9760fa8
36 changed files with 2231 additions and 478 deletions

View File

@@ -0,0 +1,3 @@
# Grafana PostgreSQL Datasource - Native Plugin
This is the built in PostgreSQL Datasource that is used to connect to PostgreSQL databases.

View File

@@ -0,0 +1,132 @@
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
import ResponseParser from './response_parser';
export class PostgresDatasource {
id: any;
name: any;
responseParser: ResponseParser;
/** @ngInject **/
constructor(instanceSettings, private backendSrv, private $q, private templateSrv) {
this.name = instanceSettings.name;
this.id = instanceSettings.id;
this.responseParser = new ResponseParser(this.$q);
}
interpolateVariable(value) {
if (typeof value === 'string') {
return '\'' + value + '\'';
}
var quotedValues = _.map(value, function(val) {
return '\'' + val + '\'';
});
return quotedValues.join(',');
}
query(options) {
var queries = _.filter(options.targets, item => {
return item.hide !== true;
}).map(item => {
return {
refId: item.refId,
intervalMs: options.intervalMs,
maxDataPoints: options.maxDataPoints,
datasourceId: this.id,
rawSql: this.templateSrv.replace(item.rawSql, options.scopedVars, this.interpolateVariable),
format: item.format,
};
});
if (queries.length === 0) {
return this.$q.when({data: []});
}
return this.backendSrv.datasourceRequest({
url: '/api/tsdb/query',
method: 'POST',
data: {
from: options.range.from.valueOf().toString(),
to: options.range.to.valueOf().toString(),
queries: queries,
}
}).then(this.responseParser.processQueryResult);
}
annotationQuery(options) {
if (!options.annotation.rawQuery) {
return this.$q.reject({message: 'Query missing in annotation definition'});
}
const query = {
refId: options.annotation.name,
datasourceId: this.id,
rawSql: this.templateSrv.replace(options.annotation.rawQuery, options.scopedVars, this.interpolateVariable),
format: 'table',
};
return this.backendSrv.datasourceRequest({
url: '/api/tsdb/query',
method: 'POST',
data: {
from: options.range.from.valueOf().toString(),
to: options.range.to.valueOf().toString(),
queries: [query],
}
}).then(data => this.responseParser.transformAnnotationResponse(options, data));
}
metricFindQuery(query, optionalOptions) {
let refId = 'tempvar';
if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) {
refId = optionalOptions.variable.name;
}
const interpolatedQuery = {
refId: refId,
datasourceId: this.id,
rawSql: this.templateSrv.replace(query, {}, this.interpolateVariable),
format: 'table',
};
return this.backendSrv.datasourceRequest({
url: '/api/tsdb/query',
method: 'POST',
data: {
queries: [interpolatedQuery],
}
})
.then(data => this.responseParser.parseMetricFindQueryResult(refId, data));
}
testDatasource() {
return this.backendSrv.datasourceRequest({
url: '/api/tsdb/query',
method: 'POST',
data: {
from: '5m',
to: 'now',
queries: [{
refId: 'A',
intervalMs: 1,
maxDataPoints: 1,
datasourceId: this.id,
rawSql: "SELECT 1",
format: 'table',
}],
}
}).then(res => {
return { status: "success", message: "Database Connection OK"};
}).catch(err => {
console.log(err);
if (err.data && err.data.message) {
return { status: "error", message: err.data.message };
} else {
return { status: "error", message: err.status };
}
});
}
}

View File

@@ -0,0 +1,22 @@
<?xml version="1.0"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="432.071pt" height="445.383pt" viewBox="0 0 432.071 445.383" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g id="orginal" style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
</g>
<g id="Layer_x0020_3" style="fill-rule:nonzero;clip-rule:nonzero;fill:none;stroke:#FFFFFF;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;">
<path style="fill:#000000;stroke:#000000;stroke-width:37.3953;stroke-linecap:butt;stroke-linejoin:miter;" d="M323.205,324.227c2.833-23.601,1.984-27.062,19.563-23.239l4.463,0.392c13.517,0.615,31.199-2.174,41.587-7c22.362-10.376,35.622-27.7,13.572-23.148c-50.297,10.376-53.755-6.655-53.755-6.655c53.111-78.803,75.313-178.836,56.149-203.322 C352.514-5.534,262.036,26.049,260.522,26.869l-0.482,0.089c-9.938-2.062-21.06-3.294-33.554-3.496c-22.761-0.374-40.032,5.967-53.133,15.904c0,0-161.408-66.498-153.899,83.628c1.597,31.936,45.777,241.655,98.47,178.31 c19.259-23.163,37.871-42.748,37.871-42.748c9.242,6.14,20.307,9.272,31.912,8.147l0.897-0.765c-0.281,2.876-0.157,5.689,0.359,9.019c-13.572,15.167-9.584,17.83-36.723,23.416c-27.457,5.659-11.326,15.734-0.797,18.367c12.768,3.193,42.305,7.716,62.268-20.224 l-0.795,3.188c5.325,4.26,4.965,30.619,5.72,49.452c0.756,18.834,2.017,36.409,5.856,46.771c3.839,10.36,8.369,37.05,44.036,29.406c29.809-6.388,52.6-15.582,54.677-101.107"/>
<path style="fill:#336791;stroke:none;" d="M402.395,271.23c-50.302,10.376-53.76-6.655-53.76-6.655c53.111-78.808,75.313-178.843,56.153-203.326c-52.27-66.785-142.752-35.2-144.262-34.38l-0.486,0.087c-9.938-2.063-21.06-3.292-33.56-3.496c-22.761-0.373-40.026,5.967-53.127,15.902 c0,0-161.411-66.495-153.904,83.63c1.597,31.938,45.776,241.657,98.471,178.312c19.26-23.163,37.869-42.748,37.869-42.748c9.243,6.14,20.308,9.272,31.908,8.147l0.901-0.765c-0.28,2.876-0.152,5.689,0.361,9.019c-13.575,15.167-9.586,17.83-36.723,23.416 c-27.459,5.659-11.328,15.734-0.796,18.367c12.768,3.193,42.307,7.716,62.266-20.224l-0.796,3.188c5.319,4.26,9.054,27.711,8.428,48.969c-0.626,21.259-1.044,35.854,3.147,47.254c4.191,11.4,8.368,37.05,44.042,29.406c29.809-6.388,45.256-22.942,47.405-50.555 c1.525-19.631,4.976-16.729,5.194-34.28l2.768-8.309c3.192-26.611,0.507-35.196,18.872-31.203l4.463,0.392c13.517,0.615,31.208-2.174,41.591-7c22.358-10.376,35.618-27.7,13.573-23.148z"/>
<path d="M215.866,286.484c-1.385,49.516,0.348,99.377,5.193,111.495c4.848,12.118,15.223,35.688,50.9,28.045c29.806-6.39,40.651-18.756,45.357-46.051c3.466-20.082,10.148-75.854,11.005-87.281"/>
<path d="M173.104,38.256c0,0-161.521-66.016-154.012,84.109c1.597,31.938,45.779,241.664,98.473,178.316c19.256-23.166,36.671-41.335,36.671-41.335"/>
<path d="M260.349,26.207c-5.591,1.753,89.848-34.889,144.087,34.417c19.159,24.484-3.043,124.519-56.153,203.329"/>
<path style="stroke-linejoin:bevel;" d="M348.282,263.953c0,0,3.461,17.036,53.764,6.653c22.04-4.552,8.776,12.774-13.577,23.155c-18.345,8.514-59.474,10.696-60.146-1.069c-1.729-30.355,21.647-21.133,19.96-28.739c-1.525-6.85-11.979-13.573-18.894-30.338 c-6.037-14.633-82.796-126.849,21.287-110.183c3.813-0.789-27.146-99.002-124.553-100.599c-97.385-1.597-94.19,119.762-94.19,119.762"/>
<path d="M188.604,274.334c-13.577,15.166-9.584,17.829-36.723,23.417c-27.459,5.66-11.326,15.733-0.797,18.365c12.768,3.195,42.307,7.718,62.266-20.229c6.078-8.509-0.036-22.086-8.385-25.547c-4.034-1.671-9.428-3.765-16.361,3.994z"/>
<path d="M187.715,274.069c-1.368-8.917,2.93-19.528,7.536-31.942c6.922-18.626,22.893-37.255,10.117-96.339c-9.523-44.029-73.396-9.163-73.436-3.193c-0.039,5.968,2.889,30.26-1.067,58.548c-5.162,36.913,23.488,68.132,56.479,64.938"/>
<path style="fill:#FFFFFF;stroke-width:4.155;stroke-linecap:butt;stroke-linejoin:miter;" d="M172.517,141.7c-0.288,2.039,3.733,7.48,8.976,8.207c5.234,0.73,9.714-3.522,9.998-5.559c0.284-2.039-3.732-4.285-8.977-5.015c-5.237-0.731-9.719,0.333-9.996,2.367z"/>
<path style="fill:#FFFFFF;stroke-width:2.0775;stroke-linecap:butt;stroke-linejoin:miter;" d="M331.941,137.543c0.284,2.039-3.732,7.48-8.976,8.207c-5.238,0.73-9.718-3.522-10.005-5.559c-0.277-2.039,3.74-4.285,8.979-5.015c5.239-0.73,9.718,0.333,10.002,2.368z"/>
<path d="M350.676,123.432c0.863,15.994-3.445,26.888-3.988,43.914c-0.804,24.748,11.799,53.074-7.191,81.435"/>
<path style="stroke-width:3;" d="M0,60.232"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,103 @@
// jshint ignore: start
// jscs: disable
ace.define("ace/mode/sql_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"], function(require, exports, module) {
"use strict";
var oop = require("../lib/oop");
var TextHighlightRules = require("./text_highlight_rules").TextHighlightRules;
var SqlHighlightRules = function() {
var keywords = (
"select|insert|update|delete|from|where|and|or|group|by|order|limit|offset|having|as|case|" +
"when|else|end|type|left|right|join|on|outer|desc|asc|union|create|table|primary|key|if|" +
"foreign|not|references|default|null|inner|cross|natural|database|drop|grant"
);
var builtinConstants = (
"true|false"
);
var builtinFunctions = (
"avg|count|first|last|max|min|sum|upper|lower|substring|char_length|round|rank|now|" +
"coalesce"
);
var dataTypes = (
"int|int2|int4|int8|numeric|decimal|date|varchar|char|bigint|float|bool|bytea|text|timestamp|" +
"time|money|real|integer"
);
var keywordMapper = this.createKeywordMapper({
"support.function": builtinFunctions,
"keyword": keywords,
"constant.language": builtinConstants,
"storage.type": dataTypes
}, "identifier", true);
this.$rules = {
"start" : [ {
token : "comment",
regex : "--.*$"
}, {
token : "comment",
start : "/\\*",
end : "\\*/"
}, {
token : "string", // " string
regex : '".*?"'
}, {
token : "string", // ' string
regex : "'.*?'"
}, {
token : "constant.numeric", // float
regex : "[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b"
}, {
token : keywordMapper,
regex : "[a-zA-Z_$][a-zA-Z0-9_$]*\\b"
}, {
token : "keyword.operator",
regex : "\\+|\\-|\\/|\\/\\/|%|<@>|@>|<@|&|\\^|~|<|>|<=|=>|==|!=|<>|="
}, {
token : "paren.lparen",
regex : "[\\(]"
}, {
token : "paren.rparen",
regex : "[\\)]"
}, {
token : "text",
regex : "\\s+"
} ]
};
this.normalizeRules();
};
oop.inherits(SqlHighlightRules, TextHighlightRules);
exports.SqlHighlightRules = SqlHighlightRules;
});
ace.define("ace/mode/sql",["require","exports","module","ace/lib/oop","ace/mode/text","ace/mode/sql_highlight_rules"], function(require, exports, module) {
"use strict";
var oop = require("../lib/oop");
var TextMode = require("./text").Mode;
var SqlHighlightRules = require("./sql_highlight_rules").SqlHighlightRules;
var Mode = function() {
this.HighlightRules = SqlHighlightRules;
this.$behaviour = this.$defaultBehaviour;
};
oop.inherits(Mode, TextMode);
(function() {
this.lineCommentStart = "--";
this.$id = "ace/mode/sql";
}).call(Mode.prototype);
exports.Mode = Mode;
});

View File

@@ -0,0 +1,43 @@
///<reference path="../../../headers/common.d.ts" />
import {PostgresDatasource} from './datasource';
import {PostgresQueryCtrl} from './query_ctrl';
class PostgresConfigCtrl {
static templateUrl = 'partials/config.html';
current: any;
constructor($scope) {
this.current.jsonData.sslmode = this.current.jsonData.sslmode || 'require';
}
}
const defaultQuery = `SELECT
extract(epoch from time_column) AS time,
title_column as title,
description_column as text
FROM
metric_table
WHERE
$__timeFilter(time_column)
`;
class PostgresAnnotationsQueryCtrl {
static templateUrl = 'partials/annotations.editor.html';
annotation: any;
/** @ngInject **/
constructor() {
this.annotation.rawQuery = this.annotation.rawQuery || defaultQuery;
}
}
export {
PostgresDatasource,
PostgresDatasource as Datasource,
PostgresQueryCtrl as QueryCtrl,
PostgresConfigCtrl as ConfigCtrl,
PostgresAnnotationsQueryCtrl as AnnotationsQueryCtrl,
};

View File

@@ -0,0 +1,41 @@
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<textarea rows="10" class="gf-form-input" ng-model="ctrl.annotation.rawQuery" spellcheck="false" placeholder="query expression" data-min-length=0 data-items=100 ng-model-onblur ng-change="ctrl.panelCtrl.refresh()"></textarea>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
Show Help
<i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
</label>
</div>
</div>
<div class="gf-form" ng-show="ctrl.showHelp">
<pre class="gf-form-pre alert alert-info"><h6>Annotation Query Format</h6>
An annotation is an event that is overlayed on top of graphs. The query can have up to four columns per row, the time column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned.
- column with alias: <b>time</b> for the annotation event. Format is UTC in seconds, use extract(epoch from column) as "time"
- column with alias <b>title</b> for the annotation title
- column with alias: <b>text</b> for the annotation text
- column with alias: <b>tags</b> for annotation tags. This is a comma separated string of tags e.g. 'tag1,tag2'
Macros:
- $__time(column) -&gt; column as "time"
- $__timeFilter(column) -&gt; column &ge; to_timestamp(1492750877) AND column &le; to_timestamp(1492750877)
- $__unixEpochFilter(column) -&gt; column &gt; 1492750877 AND column &lt; 1492750877
Or build your own conditionals using these macros which just return the values:
- $__timeFrom() -&gt; to_timestamp(1492750877)
- $__timeTo() -&gt; to_timestamp(1492750877)
- $__unixEpochFrom() -&gt; 1492750877
- $__unixEpochTo() -&gt; 1492750877
</pre>
</div>
</div>

View File

@@ -0,0 +1,52 @@
<h3 class="page-heading">PostgreSQL Connection</h3>
<div class="gf-form-group">
<div class="gf-form max-width-30">
<span class="gf-form-label width-7">Host</span>
<input type="text" class="gf-form-input" ng-model='ctrl.current.url' placeholder="localhost:5432" bs-typeahead="{{['localhost:5432', 'localhost:5433']}}" required></input>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-7">Database</span>
<input type="text" class="gf-form-input" ng-model='ctrl.current.database' placeholder="database name" required></input>
</div>
<div class="gf-form-inline">
<div class="gf-form max-width-15">
<span class="gf-form-label width-7">User</span>
<input type="text" class="gf-form-input" ng-model='ctrl.current.user' placeholder="user"></input>
</div>
<div class="gf-form max-width-15" ng-if="!ctrl.current.secureJsonFields.password">
<span class="gf-form-label width-7">Password</span>
<input type="password" class="gf-form-input" ng-model='ctrl.current.secureJsonData.password' placeholder="password"></input>
</div>
<div class="gf-form max-width-19" ng-if="ctrl.current.secureJsonFields.password">
<span class="gf-form-label width-7">Password</span>
<input type="text" class="gf-form-input" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.current.secureJsonFields.password = false">reset</a>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-7">SSL Mode</label>
<div class="gf-form-select-wrapper max-width-15 gf-form-select-wrapper--has-help-icon">
<select class="gf-form-input" ng-model="ctrl.current.jsonData.sslmode" ng-options="mode for mode in ['disable', 'require', 'verify-ca', 'verify-full']" ng-init="ctrl.current.jsonData.sslmode"></select>
<info-popover mode="right-absolute">
This option determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server.
</info-popover>
</div>
</div>
</div>
<div class="gf-form-group">
<div class="grafana-info-box">
<h5>User Permission</h5>
<p>
The database user should only be granted SELECT permissions on the specified database &amp; tables you want to query.
Grafana does not validate that queries are safe so queries can contain any SQL statement. For example, statements
like <code>DELETE FROM user;</code> and <code>DROP TABLE user;</code> would be executed. To protect against this we
<strong>Highly</strong> recommmend you create a specific PostgreSQL user with restricted permissions.
</p>
</div>
</div>

View File

@@ -0,0 +1,79 @@
<query-editor-row query-ctrl="ctrl" can-collapse="false">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<code-editor content="ctrl.target.rawSql" datasource="ctrl.datasource" on-change="ctrl.panelCtrl.refresh()" data-mode="sql">
</code-editor>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword">Format as</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto" ng-model="ctrl.target.format" ng-options="f.value as f.text for f in ctrl.formats" ng-change="ctrl.refresh()"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
Show Help
<i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
</label>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryMeta">
<label class="gf-form-label query-keyword" ng-click="ctrl.showLastQuerySQL = !ctrl.showLastQuerySQL">
Generated SQL
<i class="fa fa-caret-down" ng-show="ctrl.showLastQuerySQL"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showLastQuerySQL"></i>
</label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form" ng-show="ctrl.showLastQuerySQL">
<pre class="gf-form-pre">{{ctrl.lastQueryMeta.sql}}</pre>
</div>
<div class="gf-form" ng-show="ctrl.showHelp">
<pre class="gf-form-pre alert alert-info">Time series:
- return column named <i>time</i> (UTC in seconds or timestamp)
- return column(s) with numeric datatype as values
- (Optional: return column named <i>metric</i> to represent the series name. If no column named metric is found the column name of the value column is used as series name)
Table:
- return any set of columns
Macros:
- $__time(column) -&gt; column as "time"
- $__timeEpoch -&gt; extract(epoch from column) as "time"
- $__timeFilter(column) -&gt; column &ge; to_timestamp(1492750877) AND column &le; to_timestamp(1492750877)
- $__unixEpochFilter(column) -&gt; column &gt; 1492750877 AND column &lt; 1492750877
To group by time use $__timeGroup:
-&gt; (extract(epoch from column)/extract(epoch from column::interval))::int
Example of group by and order by with $__timeGroup:
SELECT
min(date_time_col) AS time_sec,
sum(value_double) as value
FROM yourtable
group by $__timeGroup(date_time_col, '1h')
order by $__timeGroup(date_time_col, '1h') ASC
Or build your own conditionals using these macros which just return the values:
- $__timeFrom() -&gt; to_timestamp(1492750877)
- $__timeTo() -&gt; to_timestamp(1492750877)
- $__unixEpochFrom() -&gt; 1492750877
- $__unixEpochTo() -&gt; 1492750877
</pre>
</div>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryError">
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
</div>
</query-editor-row>

View File

@@ -0,0 +1,20 @@
{
"type": "datasource",
"name": "PostgreSQL",
"id": "postgres",
"info": {
"author": {
"name": "Grafana Project",
"url": "https://grafana.com"
},
"logos": {
"small": "img/postgresql_logo.svg",
"large": "img/postgresql_logo.svg"
}
},
"alerting": true,
"annotations": true,
"metrics": true
}

View File

@@ -0,0 +1,84 @@
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
import {QueryCtrl} from 'app/plugins/sdk';
export interface PostgresQuery {
refId: string;
format: string;
alias: string;
rawSql: string;
}
export interface QueryMeta {
sql: string;
}
const defaultQuery = `SELECT
$__time(time_column),
value1
FROM
metric_table
WHERE
$__timeFilter(time_column)
`;
export class PostgresQueryCtrl extends QueryCtrl {
static templateUrl = 'partials/query.editor.html';
showLastQuerySQL: boolean;
formats: any[];
target: PostgresQuery;
lastQueryMeta: QueryMeta;
lastQueryError: string;
showHelp: boolean;
/** @ngInject **/
constructor($scope, $injector) {
super($scope, $injector);
this.target.format = this.target.format || 'time_series';
this.target.alias = "";
this.formats = [
{text: 'Time series', value: 'time_series'},
{text: 'Table', value: 'table'},
];
if (!this.target.rawSql) {
// special handling when in table panel
if (this.panelCtrl.panel.type === 'table') {
this.target.format = 'table';
this.target.rawSql = "SELECT 1";
} else {
this.target.rawSql = defaultQuery;
}
}
this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope);
this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope);
}
onDataReceived(dataList) {
this.lastQueryMeta = null;
this.lastQueryError = null;
let anySeriesFromQuery = _.find(dataList, {refId: this.target.refId});
if (anySeriesFromQuery) {
this.lastQueryMeta = anySeriesFromQuery.meta;
}
}
onDataError(err) {
if (err.data && err.data.results) {
let queryRes = err.data.results[this.target.refId];
if (queryRes) {
this.lastQueryMeta = queryRes.meta;
this.lastQueryError = queryRes.error;
}
}
}
}

View File

@@ -0,0 +1,141 @@
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
export default class ResponseParser {
constructor(private $q) {}
processQueryResult(res) {
var data = [];
if (!res.data.results) {
return {data: data};
}
for (let key in res.data.results) {
let queryRes = res.data.results[key];
if (queryRes.series) {
for (let series of queryRes.series) {
data.push({
target: series.name,
datapoints: series.points,
refId: queryRes.refId,
meta: queryRes.meta,
});
}
}
if (queryRes.tables) {
for (let table of queryRes.tables) {
table.type = 'table';
table.refId = queryRes.refId;
table.meta = queryRes.meta;
data.push(table);
}
}
}
return {data: data};
}
parseMetricFindQueryResult(refId, results) {
if (!results || results.data.length === 0 || results.data.results[refId].meta.rowCount === 0) { return []; }
const columns = results.data.results[refId].tables[0].columns;
const rows = results.data.results[refId].tables[0].rows;
const textColIndex = this.findColIndex(columns, '__text');
const valueColIndex = this.findColIndex(columns, '__value');
if (columns.length === 2 && textColIndex !== -1 && valueColIndex !== -1) {
return this.transformToKeyValueList(rows, textColIndex, valueColIndex);
}
return this.transformToSimpleList(rows);
}
transformToKeyValueList(rows, textColIndex, valueColIndex) {
const res = [];
for (let i = 0; i < rows.length; i++) {
if (!this.containsKey(res, rows[i][textColIndex])) {
res.push({text: rows[i][textColIndex], value: rows[i][valueColIndex]});
}
}
return res;
}
transformToSimpleList(rows) {
const res = [];
for (let i = 0; i < rows.length; i++) {
for (let j = 0; j < rows[i].length; j++) {
const value = rows[i][j];
if ( res.indexOf( value ) === -1 ) {
res.push(value);
}
}
}
return _.map(res, value => {
return { text: value};
});
}
findColIndex(columns, colName) {
for (let i = 0; i < columns.length; i++) {
if (columns[i].text === colName) {
return i;
}
}
return -1;
}
containsKey(res, key) {
for (let i = 0; i < res.length; i++) {
if (res[i].text === key) {
return true;
}
}
return false;
}
transformAnnotationResponse(options, data) {
const table = data.data.results[options.annotation.name].tables[0];
let timeColumnIndex = -1;
let titleColumnIndex = -1;
let textColumnIndex = -1;
let tagsColumnIndex = -1;
for (let i = 0; i < table.columns.length; i++) {
if (table.columns[i].text === 'time') {
timeColumnIndex = i;
} else if (table.columns[i].text === 'text') {
textColumnIndex = i;
} else if (table.columns[i].text === 'tags') {
tagsColumnIndex = i;
}
}
if (timeColumnIndex === -1) {
return this.$q.reject({message: 'Missing mandatory time column in annotation query.'});
}
const list = [];
for (let i = 0; i < table.rows.length; i++) {
const row = table.rows[i];
list.push({
annotation: options.annotation,
time: Math.floor(row[timeColumnIndex]) * 1000,
title: row[titleColumnIndex],
text: row[textColumnIndex],
tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : []
});
}
return list;
}
}

View File

@@ -0,0 +1,196 @@
import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common';
import moment from 'moment';
import helpers from 'test/specs/helpers';
import {PostgresDatasource} from '../datasource';
describe('PostgreSQLDatasource', function() {
var ctx = new helpers.ServiceTestContext();
var instanceSettings = {name: 'postgresql'};
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(ctx.providePhase(['backendSrv']));
beforeEach(angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) {
ctx.$q = $q;
ctx.$httpBackend = $httpBackend;
ctx.$rootScope = $rootScope;
ctx.ds = $injector.instantiate(PostgresDatasource, {instanceSettings: instanceSettings});
$httpBackend.when('GET', /\.html$/).respond('');
}));
describe('When performing annotationQuery', function() {
let results;
const annotationName = 'MyAnno';
const options = {
annotation: {
name: annotationName,
rawQuery: 'select time, title, text, tags from table;'
},
range: {
from: moment(1432288354),
to: moment(1432288401)
}
};
const response = {
results: {
MyAnno: {
refId: annotationName,
tables: [
{
columns: [{text: 'time'}, {text: 'text'}, {text: 'tags'}],
rows: [
[1432288355, 'some text', 'TagA,TagB'],
[1432288390, 'some text2', ' TagB , TagC'],
[1432288400, 'some text3']
]
}
]
}
}
};
beforeEach(function() {
ctx.backendSrv.datasourceRequest = function(options) {
return ctx.$q.when({data: response, status: 200});
};
ctx.ds.annotationQuery(options).then(function(data) { results = data; });
ctx.$rootScope.$apply();
});
it('should return annotation list', function() {
expect(results.length).to.be(3);
expect(results[0].text).to.be('some text');
expect(results[0].tags[0]).to.be('TagA');
expect(results[0].tags[1]).to.be('TagB');
expect(results[1].tags[0]).to.be('TagB');
expect(results[1].tags[1]).to.be('TagC');
expect(results[2].tags.length).to.be(0);
});
});
describe('When performing metricFindQuery', function() {
let results;
const query = 'select * from atable';
const response = {
results: {
tempvar: {
meta: {
rowCount: 3
},
refId: 'tempvar',
tables: [
{
columns: [{text: 'title'}, {text: 'text'}],
rows: [
['aTitle', 'some text'],
['aTitle2', 'some text2'],
['aTitle3', 'some text3']
]
}
]
}
}
};
beforeEach(function() {
ctx.backendSrv.datasourceRequest = function(options) {
return ctx.$q.when({data: response, status: 200});
};
ctx.ds.metricFindQuery(query).then(function(data) { results = data; });
ctx.$rootScope.$apply();
});
it('should return list of all column values', function() {
expect(results.length).to.be(6);
expect(results[0].text).to.be('aTitle');
expect(results[5].text).to.be('some text3');
});
});
describe('When performing metricFindQuery with key, value columns', function() {
let results;
const query = 'select * from atable';
const response = {
results: {
tempvar: {
meta: {
rowCount: 3
},
refId: 'tempvar',
tables: [
{
columns: [{text: '__value'}, {text: '__text'}],
rows: [
['value1', 'aTitle'],
['value2', 'aTitle2'],
['value3', 'aTitle3']
]
}
]
}
}
};
beforeEach(function() {
ctx.backendSrv.datasourceRequest = function(options) {
return ctx.$q.when({data: response, status: 200});
};
ctx.ds.metricFindQuery(query).then(function(data) { results = data; });
ctx.$rootScope.$apply();
});
it('should return list of as text, value', function() {
expect(results.length).to.be(3);
expect(results[0].text).to.be('aTitle');
expect(results[0].value).to.be('value1');
expect(results[2].text).to.be('aTitle3');
expect(results[2].value).to.be('value3');
});
});
describe('When performing metricFindQuery with key, value columns and with duplicate keys', function() {
let results;
const query = 'select * from atable';
const response = {
results: {
tempvar: {
meta: {
rowCount: 3
},
refId: 'tempvar',
tables: [
{
columns: [{text: '__text'}, {text: '__value'}],
rows: [
['aTitle', 'same'],
['aTitle', 'same'],
['aTitle', 'diff']
]
}
]
}
}
};
beforeEach(function() {
ctx.backendSrv.datasourceRequest = function(options) {
return ctx.$q.when({data: response, status: 200});
};
ctx.ds.metricFindQuery(query).then(function(data) { results = data; });
ctx.$rootScope.$apply();
});
it('should return list of unique keys', function() {
expect(results.length).to.be(1);
expect(results[0].text).to.be('aTitle');
expect(results[0].value).to.be('same');
});
});
});