Commit beb9f8ee by Torkel Ödegaard

Merge remote-tracking branch 'origin/master' into develop

parents 548652aa a62ebb3e
......@@ -39,6 +39,8 @@ conf/custom.ini
fig.yml
docker-compose.yml
docker-compose.yaml
/conf/dashboards/custom.yaml
/conf/datasources/custom.yaml
profile.cov
/grafana
.notouch
......
......@@ -24,7 +24,7 @@
* **Cloudwatch**: Fixes broken query inspector for cloudwatch [#9661](https://github.com/grafana/grafana/issues/9661), thx [@mtanda](https://github.com/mtanda)
* **Dashboard**: Make it possible to start dashboards from search and dashboard list panel [#1871](https://github.com/grafana/grafana/issues/1871)
* **Annotations**: Posting annotations now return the id of the annotation [#9798](https://github.com/grafana/grafana/issues/9798)
* **Systemd**: Use systemd notification ready flag [#10024](https://github.com/grafana/grafana/issues/10024), thx [@jgrassler](https://github.com/jgrassler)
## Tech
* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)
......
This software is based on Kibana:
========================================
Copyright 2012-2013 Elasticsearch BV
Licensed under the Apache License, Version 2.0 (the "License"); you
may not use this file except in compliance with the License. You may
obtain a copy of the License at
Copyright 2014-2017 Grafana Labs
http://www.apache.org/licenses/LICENSE-2.0
This software is based on Kibana:
Copyright 2012-2013 Elasticsearch BV
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing
permissions and limitations under the License.
......@@ -95,9 +95,9 @@ func main() {
case "package":
grunt(gruntBuildArg("release")...)
if runtime.GOOS != "windows" {
createLinuxPackages()
}
if runtime.GOOS != "windows" {
createLinuxPackages()
}
case "pkg-rpm":
grunt(gruntBuildArg("release")...)
......
......@@ -7,3 +7,4 @@
MYSQL_PASSWORD: password
ports:
- "3306:3306"
tmpfs: /var/lib/mysql:rw
......@@ -5,3 +5,4 @@
POSTGRES_PASSWORD: grafanatest
ports:
- "5432:5432"
tmpfs: /var/lib/postgresql/data:rw
\ No newline at end of file
......@@ -65,13 +65,14 @@ Currently we do not provide any scripts/manifests for configuring Grafana. Rathe
Tool | Project
-----|------------
Puppet | [https://forge.puppet.com/puppet/grafana](https://forge.puppet.com/puppet/grafana)
Ansible | [https://github.com/cloudalchemy/ansible-grafana](https://github.com/cloudalchemy/ansible-grafana)
Ansible | [https://github.com/picotrading/ansible-grafana](https://github.com/picotrading/ansible-grafana)
Chef | [https://github.com/JonathanTron/chef-grafana](https://github.com/JonathanTron/chef-grafana)
Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://github.com/salt-formulas/salt-formula-grafana)
## Datasources
> This feature is available from v4.7
> This feature is available from v5.0
It's possible to manage datasources in Grafana by adding one or more yaml config files in the [`conf/datasources`](/installation/configuration/#datasources) directory. Each config file can contain a list of `datasources` that will be added or updated during start up. If the datasource already exists, Grafana will update it to match the configuration file. The config file can also contain a list of datasources that should be deleted. That list is called `delete_datasources`. Grafana will delete datasources listed in `delete_datasources` before inserting/updating those in the `datasource` list.
......
......@@ -127,6 +127,12 @@ A query can returns multiple columns and Grafana will automatically create a lis
SELECT my_host.hostname, my_other_host.hostname2 FROM my_host JOIN my_other_host ON my_host.city = my_other_host.city
```
To use time range dependent macros like `$__timeFilter(column)` in your query the refresh mode of the template variable needs to be set to *On Time Range Change*.
```sql
SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
```
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allows you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value:
```sql
......
......@@ -139,6 +139,12 @@ A query can return multiple columns and Grafana will automatically create a list
SELECT host.hostname, other_host.hostname2 FROM host JOIN other_host ON host.city = other_host.city
```
To use time range dependent macros like `$__timeFilter(column)` in your query the refresh mode of the template variable needs to be set to *On Time Range Change*.
```sql
SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
```
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allows you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value:
```sql
......
......@@ -93,7 +93,10 @@ Directory where grafana will automatically scan and look for plugins
### datasources
Config files containing datasources that will be configured at startup
> This feature is available in 5.0+
Config files containing datasources that will be configured at startup.
You can read more about the config files at the [provisioning page](/administration/provisioning/#datasources).
## [server]
......
......@@ -9,7 +9,7 @@ After=postgresql.service mariadb.service mysql.service
EnvironmentFile=/etc/sysconfig/grafana-server
User=grafana
Group=grafana
Type=simple
Type=notify
Restart=on-failure
WorkingDirectory=/usr/share/grafana
RuntimeDirectory=grafana
......
......@@ -10,7 +10,11 @@ import (
)
func RenderToPng(c *middleware.Context) {
queryReader := util.NewUrlQueryReader(c.Req.URL)
queryReader, err := util.NewUrlQueryReader(c.Req.URL)
if err != nil {
c.Handle(400, "Render parameters error", err)
return
}
queryParams := fmt.Sprintf("?%s", c.Req.URL.RawQuery)
renderOpts := &renderer.RenderOpts{
......
......@@ -3,7 +3,9 @@ package main
import (
"context"
"flag"
"fmt"
"io/ioutil"
"net"
"os"
"path/filepath"
"strconv"
......@@ -96,6 +98,7 @@ func (g *GrafanaServerImpl) Start() {
return
}
SendSystemdNotification("READY=1")
g.startHttpServer()
}
......@@ -169,3 +172,28 @@ func (g *GrafanaServerImpl) writePIDFile() {
g.log.Info("Writing PID file", "path", *pidFile, "pid", pid)
}
func SendSystemdNotification(state string) error {
notifySocket := os.Getenv("NOTIFY_SOCKET")
if notifySocket == "" {
return fmt.Errorf("NOTIFY_SOCKET environment variable empty or unset.")
}
socketAddr := &net.UnixAddr{
Name: notifySocket,
Net: "unixgram",
}
conn, err := net.DialUnix(socketAddr.Net, nil, socketAddr)
if err != nil {
return err
}
_, err = conn.Write([]byte(state))
conn.Close()
return err
}
......@@ -38,6 +38,7 @@ func TestLogFile(t *testing.T) {
So(fileLogWrite.maxlines_curlines, ShouldEqual, 3)
})
fileLogWrite.Close()
err = os.Remove(fileLogWrite.Filename)
So(err, ShouldBeNil)
})
......
......@@ -366,7 +366,6 @@ func TestAlertRuleExtraction(t *testing.T) {
"steppedLine": false,
"targets": [
{
"dsType": "influxdb",
"groupBy": [
{
"params": [
......@@ -411,7 +410,6 @@ func TestAlertRuleExtraction(t *testing.T) {
"tags": []
},
{
"dsType": "influxdb",
"groupBy": [
{
"params": [
......
......@@ -6,6 +6,7 @@ import (
"io"
"mime/multipart"
"os"
"path/filepath"
"time"
"github.com/grafana/grafana/pkg/bus"
......@@ -176,7 +177,7 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
func SlackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url string, recipient string, token string) error {
if evalContext.ImageOnDiskPath == "" {
evalContext.ImageOnDiskPath = "public/img/mixed_styles.png"
evalContext.ImageOnDiskPath = filepath.Join(setting.HomePath, "public/img/mixed_styles.png")
}
log.Info("Uploading to slack via file.upload API")
headers, uploadBody, err := GenerateSlackBody(evalContext.ImageOnDiskPath, token, recipient)
......
......@@ -401,7 +401,7 @@ func SearchUsers(query *m.SearchUsersQuery) error {
}
if query.Query != "" {
whereConditions = append(whereConditions, "(email LIKE ? OR name LIKE ? OR login like ?)")
whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)")
whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards)
}
......
......@@ -17,7 +17,7 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb"
opentracing "github.com/opentracing/opentracing-go"
"github.com/opentracing/opentracing-go"
)
type GraphiteExecutor struct {
......@@ -158,7 +158,7 @@ func formatTimeRange(input string) string {
if input == "now" {
return input
}
return strings.Replace(strings.Replace(input, "m", "min", -1), "M", "mon", -1)
return strings.Replace(strings.Replace(strings.Replace(input, "now", "", -1), "m", "min", -1), "M", "mon", -1)
}
func fixIntervalFormat(target string) string {
......
......@@ -18,14 +18,14 @@ func TestGraphiteFunctions(t *testing.T) {
Convey("formatting time range for now-1m", func() {
timeRange := formatTimeRange("now-1m")
So(timeRange, ShouldEqual, "now-1min")
So(timeRange, ShouldEqual, "-1min")
})
Convey("formatting time range for now-1M", func() {
timeRange := formatTimeRange("now-1M")
So(timeRange, ShouldEqual, "now-1mon")
So(timeRange, ShouldEqual, "-1mon")
})
......
......@@ -20,7 +20,6 @@ func TestInfluxdbQueryParser(t *testing.T) {
Convey("can parse influxdb json model", func() {
json := `
{
"dsType": "influxdb",
"groupBy": [
{
"params": [
......@@ -123,7 +122,6 @@ func TestInfluxdbQueryParser(t *testing.T) {
Convey("can part raw query json model", func() {
json := `
{
"dsType": "influxdb",
"groupBy": [
{
"params": [
......
......@@ -50,6 +50,7 @@ func (rp *ResponseParser) transformRows(rows []Row, queryResult *tsdb.QueryResul
result = append(result, &tsdb.TimeSeries{
Name: rp.formatSerieName(row, column, query),
Points: points,
Tags: row.Tags,
})
}
}
......
......@@ -78,6 +78,15 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro
rowLimit := 1000000
rowCount := 0
timeIndex := -1
// check if there is a column named time
for i, col := range columnNames {
switch col {
case "time":
timeIndex = i
}
}
for ; rows.Next(); rowCount++ {
if rowCount > rowLimit {
......@@ -89,6 +98,15 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro
return err
}
// convert column named time to unix timestamp to make
// native datetime postgres types work in annotation queries
if timeIndex != -1 {
switch value := values[timeIndex].(type) {
case time.Time:
values[timeIndex] = float64(value.UnixNano() / 1e9)
}
}
table.Rows = append(table.Rows, values)
}
......@@ -142,8 +160,13 @@ func (e PostgresQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues,
func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error {
pointsBySeries := make(map[string]*tsdb.TimeSeries)
seriesByQueryOrder := list.New()
columnNames, err := rows.Columns()
if err != nil {
return err
}
columnTypes, err := rows.ColumnTypes()
if err != nil {
return err
}
......@@ -153,13 +176,21 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co
timeIndex := -1
metricIndex := -1
// check columns of resultset
// check columns of resultset: a column named time is mandatory
// the first text column is treated as metric name unless a column named metric is present
for i, col := range columnNames {
switch col {
case "time":
timeIndex = i
case "metric":
metricIndex = i
default:
if metricIndex == -1 {
switch columnTypes[i].DatabaseTypeName() {
case "UNKNOWN", "TEXT", "VARCHAR", "CHAR":
metricIndex = i
}
}
}
}
......
......@@ -9,10 +9,15 @@ type UrlQueryReader struct {
values url.Values
}
func NewUrlQueryReader(url *url.URL) *UrlQueryReader {
return &UrlQueryReader{
values: url.Query(),
func NewUrlQueryReader(urlInfo *url.URL) (*UrlQueryReader, error) {
u, err := url.ParseQuery(urlInfo.String())
if err != nil {
return nil, err
}
return &UrlQueryReader{
values: u,
}, nil
}
func (r *UrlQueryReader) Get(name string, def string) string {
......
......@@ -17,6 +17,10 @@ class Settings {
alertingEnabled: boolean;
authProxyEnabled: boolean;
ldapEnabled: boolean;
oauth: any;
disableUserSignUp: boolean;
loginHint: any;
loginError: any;
constructor(options) {
var defaults = {
......
define([
'./inspect_ctrl',
'./json_editor_ctrl',
'./login_ctrl',
'./invited_ctrl',
'./signup_ctrl',
'./reset_password_ctrl',
'./error_ctrl',
], function () {});
import './inspect_ctrl';
import './json_editor_ctrl';
import './login_ctrl';
import './invited_ctrl';
import './signup_ctrl';
import './reset_password_ctrl';
import './error_ctrl';
define([
'angular',
'app/core/config',
'../core_module',
],
function (angular, config, coreModule) {
'use strict';
coreModule.default.controller('ErrorCtrl', function($scope, contextSrv, navModelSrv) {
$scope.navModel = navModelSrv.getNotFoundNav();
$scope.appSubUrl = config.appSubUrl;
var showSideMenu = contextSrv.sidemenu;
contextSrv.sidemenu = false;
$scope.$on('$destroy', function() {
contextSrv.sidemenu = showSideMenu;
});
});
});
import config from 'app/core/config';
import coreModule from '../core_module';
export class ErrorCtrl {
/** @ngInject */
constructor($scope, contextSrv, navModelSrv) {
$scope.navModel = navModelSrv.getNotFoundNav();
$scope.appSubUrl = config.appSubUrl;
var showSideMenu = contextSrv.sidemenu;
contextSrv.sidemenu = false;
$scope.$on('$destroy', function() {
contextSrv.sidemenu = showSideMenu;
});
}
}
coreModule.controller('ErrorCtrl', ErrorCtrl);
define([
'angular',
'../core_module',
'app/core/config',
],
function (angular, coreModule, config) {
'use strict';
import coreModule from '../core_module';
import config from 'app/core/config';
config = config.default;
export class InvitedCtrl {
coreModule.default.controller('InvitedCtrl', function($scope, $routeParams, contextSrv, backendSrv) {
/** @ngInject */
constructor($scope, $routeParams, contextSrv, backendSrv) {
contextSrv.sidemenu = false;
$scope.formModel = {};
......@@ -35,6 +31,7 @@ function (angular, coreModule, config) {
};
$scope.init();
}
}
});
});
coreModule.controller('InvitedCtrl', InvitedCtrl);
define([
'angular',
'../core_module',
],
function (angular, coreModule) {
'use strict';
coreModule.default.controller('JsonEditorCtrl', function($scope) {
$scope.json = angular.toJson($scope.object, true);
$scope.canUpdate = $scope.updateHandler !== void 0 && $scope.contextSrv.isEditor;
$scope.update = function () {
var newObject = angular.fromJson($scope.json);
$scope.updateHandler(newObject, $scope.object);
};
});
});
import angular from 'angular';
import coreModule from '../core_module';
export class JsonEditorCtrl {
/** @ngInject */
constructor($scope) {
$scope.json = angular.toJson($scope.object, true);
$scope.canUpdate = $scope.updateHandler !== void 0 && $scope.contextSrv.isEditor;
$scope.update = function () {
var newObject = angular.fromJson($scope.json);
$scope.updateHandler(newObject, $scope.object);
};
}
}
coreModule.controller('JsonEditorCtrl', JsonEditorCtrl);
define([
'angular',
'lodash',
'../core_module',
'app/core/config',
],
function (angular, _, coreModule, config) {
'use strict';
config = config.default;
coreModule.default.controller('LoginCtrl', function($scope, backendSrv, contextSrv, $location) {
import _ from 'lodash';
import coreModule from '../core_module';
import config from 'app/core/config';
export class LoginCtrl {
/** @ngInject */
constructor($scope, backendSrv, contextSrv, $location) {
$scope.formModel = {
user: '',
email: '',
......@@ -74,8 +70,7 @@ function (angular, _, coreModule, config) {
if (params.redirect && params.redirect[0] === '/') {
window.location.href = config.appSubUrl + params.redirect;
}
else if (result.redirectUrl) {
} else if (result.redirectUrl) {
window.location.href = result.redirectUrl;
} else {
window.location.href = config.appSubUrl + '/';
......@@ -84,5 +79,7 @@ function (angular, _, coreModule, config) {
};
$scope.init();
});
});
}
}
coreModule.controller('LoginCtrl', LoginCtrl);
define([
'angular',
'../core_module',
],
function (angular, coreModule) {
'use strict';
coreModule.default.controller('ResetPasswordCtrl', function($scope, contextSrv, backendSrv, $location) {
import coreModule from '../core_module';
export class ResetPasswordCtrl {
/** @ngInject */
constructor($scope, contextSrv, backendSrv, $location) {
contextSrv.sidemenu = false;
$scope.formModel = {};
$scope.mode = 'send';
......@@ -37,7 +35,7 @@ function (angular, coreModule) {
$location.path('login');
});
};
}
}
});
});
coreModule.controller('ResetPasswordCtrl', ResetPasswordCtrl);
......@@ -26,7 +26,7 @@ export class DashExportCtrl {
}
saveJson() {
var clone = this.dashboardSrv.getCurrent().getSaveModelClone();
var clone = this.dash;
this.$scope.$root.appEvent('show-json-editor', {
object: clone,
......
define(['angular',
'lodash',
'jquery',
'moment',
'app/core/config',
],
function (angular, _, $, moment, config) {
'use strict';
import angular from 'angular';
import moment from 'moment';
import config from 'app/core/config';
config = config.default;
var module = angular.module('grafana.controllers');
module.controller('ShareModalCtrl', function($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) {
export class ShareModalCtrl {
/** @ngInject */
constructor($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) {
$scope.options = { forCurrent: true, includeTemplateVars: true, theme: 'current' };
$scope.editor = { index: $scope.tabIndex || 0};
......@@ -93,7 +86,7 @@ function (angular, _, $, moment, config) {
$scope.getShareUrl = function() {
return $scope.shareUrl;
};
}
}
});
});
angular.module('grafana.controllers').controller('ShareModalCtrl', ShareModalCtrl);
......@@ -172,7 +172,6 @@ export class ElasticQueryBuilder {
build(target, adhocFilters?, queryString?) {
// make sure query has defaults;
target.metrics = target.metrics || [{ type: 'count', id: '1' }];
target.dsType = 'elasticsearch';
target.bucketAggs = target.bucketAggs || [{type: 'date_histogram', id: '2', settings: {interval: 'auto'}}];
target.timeField = this.timeField;
......
......@@ -19,7 +19,6 @@ export default class InfluxQuery {
this.scopedVars = scopedVars;
target.policy = target.policy || 'default';
target.dsType = 'influxdb';
target.resultFormat = target.resultFormat || 'time_series';
target.orderByTime = target.orderByTime || 'ASC';
target.tags = target.tags || [];
......
......@@ -103,12 +103,21 @@ export class MysqlDatasource {
format: 'table',
};
var data = {
queries: [interpolatedQuery],
};
if (optionalOptions && optionalOptions.range && optionalOptions.range.from) {
data['from'] = optionalOptions.range.from.valueOf().toString();
}
if (optionalOptions && optionalOptions.range && optionalOptions.range.to) {
data['to'] = optionalOptions.range.to.valueOf().toString();
}
return this.backendSrv.datasourceRequest({
url: '/api/tsdb/query',
method: 'POST',
data: {
queries: [interpolatedQuery],
}
data: data
})
.then(data => this.responseParser.parseMetricFindQueryResult(refId, data));
}
......
......@@ -99,12 +99,21 @@ export class PostgresDatasource {
format: 'table',
};
var data = {
queries: [interpolatedQuery],
};
if (optionalOptions && optionalOptions.range && optionalOptions.range.from) {
data['from'] = optionalOptions.range.from.valueOf().toString();
}
if (optionalOptions && optionalOptions.range && optionalOptions.range.to) {
data['to'] = optionalOptions.range.to.valueOf().toString();
}
return this.backendSrv.datasourceRequest({
url: '/api/tsdb/query',
method: 'POST',
data: {
queries: [interpolatedQuery],
}
data: data
})
.then(data => this.responseParser.parseMetricFindQueryResult(refId, data));
}
......
......@@ -67,8 +67,8 @@ export default class PrometheusMetricFindQuery {
return this.datasource._request("GET", url).then(function(result) {
var _labels = _.map(result.data.data, function(metric) {
return metric[label];
});
return metric[label] || '';
}).filter(function(label) { return label !== ''; });
return _.uniq(_labels).map(function(metric) {
return {
......
<query-editor-row query-ctrl="ctrl" can-collapse="true" has-text-edit-mode="false">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<code-editor content="ctrl.target.expr" datasource="ctrl.datasource" on-change="ctrl.refreshMetricData()"
get-completer="ctrl.getCompleter()" data-mode="prometheus" code-editor-focus="ctrl.isLastQuery">
</code-editor>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<code-editor content="ctrl.target.expr" datasource="ctrl.datasource" on-change="ctrl.refreshMetricData()" get-completer="ctrl.getCompleter()"
data-mode="prometheus" code-editor-focus="ctrl.isLastQuery">
</code-editor>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form max-width-26">
<label class="gf-form-label width-8">Legend format</label>
<input type="text" class="gf-form-input" ng-model="ctrl.target.legendFormat"
spellcheck='false' placeholder="legend format" data-min-length=0 data-items=1000
ng-model-onblur ng-change="ctrl.refreshMetricData()">
</input>
</div>
<div class="gf-form-inline">
<div class="gf-form max-width-26">
<label class="gf-form-label width-8">Legend format</label>
<input type="text" class="gf-form-input" ng-model="ctrl.target.legendFormat" spellcheck='false' placeholder="legend format"
data-min-length=0 data-items=1000 ng-model-onblur ng-change="ctrl.refreshMetricData()">
</input>
<info-popover mode="right-absolute">
Controls the name of the time series, using name or pattern. For example {{hostname}} will be replaced with label value for
the label hostname.
</info-popover>
</div>
<div class="gf-form">
<label class="gf-form-label width-6">Min step</label>
<input type="text" class="gf-form-input width-8" ng-model="ctrl.target.interval"
data-placement="right"
spellcheck='false'
placeholder="{{ctrl.panelCtrl.interval}}"
data-min-length=0 data-items=100
ng-model-onblur
ng-change="ctrl.refreshMetricData()"/>
<info-popover mode="right-absolute">
Leave blank for auto handling based on time range and panel width
</info-popover>
</div>
<div class="gf-form">
<label class="gf-form-label width-6">Min step</label>
<input type="text" class="gf-form-input width-8" ng-model="ctrl.target.interval" data-placement="right" spellcheck='false'
placeholder="{{ctrl.panelCtrl.interval}}" data-min-length=0 data-items=100 ng-model-onblur ng-change="ctrl.refreshMetricData()"
/>
<info-popover mode="right-absolute">
Leave blank for auto handling based on time range and panel width
</info-popover>
</div>
<div class="gf-form">
<label class="gf-form-label">Resolution</label>
<div class="gf-form-select-wrapper max-width-15">
<select ng-model="ctrl.target.intervalFactor" class="gf-form-input"
ng-options="r.factor as r.label for r in ctrl.resolutions"
ng-change="ctrl.refreshMetricData()">
</select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-6">Format as</label>
<div class="gf-form-select-wrapper width-8">
<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>
<gf-form-switch class="gf-form" label="Instant" label-class="width-5" checked="ctrl.target.instant" on-change="ctrl.refresh()">
</gf-form-switch>
<label class="gf-form-label">
<a href="{{ctrl.linkToPrometheus}}" target="_blank" bs-tooltip="'Link to Graph in Prometheus'">
<i class="fa fa-share-square-o"></i>
</a>
</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">
<label class="gf-form-label">Resolution</label>
<div class="gf-form-select-wrapper max-width-15">
<select ng-model="ctrl.target.intervalFactor" class="gf-form-input" ng-options="r.factor as r.label for r in ctrl.resolutions"
ng-change="ctrl.refreshMetricData()">
</select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-6">Format as</label>
<div class="gf-form-select-wrapper width-8">
<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>
<gf-form-switch class="gf-form" label="Instant" label-class="width-5" checked="ctrl.target.instant" on-change="ctrl.refresh()">
</gf-form-switch>
<label class="gf-form-label">
<a href="{{ctrl.linkToPrometheus}}" target="_blank" bs-tooltip="'Link to Graph in Prometheus'">
<i class="fa fa-share-square-o"></i>
</a>
</label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
</query-editor-row>
......@@ -13,6 +13,10 @@
"alerting": true,
"annotations": true,
"queryOptions": {
"minInterval": true
},
"info": {
"author": {
"name": "Grafana Project",
......
......@@ -76,6 +76,24 @@ describe('PrometheusMetricFindQuery', function() {
ctx.$rootScope.$apply();
expect(results.length).to.be(3);
});
it('label_values(metric, resource) result should not contain empty string', function() {
response = {
status: "success",
data: [
{__name__: "metric", resource: "value1"},
{__name__: "metric", resource: "value2"},
{__name__: "metric", resource: ""}
]
};
ctx.$httpBackend.expect('GET', /proxied\/api\/v1\/series\?match\[\]=metric&start=.*&end=.*/).respond(response);
var pm = new PrometheusMetricFindQuery(ctx.ds, 'label_values(metric, resource)', ctx.timeSrv);
pm.process().then(function(data) { results = data; });
ctx.$httpBackend.flush();
ctx.$rootScope.$apply();
expect(results.length).to.be(2);
expect(results[0].text).to.be("value1");
expect(results[1].text).to.be("value2");
});
it('metrics(metric.*) should generate metric name query', function() {
response = {
status: "success",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment