Merge branch 'graphite-seriesbytag' of https://github.com/alexanderzobnin/grafana into graphite-series-by-tags

This commit is contained in:
Torkel Ödegaard 2017-11-15 10:51:05 +01:00
commit af9941e50a
52 changed files with 1327 additions and 485 deletions

View File

@ -14,7 +14,7 @@
* **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187)
* **Graph**: Add support for local formating in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk)
* **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213)
* **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/6710)
* **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764)
* **CLI**: Make it possible to install plugins from any url [#5873](https://github.com/grafana/grafana/issues/5873)
* **Prometheus**: Add support for instant queries [#5765](https://github.com/grafana/grafana/issues/5765), thx [@mtanda](https://github.com/mtanda)
* **Cloudwatch**: Add support for alerting using the cloudwatch datasource [#8050](https://github.com/grafana/grafana/pull/8050), thx [@mtanda](https://github.com/mtanda)
@ -23,6 +23,8 @@
* **Prometheus**: Align $__interval with the step parameters. [#9226](https://github.com/grafana/grafana/pull/9226), thx [@alin-amana](https://github.com/alin-amana)
* **Prometheus**: Autocomplete for label name and label value [#9208](https://github.com/grafana/grafana/pull/9208), thx [@mtanda](https://github.com/mtanda)
* **Postgres**: New Postgres data source [#9209](https://github.com/grafana/grafana/pull/9209), thx [@svenklemm](https://github.com/svenklemm)
* **Datasources**: Make datasource HTTP requests verify TLS by default. closes [#9371](https://github.com/grafana/grafana/issues/9371), [#5334](https://github.com/grafana/grafana/issues/5334), [#8812](https://github.com/grafana/grafana/issues/8812), thx [@mattbostock](https://github.com/mattbostock)
* **OAuth**: Verify TLS during OAuth callback [#9373](https://github.com/grafana/grafana/issues/9373), thx [@mattbostock](https://github.com/mattbostock)
## Minor
* **SMTP**: Make it possible to set specific EHLO for smtp client. [#9319](https://github.com/grafana/grafana/issues/9319)
@ -33,9 +35,11 @@
* **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn)
* **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123)
* **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo)
* **Kafka**: Add support for sending alert notifications to kafka [#7104](https://github.com/grafana/grafana/issues/7104), thx [@utkarshcmu](https://github.com/utkarshcmu)
## Tech
* **Go**: Grafana is now built using golang 1.9
* **Webpack**: Changed from systemjs to webpack (see readme or building from source guide for new build instructions). Systemjs is still used to load plugins but now plugins can only import a limited set of dependencies. See [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) for more details on how this can effect some plugins.
# 4.5.2 (2017-09-22)

28
PLUGIN_DEV.md Normal file
View File

@ -0,0 +1,28 @@
# Plugin Development
This document is not meant as complete guide for developing plugins but more as a changelog for changes in
Grafana that can impact plugin development. When ever you as plugin author encounter an issue with your plugin after
upgrading Grafana please check here before creating an issue.
## Links
- [Datasource plugin written in typescript](https://github.com/grafana/typescript-template-datasource)
- [Simple json dataource plugin](https://github.com/grafana/simple-json-datasource)
- [Plugin development guide](http://docs.grafana.org/plugins/developing/development/)
## Changes in v4.6
This version of Grafana has big changes that will impact a limited set of plugins. We moved from systemjs to webpack
for built-in plugins & everything internal. External plugins still use systemjs but now with a limited
set of Grafana components they can import. Plugins can depend on libs like lodash & moment and internal components
like before using the same import paths. However since everything in Grafana is no longer accessible, a few plugins could encounter issues when importing a Grafana dependency.
[List of exposed components plugins can import/require](https://github.com/grafana/grafana/blob/master/public/app/features/plugins/plugin_loader.ts#L48)
If you think we missed exposing a crucial lib or Grafana component let us know by opening an issue.
### Deprecated components
The angular directive `<spectrum-picker>` is no deprecated (will still work for a version more) but we recommend plugin authors
to upgrade to new `<color-picker color="ctrl.color" onChange="ctrl.onSparklineColorChange"></color-picker>`

View File

@ -82,10 +82,17 @@ You only need to add the options you want to override. Config files are applied
In your custom.ini uncomment (remove the leading `;`) sign. And set `app_mode = development`.
## Contribute
If you have any idea for an improvement or found a bug do not hesitate to open an issue.
And if you have time clone this repo and submit a pull request and help me make Grafana
the kickass metrics & devops dashboard we all dream about!
## Plugin development
Checkout the [Plugin Development Guide](http://docs.grafana.org/plugins/developing/development/) and checkout the [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) file for changes in Grafana that relate to
plugin development.
## License
Grafana is distributed under Apache 2.0 License.
Work in progress Grafana 2.0 (with included Grafana backend)

View File

@ -115,6 +115,17 @@ In DingTalk PC Client:
Dingtalk supports the following "message type": `text`, `link` and `markdown`. Only the `text` message type is supported.
### Kafka
Notifications can be sent to a Kafka topic from Grafana using [Kafka REST Proxy](https://docs.confluent.io/1.0/kafka-rest/docs/index.html).
There are couple of configurations options which need to be set in Grafana UI under Kafka Settings:
1. Kafka REST Proxy endpoint.
2. Kafka Topic.
Once these two properties are set, you can send the alerts to Kafka for further processing or throttling them.
### Other Supported Notification Channels
Grafana also supports the following Notification Channels:

View File

@ -135,6 +135,5 @@ Name | Description
------------ | -------------
Query | You can leave the search query blank or specify a lucene query
Time | The name of the time field, needs to be date field.
Title | The name of the field to use for the event title.
Text | Event description field.
Tags | Optional field name to use for event tags (can be an array or a CSV string).
Text | Optional field name to use event text body.

View File

@ -18,7 +18,7 @@ The alert list panel allows you to display your dashbords alerts. The list can b
## Alert List Options
{{< docs-imagebox img="/img/docs/v45/alert-list-options.png" max-width="600px" class="docs-image--no-shadow docs-image--right">}}
{{< docs-imagebox img="/img/docs/v45/alert-list-options.png" max-width="600px" class="docs-image--no-shadow docs-image--right" >}}
1. **Show**: Lets you choose between current state or recent state changes.
2. **Max Items**: Max items set the maximum of items in a list.

View File

@ -0,0 +1,74 @@
+++
title = "What's New in Grafana v4.6"
description = "Feature & improvement highlights for Grafana v4.6"
keywords = ["grafana", "new", "documentation", "4.6"]
type = "docs"
[menu.docs]
name = "Version 4.6"
identifier = "v4.6"
parent = "whatsnew"
weight = -5
+++
# What's New in Grafana v4.6
Grafana v4.6 brings many enhancements to Annotations, Cloudwatch & Prometheus. It also adds support for Postgres as metric & table data source!
### Annotations
{{< docs-imagebox img="/img/docs/v46/add_annotation_region.png" max-width= "800px" >}}
You can now add annotation events and regions right from the graph panel! Just hold CTRL/CMD + click or drag region to open the **Add Annotation** view. The
[Annotations]({{< relref "reference/annotations.md" >}}) documentation is updated to include details on this new exciting feature.
### Cloudwatch
Cloudwatch now supports alerting. Setup alert rules for any Cloudwatch metric!
{{< docs-imagebox img="/img/docs/v46/cloudwatch_alerting.png" max-width= "800px" >}}
### Postgres
Grafana v4.6 now ships with a built-in datasource plugin for Postgres. Have logs or metric data in Postgres? You can now visualize that data and
define alert rules on it like any of our other data sources.
{{< docs-imagebox img="/img/docs/v46/postgres_table_query.png" max-width= "800px" >}}
### Prometheus
New enhancements include support for **instant queries** and improvements to query editor in the form of autocomplete for label names and label values.
This makes exploring and filtering Prometheus data much easier.
## Changelog
### New Features
* **GCS**: Adds support for Google Cloud Storage [#8370](https://github.com/grafana/grafana/issues/8370) thx [@chuhlomin](https://github.com/chuhlomin)
* **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187)
* **Graph**: Add support for local formating in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk)
* **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213)
* **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764)
* **CLI**: Make it possible to install plugins from any url [#5873](https://github.com/grafana/grafana/issues/5873)
* **Prometheus**: Add support for instant queries [#5765](https://github.com/grafana/grafana/issues/5765), thx [@mtanda](https://github.com/mtanda)
* **Cloudwatch**: Add support for alerting using the cloudwatch datasource [#8050](https://github.com/grafana/grafana/pull/8050), thx [@mtanda](https://github.com/mtanda)
* **Pagerduty**: Include triggering series in pagerduty notification [#8479](https://github.com/grafana/grafana/issues/8479), thx [@rickymoorhouse](https://github.com/rickymoorhouse)
* **Timezone**: Time ranges like Today & Yesterday now work correctly when timezone setting is set to UTC [#8916](https://github.com/grafana/grafana/issues/8916), thx [@ctide](https://github.com/ctide)
* **Prometheus**: Align $__interval with the step parameters. [#9226](https://github.com/grafana/grafana/pull/9226), thx [@alin-amana](https://github.com/alin-amana)
* **Prometheus**: Autocomplete for label name and label value [#9208](https://github.com/grafana/grafana/pull/9208), thx [@mtanda](https://github.com/mtanda)
* **Postgres**: New Postgres data source [#9209](https://github.com/grafana/grafana/pull/9209), thx [@svenklemm](https://github.com/svenklemm)
* **Datasources**: closes [#9371](https://github.com/grafana/grafana/issues/9371), [#5334](https://github.com/grafana/grafana/issues/5334), [#8812](https://github.com/grafana/grafana/issues/8812), thx [@mattbostock](https://github.com/mattbostock)
### Minor Changes
* **SMTP**: Make it possible to set specific EHLO for smtp client. [#9319](https://github.com/grafana/grafana/issues/9319)
* **Dataproxy**: Allow grafan to renegotiate tls connection [#9250](https://github.com/grafana/grafana/issues/9250)
* **HTTP**: set net.Dialer.DualStack to true for all http clients [#9367](https://github.com/grafana/grafana/pull/9367)
* **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739)
* **Slack**: Allow images to be uploaded to slack when Token is precent [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8)
* **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn)
* **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123)
* **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo)
### Tech
* **Go**: Grafana is now built using golang 1.9

View File

@ -57,8 +57,7 @@ baseurl=https://packagecloud.io/grafana/stable/el/6/$basearch
repo_gpgcheck=1
enabled=1
gpgcheck=1
gpgkey=https://packagecloud.io/gpg.key
https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana
gpgkey=https://packagecloud.io/gpg.key https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana
sslverify=1
sslcacert=/etc/pki/tls/certs/ca-bundle.crt
```

View File

@ -10,34 +10,37 @@ weight = 2
# Annotations
{{< docs-imagebox img="/img/docs/v46/annotations.png" max-width="800px" >}}
Annotations provide a way to mark points on the graph with rich events. When you hover over an annotation
you can get event description and event tags. The text field can include links to other systems with more detail.
![](/img/docs/annotations/toggles.png)
## Native annotations
Grafana v4.6+ comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the [HTTP API]({{< relref "http_api/annotations.md" >}})
Grafana v4.6+ comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the [HTTP API]({{< relref "http_api/annotations.md" >}}).
## Adding annotations
by holding down CTRL/CMD + mouse click. Add tags to the annotation will make it searchable from other dashboards.
By holding down CTRL/CMD + mouse click. Add tags to the annotation will make it searchable from other dashboards.
<!-- adding annoation gif animation -->
{{< docs-imagebox img="/img/docs/annotations/annotation-still.png"
max-width="600px" animated-gif="/img/docs/annotations/annotation.gif" >}}
### Adding regions events
You can also hold down CTRL/CMD and select region to create a region annotation.
<!-- region image/gif animation -->
{{< docs-imagebox img="/img/docs/annotations/region-annotation-still.png"
max-width="600px" animated-gif="/img/docs/annotations/region-annotation.gif" >}}
### Built in query
After you added an an annotation they will be still be visible. This is due to the built in annotation query that exists on all dashboards. This annotation query will
After you added an annotation they will still be visible. This is due to the built in annotation query that exists on all dashboards. This annotation query will
fetch all annotation events that originate from the current dashboard and show them on the panel where they where created. This includes alert state history annotations. You can
stop annotations from being fetched & drawn by opening the **Annotations** settings (via Dashboard cogs menu) and modifying the query named `Annotations & Alerts (Built-in)`.
<!-- image of built in query -->
When you copy a dashboard using the **Save As** feature it will get a new dashboard id so annotations created on source dashboard will no longer be visible on the copy. You
can still show them if you add a new **Annotation Query** and filter by tags. But this only works if the annotations on the source dashboard had tags to filter by.
### Query by tag

View File

@ -4,7 +4,7 @@
"company": "Grafana Labs"
},
"name": "grafana",
"version": "4.6.0-pre1",
"version": "4.6.0-beta1",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"

View File

@ -1,6 +1,10 @@
package api
import (
"fmt"
"strings"
"time"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/middleware"
@ -78,6 +82,60 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response
return ApiSuccess("Annotation added")
}
type GraphiteAnnotationError struct {
message string
}
func (e *GraphiteAnnotationError) Error() string {
return e.message
}
func formatGraphiteAnnotation(what string, data string) string {
return fmt.Sprintf("%s\n%s", what, data)
}
func PostGraphiteAnnotation(c *middleware.Context, cmd dtos.PostGraphiteAnnotationsCmd) Response {
repo := annotations.GetRepository()
if cmd.When == 0 {
cmd.When = time.Now().Unix()
}
text := formatGraphiteAnnotation(cmd.What, cmd.Data)
// Support tags in prior to Graphite 0.10.0 format (string of tags separated by space)
var tagsArray []string
switch tags := cmd.Tags.(type) {
case string:
tagsArray = strings.Split(tags, " ")
case []interface{}:
for _, t := range tags {
if tagStr, ok := t.(string); ok {
tagsArray = append(tagsArray, tagStr)
} else {
err := &GraphiteAnnotationError{"tag should be a string"}
return ApiError(500, "Failed to save Graphite annotation", err)
}
}
default:
err := &GraphiteAnnotationError{"unsupported tags format"}
return ApiError(500, "Failed to save Graphite annotation", err)
}
item := annotations.Item{
OrgId: c.OrgId,
UserId: c.UserId,
Epoch: cmd.When,
Text: text,
Tags: tagsArray,
}
if err := repo.Save(&item); err != nil {
return ApiError(500, "Failed to save Graphite annotation", err)
}
return ApiSuccess("Graphite Annotation added")
}
func UpdateAnnotation(c *middleware.Context, cmd dtos.UpdateAnnotationsCmd) Response {
annotationId := c.ParamsInt64(":annotationId")

View File

@ -292,6 +292,7 @@ func (hs *HttpServer) registerRoutes() {
annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationById))
annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation))
annotationsRoute.Delete("/region/:regionId", wrap(DeleteAnnotationRegion))
annotationsRoute.Post("/graphite", bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation))
}, reqEditorRole)
// error test

View File

@ -6,31 +6,33 @@ import (
"net/http"
"time"
"gopkg.in/macaron.v1"
"github.com/grafana/grafana/pkg/api/pluginproxy"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
macaron "gopkg.in/macaron.v1"
)
var pluginProxyTransport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
Renegotiation: tls.RenegotiateFreelyAsClient,
},
Proxy: http.ProxyFromEnvironment,
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).Dial,
TLSHandshakeTimeout: 10 * time.Second,
}
var pluginProxyTransport *http.Transport
func InitAppPluginRoutes(r *macaron.Macaron) {
pluginProxyTransport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: setting.PluginAppsSkipVerifyTLS,
Renegotiation: tls.RenegotiateFreelyAsClient,
},
Proxy: http.ProxyFromEnvironment,
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).Dial,
TLSHandshakeTimeout: 10 * time.Second,
}
for _, plugin := range plugins.Apps {
for _, route := range plugin.Routes {
url := util.JoinUrlFragments("/api/plugin-proxy/"+plugin.Id, route.Path)

View File

@ -29,3 +29,10 @@ type DeleteAnnotationsCmd struct {
AnnotationId int64 `json:"annotationId"`
RegionId int64 `json:"regionId"`
}
type PostGraphiteAnnotationsCmd struct {
When int64 `json:"when"`
What string `json:"what"`
Data string `json:"data"`
Tags interface{} `json:"tags"`
}

View File

@ -1,7 +1,6 @@
package api
import (
"crypto/tls"
"net"
"net/http"
"net/http/httputil"
@ -14,8 +13,7 @@ import (
)
var grafanaComProxyTransport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
Proxy: http.ProxyFromEnvironment,
Proxy: http.ProxyFromEnvironment,
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,

View File

@ -11,6 +11,8 @@ import (
"path"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
gocache "github.com/patrickmn/go-cache"
@ -19,7 +21,6 @@ import (
"github.com/grafana/grafana/pkg/api/live"
httpstatic "github.com/grafana/grafana/pkg/api/static"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/middleware"
@ -153,7 +154,7 @@ func (hs *HttpServer) newMacaron() *macaron.Macaron {
for _, route := range plugins.StaticRoutes {
pluginRoute := path.Join("/public/plugins/", route.PluginId)
logger.Debug("Plugins: Adding route", "route", pluginRoute, "dir", route.Directory)
hs.log.Debug("Plugins: Adding route", "route", pluginRoute, "dir", route.Directory)
hs.mapStatic(m, route.Directory, "", pluginRoute)
}
@ -187,7 +188,9 @@ func (hs *HttpServer) metricsEndpoint(ctx *macaron.Context) {
return
}
promhttp.Handler().ServeHTTP(ctx.Resp, ctx.Req.Request)
promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
DisableCompression: true,
}).ServeHTTP(ctx.Resp, ctx.Req.Request)
}
func (hs *HttpServer) healthHandler(ctx *macaron.Context) {

View File

@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
@ -16,6 +15,7 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/metrics"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
@ -29,6 +29,7 @@ var (
ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter")
ErrUsersQuotaReached = errors.New("Users quota reached")
ErrNoEmail = errors.New("Login provider didn't return an email address")
oauthLogger = log.New("oauth.login")
)
func GenStateString() string {
@ -50,10 +51,11 @@ func OAuthLogin(ctx *middleware.Context) {
return
}
error := ctx.Query("error")
if error != "" {
errorParam := ctx.Query("error")
if errorParam != "" {
errorDesc := ctx.Query("error_description")
redirectWithError(ctx, ErrProviderDeniedRequest, "error", error, "errorDesc", errorDesc)
oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc)
redirectWithError(ctx, ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc)
return
}
@ -69,8 +71,12 @@ func OAuthLogin(ctx *middleware.Context) {
return
}
// verify state string
savedState := ctx.Session.Get(middleware.SESS_KEY_OAUTH_STATE).(string)
savedState, ok := ctx.Session.Get(middleware.SESS_KEY_OAUTH_STATE).(string)
if !ok {
ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
return
}
queryState := ctx.Query("state")
if savedState != queryState {
ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil)
@ -78,36 +84,37 @@ func OAuthLogin(ctx *middleware.Context) {
}
// handle call back
tr := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: setting.OAuthService.OAuthInfos[name].TlsSkipVerify,
},
}
oauthClient := &http.Client{
Transport: tr,
}
// initialize oauth2 context
oauthCtx := oauth2.NoContext
if setting.OAuthService.OAuthInfos[name].TlsClientCert != "" {
if setting.OAuthService.OAuthInfos[name].TlsClientCert != "" || setting.OAuthService.OAuthInfos[name].TlsClientKey != "" {
cert, err := tls.LoadX509KeyPair(setting.OAuthService.OAuthInfos[name].TlsClientCert, setting.OAuthService.OAuthInfos[name].TlsClientKey)
if err != nil {
log.Fatal(err)
log.Fatal(1, "Failed to setup TlsClientCert", "oauth provider", name, "error", err)
}
// Load CA cert
tr.TLSClientConfig.Certificates = append(tr.TLSClientConfig.Certificates, cert)
}
if setting.OAuthService.OAuthInfos[name].TlsClientCa != "" {
caCert, err := ioutil.ReadFile(setting.OAuthService.OAuthInfos[name].TlsClientCa)
if err != nil {
log.Fatal(err)
log.Fatal(1, "Failed to setup TlsClientCa", "oauth provider", name, "error", err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
tr := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
Certificates: []tls.Certificate{cert},
RootCAs: caCertPool,
},
}
sslcli := &http.Client{Transport: tr}
oauthCtx = context.Background()
oauthCtx = context.WithValue(oauthCtx, oauth2.HTTPClient, sslcli)
tr.TLSClientConfig.RootCAs = caCertPool
}
oauthCtx := context.WithValue(context.Background(), oauth2.HTTPClient, oauthClient)
// get token from provider
token, err := connect.Exchange(oauthCtx, code)
if err != nil {

View File

@ -17,8 +17,6 @@ var version = "master"
func main() {
setupLogging()
services.Init(version)
app := cli.NewApp()
app.Name = "Grafana cli"
app.Usage = ""
@ -44,12 +42,20 @@ func main() {
Value: "",
EnvVar: "GF_PLUGIN_URL",
},
cli.BoolFlag{
Name: "insecure",
Usage: "Skip TLS verification (insecure)",
},
cli.BoolFlag{
Name: "debug, d",
Usage: "enable debug logging",
},
}
app.Before = func(c *cli.Context) error {
services.Init(version, c.GlobalBool("insecure"))
return nil
}
app.Commands = commands.Commands
app.CommandNotFound = cmdNotFound

View File

@ -22,7 +22,7 @@ var (
grafanaVersion string
)
func Init(version string) {
func Init(version string, skipTLSVerify bool) {
grafanaVersion = version
tr := &http.Transport{
@ -36,8 +36,9 @@ func Init(version string) {
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
TLSClientConfig: &tls.Config{
InsecureSkipVerify: skipTLSVerify,
},
}
HttpClient = http.Client{

View File

@ -3,6 +3,7 @@ package models
import (
"crypto/tls"
"crypto/x509"
"errors"
"net"
"net/http"
"sync"
@ -45,9 +46,16 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
return t.Transport, nil
}
var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool
if ds.JsonData != nil {
tlsClientAuth = ds.JsonData.Get("tlsAuth").MustBool(false)
tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false)
tlsSkipVerify = ds.JsonData.Get("tlsSkipVerify").MustBool(false)
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
InsecureSkipVerify: tlsSkipVerify,
Renegotiation: tls.RenegotiateFreelyAsClient,
},
Proxy: http.ProxyFromEnvironment,
@ -62,30 +70,24 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
IdleConnTimeout: 90 * time.Second,
}
var tlsAuth, tlsAuthWithCACert bool
if ds.JsonData != nil {
tlsAuth = ds.JsonData.Get("tlsAuth").MustBool(false)
tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false)
}
if tlsAuth {
transport.TLSClientConfig.InsecureSkipVerify = false
if tlsClientAuth || tlsAuthWithCACert {
decrypted := ds.SecureJsonData.Decrypt()
if tlsAuthWithCACert && len(decrypted["tlsCACert"]) > 0 {
caPool := x509.NewCertPool()
ok := caPool.AppendCertsFromPEM([]byte(decrypted["tlsCACert"]))
if ok {
transport.TLSClientConfig.RootCAs = caPool
if !ok {
return nil, errors.New("Failed to parse TLS CA PEM certificate")
}
transport.TLSClientConfig.RootCAs = caPool
}
cert, err := tls.X509KeyPair([]byte(decrypted["tlsClientCert"]), []byte(decrypted["tlsClientKey"]))
if err != nil {
return nil, err
if tlsClientAuth {
cert, err := tls.X509KeyPair([]byte(decrypted["tlsClientCert"]), []byte(decrypted["tlsClientKey"]))
if err != nil {
return nil, err
}
transport.TLSClientConfig.Certificates = []tls.Certificate{cert}
}
transport.TLSClientConfig.Certificates = []tls.Certificate{cert}
}
ptc.cache[ds.Id] = cachedTransport{

View File

@ -29,61 +29,140 @@ func TestDataSourceCache(t *testing.T) {
Convey("Should be using the cached proxy", func() {
So(t2, ShouldEqual, t1)
})
Convey("Should verify TLS by default", func() {
So(t1.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false)
})
Convey("Should have no TLS client certificate configured", func() {
So(len(t1.TLSClientConfig.Certificates), ShouldEqual, 0)
})
Convey("Should have no user-supplied TLS CA onfigured", func() {
So(t1.TLSClientConfig.RootCAs, ShouldBeNil)
})
})
Convey("When getting kubernetes datasource proxy", t, func() {
Convey("When caching a datasource proxy then updating it", t, func() {
clearCache()
setting.SecretKey = "password"
json := simplejson.New()
json.Set("tlsAuthWithCACert", true)
tlsCaCert, err := util.Encrypt([]byte(caCert), "password")
So(err, ShouldBeNil)
ds := DataSource{
Id: 1,
Url: "http://k8s:8001",
Type: "Kubernetes",
SecureJsonData: map[string][]byte{"tlsCACert": tlsCaCert},
Updated: time.Now().Add(-2 * time.Minute),
}
t1, err := ds.GetHttpTransport()
So(err, ShouldBeNil)
Convey("Should verify TLS by default", func() {
So(t1.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false)
})
Convey("Should have no TLS client certificate configured", func() {
So(len(t1.TLSClientConfig.Certificates), ShouldEqual, 0)
})
Convey("Should have no user-supplied TLS CA configured", func() {
So(t1.TLSClientConfig.RootCAs, ShouldBeNil)
})
ds.JsonData = nil
ds.SecureJsonData = map[string][]byte{}
ds.Updated = time.Now()
t2, err := ds.GetHttpTransport()
So(err, ShouldBeNil)
Convey("Should have no user-supplied TLS CA configured after the update", func() {
So(t2.TLSClientConfig.RootCAs, ShouldBeNil)
})
})
Convey("When caching a datasource proxy with TLS client authentication enabled", t, func() {
clearCache()
setting.SecretKey = "password"
json := simplejson.New()
json.Set("tlsAuth", true)
tlsClientCert, err := util.Encrypt([]byte(clientCert), "password")
So(err, ShouldBeNil)
tlsClientKey, err := util.Encrypt([]byte(clientKey), "password")
So(err, ShouldBeNil)
ds := DataSource{
Id: 1,
Url: "http://k8s:8001",
Type: "Kubernetes",
JsonData: json,
SecureJsonData: map[string][]byte{
"tlsClientCert": tlsClientCert,
"tlsClientKey": tlsClientKey,
},
}
tr, err := ds.GetHttpTransport()
So(err, ShouldBeNil)
Convey("Should verify TLS by default", func() {
So(tr.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false)
})
Convey("Should have a TLS client certificate configured", func() {
So(len(tr.TLSClientConfig.Certificates), ShouldEqual, 1)
})
})
Convey("When caching a datasource proxy with a user-supplied TLS CA", t, func() {
clearCache()
setting.SecretKey = "password"
json := simplejson.New()
json.Set("tlsAuthWithCACert", true)
t := time.Now()
tlsCaCert, err := util.Encrypt([]byte(caCert), "password")
So(err, ShouldBeNil)
ds := DataSource{
Url: "http://k8s:8001",
Type: "Kubernetes",
Updated: t.Add(-2 * time.Minute),
Id: 1,
Url: "http://k8s:8001",
Type: "Kubernetes",
JsonData: json,
SecureJsonData: map[string][]byte{"tlsCACert": tlsCaCert},
}
transport, err := ds.GetHttpTransport()
tr, err := ds.GetHttpTransport()
So(err, ShouldBeNil)
Convey("Should have no cert", func() {
So(transport.TLSClientConfig.InsecureSkipVerify, ShouldEqual, true)
Convey("Should verify TLS by default", func() {
So(tr.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false)
})
Convey("Should have a TLS CA configured", func() {
So(len(tr.TLSClientConfig.RootCAs.Subjects()), ShouldEqual, 1)
})
})
ds.JsonData = json
Convey("When caching a datasource proxy when user skips TLS verification", t, func() {
clearCache()
tlsCaCert, _ := util.Encrypt([]byte(caCert), "password")
tlsClientCert, _ := util.Encrypt([]byte(clientCert), "password")
tlsClientKey, _ := util.Encrypt([]byte(clientKey), "password")
json := simplejson.New()
json.Set("tlsSkipVerify", true)
ds.SecureJsonData = map[string][]byte{
"tlsCACert": tlsCaCert,
"tlsClientCert": tlsClientCert,
"tlsClientKey": tlsClientKey,
ds := DataSource{
Id: 1,
Url: "http://k8s:8001",
Type: "Kubernetes",
JsonData: json,
}
ds.Updated = t.Add(-1 * time.Minute)
transport, err = ds.GetHttpTransport()
tr, err := ds.GetHttpTransport()
So(err, ShouldBeNil)
Convey("Should add cert", func() {
So(transport.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false)
So(len(transport.TLSClientConfig.Certificates), ShouldEqual, 1)
})
ds.JsonData = nil
ds.SecureJsonData = map[string][]byte{}
ds.Updated = t
transport, err = ds.GetHttpTransport()
So(err, ShouldBeNil)
Convey("Should remove cert", func() {
So(transport.TLSClientConfig.InsecureSkipVerify, ShouldEqual, true)
So(len(transport.TLSClientConfig.Certificates), ShouldEqual, 0)
Convey("Should skip TLS verification", func() {
So(tr.TLSClientConfig.InsecureSkipVerify, ShouldEqual, true)
})
})
}
@ -115,7 +194,8 @@ FHoXIyGOdq1chmRVocdGBCF8fUoGIbuF14r53rpvcbEKtKnnP8+96luKAZLq0a4n
3lb92xM=
-----END CERTIFICATE-----`
const clientCert string = `-----BEGIN CERTIFICATE-----
const clientCert string = `
-----BEGIN CERTIFICATE-----
MIICsjCCAZoCCQCcd8sOfstQLzANBgkqhkiG9w0BAQsFADAXMRUwEwYDVQQDDAxj
YS1rOHMtc3RobG0wHhcNMTYxMTAyMDkyNTE1WhcNMTcxMTAyMDkyNTE1WjAfMR0w
GwYDVQQDDBRhZG0tZGFuaWVsLWs4cy1zdGhsbTCCASIwDQYJKoZIhvcNAQEBBQAD

View File

@ -0,0 +1,120 @@
package notifiers
import (
"strconv"
"fmt"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
)
func init() {
alerting.RegisterNotifier(&alerting.NotifierPlugin{
Type: "kafka",
Name: "Kafka REST Proxy",
Description: "Sends notifications to Kafka Rest Proxy",
Factory: NewKafkaNotifier,
OptionsTemplate: `
<h3 class="page-heading">Kafka settings</h3>
<div class="gf-form">
<span class="gf-form-label width-14">Kafka REST Proxy</span>
<input type="text" required class="gf-form-input max-width-22" ng-model="ctrl.model.settings.kafkaRestProxy" placeholder="http://localhost:8082"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-14">Topic</span>
<input type="text" required class="gf-form-input max-width-22" ng-model="ctrl.model.settings.kafkaTopic" placeholder="topic1"></input>
</div>
`,
})
}
func NewKafkaNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
endpoint := model.Settings.Get("kafkaRestProxy").MustString()
if endpoint == "" {
return nil, alerting.ValidationError{Reason: "Could not find kafka rest proxy endpoint property in settings"}
}
topic := model.Settings.Get("kafkaTopic").MustString()
if topic == "" {
return nil, alerting.ValidationError{Reason: "Could not find kafka topic property in settings"}
}
return &KafkaNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
Endpoint: endpoint,
Topic: topic,
log: log.New("alerting.notifier.kafka"),
}, nil
}
type KafkaNotifier struct {
NotifierBase
Endpoint string
Topic string
log log.Logger
}
func (this *KafkaNotifier) Notify(evalContext *alerting.EvalContext) error {
state := evalContext.Rule.State
customData := "Triggered metrics:\n\n"
for _, evt := range evalContext.EvalMatches {
customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value)
}
this.log.Info("Notifying Kafka", "alert_state", state)
recordJSON := simplejson.New()
records := make([]interface{}, 1)
bodyJSON := simplejson.New()
bodyJSON.Set("description", evalContext.Rule.Name+" - "+evalContext.Rule.Message)
bodyJSON.Set("client", "Grafana")
bodyJSON.Set("details", customData)
bodyJSON.Set("incident_key", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10))
ruleUrl, err := evalContext.GetRuleUrl()
if err != nil {
this.log.Error("Failed get rule link", "error", err)
return err
}
bodyJSON.Set("client_url", ruleUrl)
if evalContext.ImagePublicUrl != "" {
contexts := make([]interface{}, 1)
imageJSON := simplejson.New()
imageJSON.Set("type", "image")
imageJSON.Set("src", evalContext.ImagePublicUrl)
contexts[0] = imageJSON
bodyJSON.Set("contexts", contexts)
}
valueJSON := simplejson.New()
valueJSON.Set("value", bodyJSON)
records[0] = valueJSON
recordJSON.Set("records", records)
body, _ := recordJSON.MarshalJSON()
topicUrl := this.Endpoint + "/topics/" + this.Topic
cmd := &m.SendWebhookSync{
Url: topicUrl,
Body: string(body),
HttpMethod: "POST",
HttpHeader: map[string]string{
"Content-Type": "application/vnd.kafka.json.v2+json",
"Accept": "application/vnd.kafka.v2+json",
},
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
this.log.Error("Failed to send notification to Kafka", "error", err, "body", string(body))
return err
}
return nil
}

View File

@ -0,0 +1,55 @@
package notifiers
import (
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
)
func TestKafkaNotifier(t *testing.T) {
Convey("Kafka notifier tests", t, func() {
Convey("Parsing alert notification from settings", func() {
Convey("empty settings should return error", func() {
json := `{ }`
settingsJSON, _ := simplejson.NewJson([]byte(json))
model := &m.AlertNotification{
Name: "kafka_testing",
Type: "kafka",
Settings: settingsJSON,
}
_, err := NewKafkaNotifier(model)
So(err, ShouldNotBeNil)
})
Convey("settings should send an event to kafka", func() {
json := `
{
"kafkaRestProxy": "http://localhost:8082",
"kafkaTopic": "topic1"
}`
settingsJSON, _ := simplejson.NewJson([]byte(json))
model := &m.AlertNotification{
Name: "kafka_testing",
Type: "kafka",
Settings: settingsJSON,
}
not, err := NewKafkaNotifier(model)
kafkaNotifier := not.(*KafkaNotifier)
So(err, ShouldBeNil)
So(kafkaNotifier.Name, ShouldEqual, "kafka_testing")
So(kafkaNotifier.Type, ShouldEqual, "kafka")
So(kafkaNotifier.Endpoint, ShouldEqual, "http://localhost:8082")
So(kafkaNotifier.Topic, ShouldEqual, "topic1")
})
})
})
}

View File

@ -100,13 +100,13 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
sql.WriteString(")")
}
sql.WriteString(" ORDER BY name ASC")
if query.Limit != 0 {
sql.WriteString(" LIMIT ?")
params = append(params, query.Limit)
}
sql.WriteString(" ORDER BY name ASC")
alerts := make([]*m.Alert, 0)
if err := x.Sql(sql.String(), params...).Find(&alerts); err != nil {
return err

View File

@ -122,6 +122,9 @@ var (
// Basic Auth
BasicAuthEnabled bool
// Plugin settings
PluginAppsSkipVerifyTLS bool
// Session settings.
SessionOptions session.Options
@ -560,6 +563,9 @@ func NewConfigContext(args *CommandLineArgs) error {
authBasic := Cfg.Section("auth.basic")
BasicAuthEnabled = authBasic.Key("enabled").MustBool(true)
// global plugin settings
PluginAppsSkipVerifyTLS = Cfg.Section("plugins").Key("app_tls_skip_verify_insecure").MustBool(false)
// PhantomJS rendering
ImagesDir = filepath.Join(DataPath, "png")
PhantomDir = filepath.Join(HomePath, "vendor/phantomjs")

View File

@ -13,6 +13,7 @@ type OAuthInfo struct {
TlsClientCert string
TlsClientKey string
TlsClientCa string
TlsSkipVerify bool
}
type OAuther struct {

View File

@ -66,6 +66,7 @@ func NewOAuthService() {
TlsClientCert: sec.Key("tls_client_cert").String(),
TlsClientKey: sec.Key("tls_client_key").String(),
TlsClientCa: sec.Key("tls_client_ca").String(),
TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(),
}
if !info.Enabled {

View File

@ -0,0 +1,23 @@
/**
* Wrapper for the new ngReact <color-picker> directive for backward compatibility.
* Allows remaining <spectrum-picker> untouched in outdated plugins.
* Technically, it's just a wrapper for react component with two-way data binding support.
*/
import coreModule from '../../core_module';
export function spectrumPicker() {
return {
restrict: 'E',
require: 'ngModel',
scope: true,
replace: true,
template: '<color-picker color="ngModel.$viewValue" onChange="onColorChange"></color-picker>',
link: function(scope, element, attrs, ngModel) {
scope.ngModel = ngModel;
scope.onColorChange = (color) => {
ngModel.$setViewValue(color);
};
}
};
}
coreModule.directive('spectrumPicker', spectrumPicker);

View File

@ -1,19 +1,21 @@
define([
'angular',
'app/core/config',
'../core_module',
],
function (angular, coreModule) {
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() {
$scope.contextSrv.sidemenu = showSideMenu;
contextSrv.sidemenu = showSideMenu;
});
});

View File

@ -17,6 +17,7 @@ import './components/code_editor/code_editor';
import './utils/outline';
import './components/colorpicker/ColorPicker';
import './components/colorpicker/SeriesColorPicker';
import './components/colorpicker/spectrum_picker';
import {grafanaAppDirective} from './components/grafana_app';
import {sideMenuDirective} from './components/sidemenu/sidemenu';

View File

@ -46,6 +46,7 @@ function (_, $, coreModule) {
segment.html = selected.html || selected.value;
segment.fake = false;
segment.expandable = selected.expandable;
segment.type = selected.type;
}
else if (segment.custom !== 'false') {
segment.value = value;

View File

@ -0,0 +1,34 @@
import _ from 'lodash';
const versionPattern = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z\.]+))?/;
export class SemVersion {
major: number;
minor: number;
patch: number;
meta: string;
constructor(version: string) {
let match = versionPattern.exec(version);
if (match) {
this.major = Number(match[1]);
this.minor = Number(match[2] || 0);
this.patch = Number(match[3] || 0);
this.meta = match[4];
}
}
isGtOrEq(version: string): boolean {
let compared = new SemVersion(version);
return !(this.major < compared.major || this.minor < compared.minor || this.patch < compared.patch);
}
isValid(): boolean {
return _.isNumber(this.major);
}
}
export function isVersionGtOrEq(a: string, b: string): boolean {
let a_semver = new SemVersion(a);
return a_semver.isGtOrEq(b);
}

View File

@ -94,6 +94,7 @@ export class AlertTabCtrl {
case "opsgenie": return "fa fa-bell";
case "hipchat": return "fa fa-mail-forward";
case "pushover": return "fa fa-mobile";
case "kafka": return "fa fa-random";
}
return 'fa fa-bell';
}

View File

@ -40,13 +40,11 @@
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form width-6">
<button type="submit" ng-click="ctrl.save()" class="btn btn-success">Save</button>
<div class="gf-form width-8">
<button type="submit" ng-click="ctrl.save()" class="btn btn-success width-7">Save</button>
</div>
<div class="gf-form width-20">
<div class="gf-form">
<button type="submit" ng-click="ctrl.testNotification()" class="btn btn-secondary">Send Test</button>
</div>
<div class="gf-form width-8">
<button type="submit" ng-click="ctrl.testNotification()" class="btn btn-secondary width-7">Send Test</button>
</div>
</div>
</div>

View File

@ -66,7 +66,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv,
tooltip += '<div class="graph-annotation__body">';
if (text) {
tooltip += '<div>' + sanitizeString(text).replace(/\n/g, '<br>') + '</div>';
tooltip += '<div>' + sanitizeString(text.replace(/\n/g, '<br>')) + '</div>';
}
var tags = event.tags;

View File

@ -18,7 +18,7 @@ const template = `
<div class="modal-content text-center">
<div class="confirm-modal-text">
Do you want to save you changes?
Do you want to save your changes?
</div>
<div class="confirm-modal-buttons">

View File

@ -2,7 +2,7 @@
<div class="page-container">
<div class="page-header">
<div class="page-header">
<h1 ng-show="ctrl.isNew">Add data source</h1>
<h1 ng-hide="ctrl.isNew">Edit data source</h1>
@ -22,11 +22,11 @@
</div>
</div>
<div ng-if="ctrl.tabIndex === 0" class="tab-content">
<div ng-if="ctrl.tabIndex === 0" class="tab-content">
<form name="ctrl.editForm" ng-if="ctrl.current">
<div class="gf-form-group">
<div class="gf-form-inline">
<form name="ctrl.editForm" ng-if="ctrl.current">
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form max-width-30">
<span class="gf-form-label width-7">Name</span>
<input class="gf-form-input max-width-23" type="text" ng-model="ctrl.current.name" placeholder="name" required>
@ -39,7 +39,7 @@
<gf-form-switch class="gf-form" label="Default" checked="ctrl.current.isDefault" switch-class="max-width-6"></gf-form-switch>
</div>
<div class="gf-form">
<div class="gf-form max-width-30">
<span class="gf-form-label width-7">Type</span>
<div class="gf-form-select-wrapper max-width-23">
<select class="gf-form-input" ng-model="ctrl.current.type" ng-options="v.id as v.name for v in ctrl.types" ng-change="ctrl.userChangedType()"></select>
@ -61,8 +61,8 @@
<h5 ng-show="!ctrl.testing.done">Testing.... <i class="fa fa-spiner fa-spin"></i></h5>
<div class="alert-{{ctrl.testing.status}} alert" ng-show="ctrl.testing.done">
<div class="alert-icon">
<i class="fa fa-exclamation-triangle" ng-show="ctrl.testing.status === 'error'"></i>
<i class="fa fa-check" ng-show="ctrl.testing.status !== 'error'"></i>
<i class="fa fa-exclamation-triangle" ng-show="ctrl.testing.status === 'error'"></i>
<i class="fa fa-check" ng-show="ctrl.testing.status !== 'error'"></i>
</div>
<div class="alert-body">
<div class="alert-title">{{ctrl.testing.message}}</div>
@ -71,9 +71,9 @@
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-show="ctrl.isNew" ng-click="ctrl.saveChanges()">Add</button>
<button type="submit" class="btn btn-success" ng-show="!ctrl.isNew" ng-click="ctrl.saveChanges()">Save &amp; Test</button>
<button type="submit" class="btn btn-danger" ng-show="!ctrl.isNew" ng-click="ctrl.delete()">
<button type="submit" class="btn btn-success width-6" ng-show="ctrl.isNew" ng-click="ctrl.saveChanges()">Add</button>
<button type="submit" class="btn btn-success width-8" ng-show="!ctrl.isNew" ng-click="ctrl.saveChanges()">Save &amp; Test</button>
<button type="submit" class="btn btn-danger width-6" ng-show="!ctrl.isNew" ng-click="ctrl.delete()">
Delete
</button>
<a class="btn btn-link" href="datasources">Cancel</a>

View File

@ -1,23 +1,23 @@
<div class="gf-form-group">
<h3 class="page-heading">Http settings</h3>
<h3 class="page-heading">HTTP settings</h3>
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form max-width-30">
<span class="gf-form-label width-7">Url</span>
<span class="gf-form-label width-7">URL</span>
<input class="gf-form-input" type="text"
ng-model='current.url' placeholder="{{suggestUrl}}"
bs-typeahead="getSuggestUrls" min-length="0"
ng-pattern="/^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/" required></input>
<info-popover mode="right-absolute">
<p>Specify a complete HTTP url (for example http://your_server:8080)</p>
<p>Specify a complete HTTP URL (for example http://your_server:8080)</p>
<span ng-show="current.access === 'direct'">
Your access method is <em>Direct</em>, this means the url
Your access method is <em>Direct</em>, this means the URL
needs to be accessible from the browser.
</span>
<span ng-show="current.access === 'proxy'">
Your access method is currently <em>Proxy</em>, this means the url
Your access method is currently <em>Proxy</em>, this means the URL
needs to be accessible from the grafana backend.
</span>
</info-popover>
@ -30,7 +30,7 @@
<div class="gf-form-select-wrapper gf-form-select-wrapper--has-help-icon max-width-24">
<select class="gf-form-input" ng-model="current.access" ng-options="f for f in ['direct', 'proxy']"></select>
<info-popover mode="right-absolute">
Direct = url is used directly from browser<br>
Direct = URL is used directly from browser<br>
Proxy = Grafana backend will proxy the request
</info-popover>
</div>
@ -38,27 +38,21 @@
</div>
</div>
<h3 class="page-heading">Http Auth</h3>
<h3 class="page-heading">HTTP Auth</h3>
<div class="gf-form-group">
<div class="gf-form-inline">
<gf-form-switch class="gf-form" label="Basic Auth" checked="current.basicAuth" label-class="width-8" switch-class="max-width-6"></gf-form-switch>
<gf-form-switch class="gf-form" label="With Credentials" tooltip="Whether credentials such as cookies or auth headers should be sent with cross-site requests." checked="current.withCredentials" label-class="width-11" switch-class="max-width-6"></gf-form-switch>
</div>
<div class="gf-form-inline">
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="TLS Client Auth" label-class="width-8" checked="current.jsonData.tlsAuth" switch-class="max-width-6"></gf-form-switch>
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="With CA Cert" tooltip="Needed for verifing self-signed TLS Certs" checked="current.jsonData.tlsAuthWithCACert" label-class="width-11" switch-class="max-width-6"></gf-form-switch>
</div>
</div>
<div class="gf-form-inline">
<gf-form-switch class="gf-form"
label="Basic Auth"
checked="current.basicAuth" label-class="width-8" switch-class="max-width-6">
</gf-form-switch>
<gf-form-switch class="gf-form"
label="With Credentials" tooltip="Whether credentials such as cookies or auth headers should be sent with cross-site requests."
checked="current.withCredentials" label-class="width-11" switch-class="max-width-6">
</gf-form-switch>
</div>
<div class="gf-form-inline">
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'"
label="TLS Client Auth" label-class="width-8"
checked="current.jsonData.tlsAuth" switch-class="max-width-6">
</gf-form-switch>
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'"
label="With CA Cert" tooltip="Optional. Needed for self-signed TLS Certs."
checked="current.jsonData.tlsAuthWithCACert" label-class="width-11" switch-class="max-width-6">
</gf-form-switch>
<div class="gf-form-inline">
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="Skip TLS Verification (Insecure)" label-class="width-16" checked="current.jsonData.tlsSkipVerify" switch-class="max-width-6"></gf-form-switch>
</div>
</div>
</div>
@ -79,7 +73,7 @@
</div>
</div>
<div class="gf-form-group" ng-if="current.jsonData.tlsAuth && current.access=='proxy'">
<div class="gf-form-group" ng-if="(current.jsonData.tlsAuth || current.jsonData.tlsAuthWithCACert) && current.access=='proxy'">
<div class="gf-form">
<h6>TLS Auth Details</h6>
<info-popover mode="header">TLS Certs are encrypted and stored in the Grafana database.</info-popover>
@ -90,7 +84,7 @@
<label class="gf-form-label width-7">CA Cert</label>
</div>
<div class="gf-form gf-form--grow" ng-if="!current.secureJsonFields.tlsCACert">
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsCACert" placeholder="Begins with -----BEGIN CERTIFICATE-----. The CA Certificate is necessary if you are using self-signed certificates."></textarea>
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsCACert" placeholder="Begins with -----BEGIN CERTIFICATE-----"></textarea>
</div>
<div class="gf-form" ng-if="current.secureJsonFields.tlsCACert">
@ -100,29 +94,31 @@
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form gf-form--v-stretch">
<label class="gf-form-label width-7">Client Cert</label>
<div ng-if="current.jsonData.tlsAuth">
<div class="gf-form-inline">
<div class="gf-form gf-form--v-stretch">
<label class="gf-form-label width-7">Client Cert</label>
</div>
<div class="gf-form gf-form--grow" ng-if="!current.secureJsonFields.tlsClientCert">
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsClientCert" placeholder="Begins with -----BEGIN CERTIFICATE-----" required></textarea>
</div>
<div class="gf-form" ng-if="current.secureJsonFields.tlsClientCert">
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="current.secureJsonFields.tlsClientCert = false">reset</a>
</div>
</div>
<div class="gf-form gf-form--grow" ng-if="!current.secureJsonFields.tlsClientCert">
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsClientCert" placeholder="Begins with -----BEGIN CERTIFICATE-----" required></textarea>
</div>
<div class="gf-form" ng-if="current.secureJsonFields.tlsClientCert">
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="current.secureJsonFields.tlsClientCert = false">reset</a>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form gf-form--v-stretch">
<label class="gf-form-label width-7">Client Key</label>
</div>
<div class="gf-form gf-form--grow" ng-if="!current.secureJsonFields.tlsClientKey">
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsClientKey" placeholder="Begins with -----BEGIN RSA PRIVATE KEY-----" required></textarea>
</div>
<div class="gf-form" ng-if="current.secureJsonFields.tlsClientKey">
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="current.secureJsonFields.tlsClientKey = false">reset</a>
<div class="gf-form-inline">
<div class="gf-form gf-form--v-stretch">
<label class="gf-form-label width-7">Client Key</label>
</div>
<div class="gf-form gf-form--grow" ng-if="!current.secureJsonFields.tlsClientKey">
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsClientKey" placeholder="Begins with -----BEGIN RSA PRIVATE KEY-----" required></textarea>
</div>
<div class="gf-form" ng-if="current.secureJsonFields.tlsClientKey">
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="current.secureJsonFields.tlsClientKey = false">reset</a>
</div>
</div>
</div>
</div>

View File

@ -42,8 +42,8 @@
</div>
<div>
<h3>Sorry for the inconvenience</h3>
<p>Please go back to your <a href="#" class="error-link">home dashboard</a> and try again.</p>
<p>If the error persists, seek help on the <a href="#" class="error-link">community site</a>.</p>
<p>Please go back to your <a href="{{appSubUrl}}/" class="error-link">home dashboard</a> and try again.</p>
<p>If the error persists, seek help on the <a href="https://community.grafana.com" target="_blank" class="error-link">community site</a>.</p>
</div>
</div>
</div>

View File

@ -2,12 +2,26 @@
export class GraphiteConfigCtrl {
static templateUrl = 'public/app/plugins/datasource/graphite/partials/config.html';
datasourceSrv: any;
current: any;
/** @ngInject */
constructor($scope) {
constructor($scope, datasourceSrv) {
this.datasourceSrv = datasourceSrv;
this.current.jsonData = this.current.jsonData || {};
this.current.jsonData.graphiteVersion = this.current.jsonData.graphiteVersion || '0.9';
this.autoDetectGraphiteVersion();
}
autoDetectGraphiteVersion() {
this.datasourceSrv.loadDatasource(this.current.name)
.then((ds) => {
return ds.getVersion();
}).then((version) => {
this.graphiteVersions.push({name: version, value: version});
this.current.jsonData.graphiteVersion = version;
});
}
graphiteVersions = [

View File

@ -2,6 +2,7 @@
import _ from 'lodash';
import * as dateMath from 'app/core/utils/datemath';
import {isVersionGtOrEq, SemVersion} from 'app/core/utils/version';
/** @ngInject */
export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv) {
@ -9,6 +10,7 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
this.url = instanceSettings.url;
this.name = instanceSettings.name;
this.graphiteVersion = instanceSettings.jsonData.graphiteVersion || '0.9';
this.supportsTags = supportsTags(this.graphiteVersion);
this.cacheTimeout = instanceSettings.cacheTimeout;
this.withCredentials = instanceSettings.withCredentials;
this.render_method = instanceSettings.render_method || 'POST';
@ -245,7 +247,7 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
this.getTagValues = function(tag, optionalOptions) {
let options = optionalOptions || {};
let httpOptions: any = {
let httpOptions: any = {
method: 'GET',
url: '/tags/' + tag,
// for cancellations
@ -271,6 +273,72 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
});
};
this.getTagsAutoComplete = (expression, tagPrefix) => {
let httpOptions: any = {
method: 'GET',
url: '/tags/autoComplete/tags',
params: {
expr: expression
}
};
if (tagPrefix) {
httpOptions.params.tagPrefix = tagPrefix;
}
return this.doGraphiteRequest(httpOptions).then(results => {
if (results.data) {
return _.map(results.data, (tag) => {
return { text: tag };
});
} else {
return [];
}
});
};
this.getTagValuesAutoComplete = (expression, tag, valuePrefix) => {
let httpOptions: any = {
method: 'GET',
url: '/tags/autoComplete/values',
params: {
expr: expression,
tag: tag
}
};
if (valuePrefix) {
httpOptions.params.valuePrefix = valuePrefix;
}
return this.doGraphiteRequest(httpOptions).then(results => {
if (results.data) {
return _.map(results.data, (value) => {
return { text: value };
});
} else {
return [];
}
});
};
this.getVersion = function() {
let httpOptions = {
method: 'GET',
url: '/version/_', // Prevent last / trimming
};
return this.doGraphiteRequest(httpOptions).then(results => {
if (results.data) {
let semver = new SemVersion(results.data);
return semver.isValid() ? results.data : '';
}
return '';
}).catch(() => {
return '';
});
};
this.testDatasource = function() {
return this.metricFindQuery('*').then(function () {
return { status: "success", message: "Data source is working"};
@ -357,3 +425,7 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
return clean_options;
};
}
function supportsTags(version: string): boolean {
return isVersionGtOrEq(version, '1.1');
}

View File

@ -1,8 +1,9 @@
define([
'lodash',
'jquery'
'jquery',
'app/core/utils/version'
],
function (_, $) {
function (_, $, version) {
'use strict';
var index = [];
@ -944,13 +945,7 @@ function (_, $) {
};
function isVersionRelatedFunction(func, graphiteVersion) {
return isVersionGreaterOrEqual(graphiteVersion, func.version) || !func.version;
}
function isVersionGreaterOrEqual(a, b) {
var a_num = Number(a);
var b_num = Number(b);
return a_num >= b_num;
return version.isVersionGtOrEq(graphiteVersion, func.version) || !func.version;
}
return {

View File

@ -0,0 +1,283 @@
import _ from 'lodash';
import gfunc from './gfunc';
import {Parser} from './parser';
export default class GraphiteQuery {
target: any;
functions: any[];
segments: any[];
tags: any[];
error: any;
seriesByTagUsed: boolean;
checkOtherSegmentsIndex: number;
removeTagValue: string;
templateSrv: any;
scopedVars: any;
/** @ngInject */
constructor(target, templateSrv?, scopedVars?) {
this.target = target;
this.parseTarget();
this.removeTagValue = '-- remove tag --';
}
parseTarget() {
this.functions = [];
this.segments = [];
this.error = null;
if (this.target.textEditor) {
return;
}
var parser = new Parser(this.target.target);
var astNode = parser.getAst();
if (astNode === null) {
this.checkOtherSegmentsIndex = 0;
return;
}
if (astNode.type === 'error') {
this.error = astNode.message + " at position: " + astNode.pos;
this.target.textEditor = true;
return;
}
try {
this.parseTargetRecursive(astNode, null, 0);
} catch (err) {
console.log('error parsing target:', err.message);
this.error = err.message;
this.target.textEditor = true;
}
this.checkOtherSegmentsIndex = this.segments.length - 1;
this.checkForSeriesByTag();
}
checkForSeriesByTag() {
let seriesByTagFunc = _.find(this.functions, (func) => func.def.name === 'seriesByTag');
if (seriesByTagFunc) {
this.seriesByTagUsed = true;
seriesByTagFunc.hidden = true;
let tags = this.splitSeriesByTagParams(seriesByTagFunc);
this.tags = tags;
}
}
getSegmentPathUpTo(index) {
var arr = this.segments.slice(0, index);
return _.reduce(arr, function(result, segment) {
return result ? (result + "." + segment.value) : segment.value;
}, "");
}
parseTargetRecursive(astNode, func, index) {
if (astNode === null) {
return null;
}
switch (astNode.type) {
case 'function':
var innerFunc = gfunc.createFuncInstance(astNode.name, { withDefaultParams: false });
_.each(astNode.params, (param, index) => {
this.parseTargetRecursive(param, innerFunc, index);
});
innerFunc.updateText();
this.functions.push(innerFunc);
break;
case 'series-ref':
this.addFunctionParameter(func, astNode.value, index, this.segments.length > 0);
break;
case 'bool':
case 'string':
case 'number':
if ((index-1) >= func.def.params.length) {
throw { message: 'invalid number of parameters to method ' + func.def.name };
}
var shiftBack = this.isShiftParamsBack(func);
this.addFunctionParameter(func, astNode.value, index, shiftBack);
break;
case 'metric':
if (this.segments.length > 0) {
if (astNode.segments.length !== 1) {
throw { message: 'Multiple metric params not supported, use text editor.' };
}
this.addFunctionParameter(func, astNode.segments[0].value, index, true);
break;
}
this.segments = astNode.segments;
}
}
isShiftParamsBack(func) {
return func.def.name !== 'seriesByTag';
}
updateSegmentValue(segment, index) {
this.segments[index].value = segment.value;
}
addSelectMetricSegment() {
this.segments.push({value: "select metric"});
}
addFunction(newFunc) {
this.functions.push(newFunc);
this.moveAliasFuncLast();
}
moveAliasFuncLast() {
var aliasFunc = _.find(this.functions, function(func) {
return func.def.name === 'alias' ||
func.def.name === 'aliasByNode' ||
func.def.name === 'aliasByMetric';
});
if (aliasFunc) {
this.functions = _.without(this.functions, aliasFunc);
this.functions.push(aliasFunc);
}
}
addFunctionParameter(func, value, index, shiftBack) {
if (shiftBack) {
index = Math.max(index - 1, 0);
}
func.params[index] = value;
}
removeFunction(func) {
this.functions = _.without(this.functions, func);
}
updateModelTarget(targets) {
// render query
if (!this.target.textEditor) {
var metricPath = this.getSegmentPathUpTo(this.segments.length);
this.target.target = _.reduce(this.functions, wrapFunction, metricPath);
}
this.updateRenderedTarget(this.target, targets);
// loop through other queries and update targetFull as needed
for (const target of targets || []) {
if (target.refId !== this.target.refId) {
this.updateRenderedTarget(target, targets);
}
}
}
updateRenderedTarget(target, targets) {
// render nested query
var targetsByRefId = _.keyBy(targets, 'refId');
// no references to self
delete targetsByRefId[target.refId];
var nestedSeriesRefRegex = /\#([A-Z])/g;
var targetWithNestedQueries = target.target;
// Keep interpolating until there are no query references
// The reason for the loop is that the referenced query might contain another reference to another query
while (targetWithNestedQueries.match(nestedSeriesRefRegex)) {
var updated = targetWithNestedQueries.replace(nestedSeriesRefRegex, (match, g1) => {
var t = targetsByRefId[g1];
if (!t) {
return match;
}
// no circular references
delete targetsByRefId[g1];
return t.target;
});
if (updated === targetWithNestedQueries) {
break;
}
targetWithNestedQueries = updated;
}
delete target.targetFull;
if (target.target !== targetWithNestedQueries) {
target.targetFull = targetWithNestedQueries;
}
}
splitSeriesByTagParams(func) {
const tagPattern = /([^\!=~]+)([\!=~]+)([^\!=~]+)/;
return _.flatten(_.map(func.params, (param: string) => {
let matches = tagPattern.exec(param);
if (matches) {
let tag = matches.slice(1);
if (tag.length === 3) {
return {
key: tag[0],
operator: tag[1],
value: tag[2]
};
}
}
return [];
}));
}
getSeriesByTagFuncIndex() {
return _.findIndex(this.functions, (func) => func.def.name === 'seriesByTag');
}
getSeriesByTagFunc() {
let seriesByTagFuncIndex = this.getSeriesByTagFuncIndex();
if (seriesByTagFuncIndex >= 0) {
return this.functions[seriesByTagFuncIndex];
} else {
return undefined;
}
}
addTag(tag) {
let newTagParam = renderTagString(tag);
this.getSeriesByTagFunc().params.push(newTagParam);
this.tags.push(tag);
}
removeTag(index) {
this.getSeriesByTagFunc().params.splice(index, 1);
this.tags.splice(index, 1);
}
updateTag(tag, tagIndex) {
this.error = null;
if (tag.key === this.removeTagValue) {
this.removeTag(tagIndex);
return;
}
let newTagParam = renderTagString(tag);
this.getSeriesByTagFunc().params[tagIndex] = newTagParam;
this.tags[tagIndex] = tag;
}
renderTagExpressions(excludeIndex = -1) {
return _.compact(_.map(this.tags, (tagExpr, index) => {
// Don't render tag that we want to lookup
if (index !== excludeIndex) {
return tagExpr.key + tagExpr.operator + tagExpr.value;
}
}));
}
}
function wrapFunction(target, func) {
return func.render(target);
}
function renderTagString(tag) {
return tag.key + tag.operator + tag.value;
}

View File

@ -1,18 +1,18 @@
<query-editor-row query-ctrl="ctrl" has-text-edit-mode="true">
<div class="gf-form" ng-show="ctrl.target.textEditor">
<input type="text" class="gf-form-input" ng-model="ctrl.target.target" spellcheck="false" ng-blur="ctrl.targetTextChanged()"></input>
</div>
<div class="gf-form" ng-show="ctrl.target.textEditor">
<input type="text" class="gf-form-input" ng-model="ctrl.target.target" spellcheck="false" ng-blur="ctrl.targetTextChanged()"></input>
</div>
<div ng-hide="ctrl.target.textEditor">
<div class="gf-form-inline">
<div class="gf-form" ng-if="ctrl.seriesByTagUsed">
<label class="gf-form-label query-keyword">seriesByTag</label>
<div class="gf-form-inline" ng-if="ctrl.queryModel.seriesByTagUsed">
<div class="gf-form">
<label class="gf-form-label width-6 query-keyword">Tags</label>
</div>
<div ng-repeat="tag in ctrl.tags" class="gf-form">
<div ng-repeat="tag in ctrl.queryModel.tags" class="gf-form">
<gf-form-dropdown model="tag.key" lookup-text="false" allow-custom="false" label-mode="true" css-class="query-segment-key"
get-options="ctrl.getTags()"
get-options="ctrl.getTags($index, $query)"
on-change="ctrl.tagChanged(tag, $index)">
</gf-form-dropdown>
<gf-form-dropdown model="tag.operator" lookup-text="false" allow-custom="false" label-mode="true" css-class="query-segment-operator"
@ -20,24 +20,33 @@
on-change="ctrl.tagChanged(tag, $index)">
</gf-form-dropdown>
<gf-form-dropdown model="tag.value" lookup-text="false" allow-custom="false" label-mode="true" css-class="query-segment-value"
get-options="ctrl.getTagValues(tag)"
get-options="ctrl.getTagValues(tag, $index, $query)"
on-change="ctrl.tagChanged(tag, $index)">
</gf-form-dropdown>
<label class="gf-form-label query-keyword" ng-if="ctrl.showDelimiter($index)">,</label>
</div>
<div ng-if="ctrl.seriesByTagUsed" ng-repeat="segment in ctrl.addTagSegments" role="menuitem" class="gf-form">
<div ng-if="ctrl.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.addTagSegments" role="menuitem" class="gf-form">
<metric-segment segment="segment"
get-options="ctrl.getTagsAsSegments()"
on-change="ctrl.addNewTag(segment)">
</metric-segment>
</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">
<div class="gf-form" ng-if="ctrl.queryModel.seriesByTagUsed">
<label class="gf-form-label width-6 query-keyword">Functions</label>
</div>
<div ng-repeat="segment in ctrl.segments" role="menuitem" class="gf-form">
<metric-segment segment="segment" get-options="ctrl.getAltSegments($index)" on-change="ctrl.segmentValueChanged(segment, $index)"></metric-segment>
</div>
<div ng-repeat="func in ctrl.functions" class="gf-form">
<span graphite-func-editor class="gf-form-label query-part" ng-hide="ctrl.getSeriesByTagFuncIndex() === $index"></span>
<div ng-repeat="func in ctrl.queryModel.functions" class="gf-form">
<span graphite-func-editor class="gf-form-label query-part" ng-hide="func.hidden"></span>
</div>
<div class="gf-form dropdown">
@ -45,8 +54,8 @@
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
</div>

View File

@ -3,141 +3,70 @@ import './func_editor';
import _ from 'lodash';
import gfunc from './gfunc';
import {Parser} from './parser';
import GraphiteQuery from './graphite_query';
import {QueryCtrl} from 'app/plugins/sdk';
import appEvents from 'app/core/app_events';
const GRAPHITE_TAG_OPERATORS = ['=', '!=', '=~', '!=~'];
const TAG_PREFIX = 'tag: ';
export class GraphiteQueryCtrl extends QueryCtrl {
static templateUrl = 'partials/query.editor.html';
functions: any[];
queryModel: GraphiteQuery;
segments: any[];
addTagSegments: any[];
tags: any[];
seriesByTagUsed: boolean;
removeTagValue: string;
supportsTags: boolean;
/** @ngInject **/
constructor($scope, $injector, private uiSegmentSrv, private templateSrv) {
super($scope, $injector);
this.supportsTags = this.datasource.supportsTags;
if (this.target) {
this.target.target = this.target.target || '';
this.parseTarget();
this.queryModel = new GraphiteQuery(this.target, templateSrv);
this.buildSegments();
}
this.removeTagValue = '-- remove tag --';
}
parseTarget() {
this.queryModel.parseTarget();
this.buildSegments();
}
toggleEditorMode() {
this.target.textEditor = !this.target.textEditor;
this.parseTarget();
}
parseTarget() {
this.functions = [];
this.segments = [];
this.error = null;
buildSegments() {
this.segments = _.map(this.queryModel.segments, segment => {
return this.uiSegmentSrv.newSegment(segment);
});
let checkOtherSegmentsIndex = this.queryModel.checkOtherSegmentsIndex || 0;
this.checkOtherSegments(checkOtherSegmentsIndex);
if (this.target.textEditor) {
return;
}
var parser = new Parser(this.target.target);
var astNode = parser.getAst();
if (astNode === null) {
this.checkOtherSegments(0);
return;
}
if (astNode.type === 'error') {
this.error = astNode.message + " at position: " + astNode.pos;
this.target.textEditor = true;
return;
}
try {
this.parseTargetRecursive(astNode, null, 0);
} catch (err) {
console.log('error parsing target:', err.message);
this.error = err.message;
this.target.textEditor = true;
}
this.checkOtherSegments(this.segments.length - 1);
this.checkForSeriesByTag();
}
addFunctionParameter(func, value, index, shiftBack) {
if (shiftBack) {
index = Math.max(index - 1, 0);
}
func.params[index] = value;
}
parseTargetRecursive(astNode, func, index) {
if (astNode === null) {
return null;
}
switch (astNode.type) {
case 'function':
var innerFunc = gfunc.createFuncInstance(astNode.name, { withDefaultParams: false });
_.each(astNode.params, (param, index) => {
this.parseTargetRecursive(param, innerFunc, index);
});
innerFunc.updateText();
this.functions.push(innerFunc);
break;
case 'series-ref':
this.addFunctionParameter(func, astNode.value, index, this.segments.length > 0);
break;
case 'bool':
case 'string':
case 'number':
if ((index-1) >= func.def.params.length) {
throw { message: 'invalid number of parameters to method ' + func.def.name };
}
var shiftBack = this.isShiftParamsBack(func);
this.addFunctionParameter(func, astNode.value, index, shiftBack);
break;
case 'metric':
if (this.segments.length > 0) {
if (astNode.segments.length !== 1) {
throw { message: 'Multiple metric params not supported, use text editor.' };
}
this.addFunctionParameter(func, astNode.segments[0].value, index, true);
break;
}
this.segments = _.map(astNode.segments, segment => {
return this.uiSegmentSrv.newSegment(segment);
});
if (this.queryModel.seriesByTagUsed) {
this.fixTagSegments();
}
}
isShiftParamsBack(func) {
return func.def.name !== 'seriesByTag';
}
getSegmentPathUpTo(index) {
var arr = this.segments.slice(0, index);
return _.reduce(arr, function(result, segment) {
return result ? (result + "." + segment.value) : segment.value;
}, "");
addSelectMetricSegment() {
this.queryModel.addSelectMetricSegment();
this.segments.push(this.uiSegmentSrv.newSelectMetric());
}
checkOtherSegments(fromIndex) {
if (fromIndex === 0) {
this.segments.push(this.uiSegmentSrv.newSelectMetric());
this.addSelectMetricSegment();
return;
}
var path = this.getSegmentPathUpTo(fromIndex + 1);
var path = this.queryModel.getSegmentPathUpTo(fromIndex + 1);
if (path === "") {
return Promise.resolve();
}
@ -145,12 +74,13 @@ export class GraphiteQueryCtrl extends QueryCtrl {
return this.datasource.metricFindQuery(path).then(segments => {
if (segments.length === 0) {
if (path !== '') {
this.queryModel.segments = this.queryModel.segments.splice(0, fromIndex);
this.segments = this.segments.splice(0, fromIndex);
this.segments.push(this.uiSegmentSrv.newSelectMetric());
this.addSelectMetricSegment();
}
} else if (segments[0].expandable) {
if (this.segments.length === fromIndex) {
this.segments.push(this.uiSegmentSrv.newSelectMetric());
this.addSelectMetricSegment();
} else {
return this.checkOtherSegments(fromIndex + 1);
}
@ -166,12 +96,8 @@ export class GraphiteQueryCtrl extends QueryCtrl {
});
}
wrapFunction(target, func) {
return func.render(target);
}
getAltSegments(index) {
var query = index === 0 ? '*' : this.getSegmentPathUpTo(index) + '.*';
var query = index === 0 ? '*' : this.queryModel.getSegmentPathUpTo(index) + '.*';
var options = {range: this.panelCtrl.range, requestId: "get-alt-segments"};
return this.datasource.metricFindQuery(query, options).then(segments => {
@ -192,17 +118,44 @@ export class GraphiteQueryCtrl extends QueryCtrl {
// add wildcard option
altSegments.unshift(this.uiSegmentSrv.newSegment('*'));
return altSegments;
if (this.supportsTags && index === 0) {
this.removeTaggedEntry(altSegments);
return this.addAltTagSegments(index, altSegments);
} else {
return altSegments;
}
}).catch(err => {
return [];
});
}
addAltTagSegments(index, altSegments) {
return this.getTagsAsSegments().then((tagSegments) => {
tagSegments = _.map(tagSegments, (segment) => {
segment.value = TAG_PREFIX + segment.value;
return segment;
});
return altSegments.concat(...tagSegments);
});
}
removeTaggedEntry(altSegments) {
altSegments = _.remove(altSegments, (s) => s.value === '_tagged');
}
segmentValueChanged(segment, segmentIndex) {
this.error = null;
this.queryModel.updateSegmentValue(segment, segmentIndex);
if (this.functions.length > 0 && this.functions[0].def.fake) {
this.functions = [];
if (this.queryModel.functions.length > 0 && this.queryModel.functions[0].def.fake) {
this.queryModel.functions = [];
}
if (segment.type === 'tag') {
let tag = removeTagPrefix(segment.value);
this.addSeriesByTagFunc(tag);
return;
}
if (segment.expandable) {
@ -211,81 +164,41 @@ export class GraphiteQueryCtrl extends QueryCtrl {
this.targetChanged();
});
} else {
this.segments = this.segments.splice(0, segmentIndex + 1);
this.spliceSegments(segmentIndex + 1);
}
this.setSegmentFocus(segmentIndex + 1);
this.targetChanged();
}
spliceSegments(index) {
this.segments = this.segments.splice(0, index);
this.queryModel.segments = this.queryModel.segments.splice(0, index);
}
emptySegments() {
this.queryModel.segments = [];
this.segments = [];
}
targetTextChanged() {
this.updateModelTarget();
this.refresh();
}
updateModelTarget() {
// render query
if (!this.target.textEditor) {
var metricPath = this.getSegmentPathUpTo(this.segments.length);
this.target.target = _.reduce(this.functions, this.wrapFunction, metricPath);
}
this.updateRenderedTarget(this.target);
// loop through other queries and update targetFull as needed
for (const target of this.panelCtrl.panel.targets || []) {
if (target.refId !== this.target.refId) {
this.updateRenderedTarget(target);
}
}
}
updateRenderedTarget(target) {
// render nested query
var targetsByRefId = _.keyBy(this.panelCtrl.panel.targets, 'refId');
// no references to self
delete targetsByRefId[target.refId];
var nestedSeriesRefRegex = /\#([A-Z])/g;
var targetWithNestedQueries = target.target;
// Keep interpolating until there are no query references
// The reason for the loop is that the referenced query might contain another reference to another query
while (targetWithNestedQueries.match(nestedSeriesRefRegex)) {
var updated = targetWithNestedQueries.replace(nestedSeriesRefRegex, (match, g1) => {
var t = targetsByRefId[g1];
if (!t) {
return match;
}
// no circular references
delete targetsByRefId[g1];
return t.target;
});
if (updated === targetWithNestedQueries) {
break;
}
targetWithNestedQueries = updated;
}
delete target.targetFull;
if (target.target !== targetWithNestedQueries) {
target.targetFull = targetWithNestedQueries;
}
this.queryModel.updateModelTarget(this.panelCtrl.panel.targets);
}
targetChanged() {
if (this.error) {
if (this.queryModel.error) {
return;
}
var oldTarget = this.target.target;
var oldTarget = this.queryModel.target.target;
this.updateModelTarget();
if (this.target.target !== oldTarget) {
if (this.queryModel.target !== oldTarget) {
var lastSegment = this.segments.length > 0 ? this.segments[this.segments.length - 1] : {};
if (lastSegment.value !== 'select metric') {
this.panelCtrl.refresh();
@ -293,21 +206,14 @@ export class GraphiteQueryCtrl extends QueryCtrl {
}
}
removeFunction(func) {
this.functions = _.without(this.functions, func);
this.targetChanged();
}
addFunction(funcDef) {
var newFunc = gfunc.createFuncInstance(funcDef, { withDefaultParams: true });
newFunc.added = true;
this.functions.push(newFunc);
this.moveAliasFuncLast();
this.queryModel.addFunction(newFunc);
this.smartlyHandleNewAliasByNode(newFunc);
if (this.segments.length === 1 && this.segments[0].fake) {
this.segments = [];
this.emptySegments();
}
if (!newFunc.params.length && newFunc.added) {
@ -319,17 +225,22 @@ export class GraphiteQueryCtrl extends QueryCtrl {
}
}
moveAliasFuncLast() {
var aliasFunc = _.find(this.functions, function(func) {
return func.def.name === 'alias' ||
func.def.name === 'aliasByNode' ||
func.def.name === 'aliasByMetric';
});
removeFunction(func) {
this.queryModel.removeFunction(func);
this.targetChanged();
}
if (aliasFunc) {
this.functions = _.without(this.functions, aliasFunc);
this.functions.push(aliasFunc);
}
addSeriesByTagFunc(tag) {
let funcDef = gfunc.getFuncDef('seriesByTag');
let newFunc = gfunc.createFuncInstance(funcDef, { withDefaultParams: false });
let tagParam = `${tag}=select tag value`;
newFunc.params = [tagParam];
this.queryModel.addFunction(newFunc);
newFunc.added = true;
this.emptySegments();
this.targetChanged();
this.parseTarget();
}
smartlyHandleNewAliasByNode(func) {
@ -338,7 +249,7 @@ export class GraphiteQueryCtrl extends QueryCtrl {
}
for (var i = 0; i < this.segments.length; i++) {
if (this.segments[i].value.indexOf('*') >= 0) {
if (this.segments[i].value.indexOf('*') >= 0) {
func.params[0] = i;
func.added = false;
this.targetChanged();
@ -347,39 +258,7 @@ export class GraphiteQueryCtrl extends QueryCtrl {
}
}
//////////////////////////////////
// Graphite seriesByTag support //
//////////////////////////////////
checkForSeriesByTag() {
let seriesByTagFunc = _.find(this.functions, (func) => func.def.name === 'seriesByTag');
if (seriesByTagFunc) {
this.seriesByTagUsed = true;
let tags = this.splitSeriesByTagParams(seriesByTagFunc);
this.tags = tags;
this.fixTagSegments();
}
}
splitSeriesByTagParams(func) {
const tagPattern = /([^\!=~]+)([\!=~]+)([^\!=~]+)/;
return _.flatten(_.map(func.params, (param: string) => {
let matches = tagPattern.exec(param);
if (matches) {
let tag = matches.slice(1);
if (tag.length === 3) {
return {
key: tag[0],
operator: tag[1],
value: tag[2]
}
}
}
return [];
}));
}
getTags() {
getAllTags() {
return this.datasource.getTags().then((values) => {
let altTags = _.map(values, 'text');
altTags.splice(0, 0, this.removeTagValue);
@ -387,10 +266,22 @@ export class GraphiteQueryCtrl extends QueryCtrl {
});
}
getTags(index, tagPrefix) {
let tagExpressions = this.queryModel.renderTagExpressions(index);
return this.datasource.getTagsAutoComplete(tagExpressions, tagPrefix)
.then((values) => {
let altTags = _.map(values, 'text');
altTags.splice(0, 0, this.removeTagValue);
return mapToDropdownOptions(altTags);
});
}
getTagsAsSegments() {
return this.datasource.getTags().then((values) => {
let tagExpressions = this.queryModel.renderTagExpressions();
return this.datasource.getTagsAutoComplete(tagExpressions)
.then((values) => {
return _.map(values, (val) => {
return this.uiSegmentSrv.newSegment(val.text);
return this.uiSegmentSrv.newSegment({value: val.text, type: 'tag', expandable: false});
});
});
}
@ -399,7 +290,7 @@ export class GraphiteQueryCtrl extends QueryCtrl {
return mapToDropdownOptions(GRAPHITE_TAG_OPERATORS);
}
getTagValues(tag) {
getAllTagValues(tag) {
let tagKey = tag.key;
return this.datasource.getTagValues(tagKey).then((values) => {
let altValues = _.map(values, 'text');
@ -407,46 +298,30 @@ export class GraphiteQueryCtrl extends QueryCtrl {
});
}
getTagValues(tag, index, valuePrefix) {
let tagExpressions = this.queryModel.renderTagExpressions(index);
let tagKey = tag.key;
return this.datasource.getTagValuesAutoComplete(tagExpressions, tagKey, valuePrefix).then((values) => {
let altValues = _.map(values, 'text');
return mapToDropdownOptions(altValues);
});
}
tagChanged(tag, tagIndex) {
this.error = null;
if (tag.key === this.removeTagValue) {
this.removeTag(tagIndex);
return;
}
let newTagParam = renderTagString(tag);
this.getSeriesByTagFunc().params[tagIndex] = newTagParam;
this.tags[tagIndex] = tag;
this.queryModel.updateTag(tag, tagIndex);
this.targetChanged();
}
getSeriesByTagFuncIndex() {
return _.findIndex(this.functions, (func) => func.def.name === 'seriesByTag');
}
getSeriesByTagFunc() {
let seriesByTagFuncIndex = this.getSeriesByTagFuncIndex();
if (seriesByTagFuncIndex >= 0) {
return this.functions[seriesByTagFuncIndex];
} else {
return undefined;
}
}
addNewTag(segment) {
let newTagKey = segment.value;
let newTag = {key: newTagKey, operator: '=', value: 'select tag value'};
let newTagParam = renderTagString(newTag);
this.getSeriesByTagFunc().params.push(newTagParam);
this.tags.push(newTag);
this.queryModel.addTag(newTag);
this.targetChanged();
this.fixTagSegments();
}
removeTag(index) {
this.getSeriesByTagFunc().params.splice(index, 1);
this.tags.splice(index, 1);
this.queryModel.removeTag(index);
this.targetChanged();
}
@ -456,16 +331,16 @@ export class GraphiteQueryCtrl extends QueryCtrl {
}
showDelimiter(index) {
return index !== this.tags.length - 1;
return index !== this.queryModel.tags.length - 1;
}
}
function renderTagString(tag) {
return tag.key + tag.operator + tag.value;
}
function mapToDropdownOptions(results) {
return _.map(results, (value) => {
return {text: value, value: value};
});
}
function removeTagPrefix(value: string): string {
return value.replace(TAG_PREFIX, '');
}

View File

@ -48,7 +48,7 @@ describe('GraphiteQueryCtrl', function() {
});
it('should parse expression and build function model', function() {
expect(ctx.ctrl.functions.length).to.be(2);
expect(ctx.ctrl.queryModel.functions.length).to.be(2);
});
});
@ -61,7 +61,7 @@ describe('GraphiteQueryCtrl', function() {
});
it('should add function with correct node number', function() {
expect(ctx.ctrl.functions[0].params[0]).to.be(2);
expect(ctx.ctrl.queryModel.functions[0].params[0]).to.be(2);
});
it('should update target', function() {
@ -99,7 +99,7 @@ describe('GraphiteQueryCtrl', function() {
});
it('should add both series refs as params', function() {
expect(ctx.ctrl.functions[0].params.length).to.be(2);
expect(ctx.ctrl.queryModel.functions[0].params.length).to.be(2);
});
});
@ -115,7 +115,7 @@ describe('GraphiteQueryCtrl', function() {
});
it('should add function param', function() {
expect(ctx.ctrl.functions[0].params.length).to.be(1);
expect(ctx.ctrl.queryModel.functions[0].params.length).to.be(1);
});
});
@ -131,7 +131,7 @@ describe('GraphiteQueryCtrl', function() {
});
it('should have correct func params', function() {
expect(ctx.ctrl.functions[0].params.length).to.be(1);
expect(ctx.ctrl.queryModel.functions[0].params.length).to.be(1);
});
});
@ -219,11 +219,11 @@ describe('GraphiteQueryCtrl', function() {
});
it('should update functions', function() {
expect(ctx.ctrl.getSeriesByTagFuncIndex()).to.be(0);
expect(ctx.ctrl.queryModel.getSeriesByTagFuncIndex()).to.be(0);
});
it('should update seriesByTagUsed flag', function() {
expect(ctx.ctrl.seriesByTagUsed).to.be(true);
expect(ctx.ctrl.queryModel.seriesByTagUsed).to.be(true);
});
it('should update target', function() {
@ -247,7 +247,7 @@ describe('GraphiteQueryCtrl', function() {
{key: 'tag1', operator: '=', value: 'value1'},
{key: 'tag2', operator: '!=~', value: 'value2'}
];
expect(ctx.ctrl.tags).to.eql(expected);
expect(ctx.ctrl.queryModel.tags).to.eql(expected);
});
it('should add plus button', function() {
@ -267,7 +267,7 @@ describe('GraphiteQueryCtrl', function() {
const expected = [
{key: 'tag1', operator: '=', value: 'select tag value'}
];
expect(ctx.ctrl.tags).to.eql(expected);
expect(ctx.ctrl.queryModel.tags).to.eql(expected);
});
it('should update target', function() {
@ -289,7 +289,7 @@ describe('GraphiteQueryCtrl', function() {
{key: 'tag1', operator: '=', value: 'new_value'},
{key: 'tag2', operator: '!=~', value: 'value2'}
];
expect(ctx.ctrl.tags).to.eql(expected);
expect(ctx.ctrl.queryModel.tags).to.eql(expected);
});
it('should update target', function() {
@ -310,7 +310,7 @@ describe('GraphiteQueryCtrl', function() {
const expected = [
{key: 'tag2', operator: '!=~', value: 'value2'}
];
expect(ctx.ctrl.tags).to.eql(expected);
expect(ctx.ctrl.queryModel.tags).to.eql(expected);
});
it('should update target', function() {

View File

@ -31,7 +31,7 @@
</div>
<div class="gf-form">
<label class="gf-form-label width-6">Decimals</label>
<input type="number" class="gf-form-input max-width-20" placeholder="auto" bs-tooltip="'Override automatic decimal precision for y-axis'" data-placement="right" ng-model="yaxis.decimals" ng-change="ctrl.render()" ng-model-onblur>
<input type="number" class="gf-form-input max-width-20" placeholder="auto" empty-to-null bs-tooltip="'Override automatic decimal precision for y-axis'" data-placement="right" ng-model="yaxis.decimals" ng-change="ctrl.render()" ng-model-onblur>
</div>
<div class="gf-form">

View File

@ -497,8 +497,8 @@ function graphDirective($rootScope, timeSrv, popoverSrv, contextSrv) {
show: panel.yaxes[0].show,
index: 1,
logBase: panel.yaxes[0].logBase || 1,
min: panel.yaxes[0].min ? _.toNumber(panel.yaxes[0].min) : null,
max: panel.yaxes[0].max ? _.toNumber(panel.yaxes[0].max) : null,
min: parseNumber(panel.yaxes[0].min),
max: parseNumber(panel.yaxes[0].max),
tickDecimals: panel.yaxes[0].decimals
};
@ -510,9 +510,9 @@ function graphDirective($rootScope, timeSrv, popoverSrv, contextSrv) {
secondY.show = panel.yaxes[1].show;
secondY.logBase = panel.yaxes[1].logBase || 1;
secondY.position = 'right';
secondY.min = panel.yaxes[1].min ? _.toNumber(panel.yaxes[1].min) : null;
secondY.max = panel.yaxes[1].max ? _.toNumber(panel.yaxes[1].max) : null;
secondY.tickDecimals = panel.yaxes[1].decimals !== null ? _.toNumber(panel.yaxes[1].decimals): null;
secondY.min = parseNumber(panel.yaxes[1].min);
secondY.max = parseNumber(panel.yaxes[1].max);
secondY.tickDecimals = panel.yaxes[1].decimals;
options.yaxes.push(secondY);
applyLogScale(options.yaxes[1], data);
@ -522,6 +522,14 @@ function graphDirective($rootScope, timeSrv, popoverSrv, contextSrv) {
configureAxisMode(options.yaxes[0], panel.percentage && panel.stack ? "percent" : panel.yaxes[0].format);
}
function parseNumber(value: any) {
if (value === null || typeof value === 'undefined') {
return null;
}
return _.toNumber(value);
}
function applyLogScale(axis, data) {
if (axis.logBase === 1) {
return;

View File

@ -43,7 +43,7 @@
line-height: 1rem;
}
.error-link {color: $yellow;}
.error-link {color: $orange;}
.error-minus {
color: #7eb26d;
@ -57,4 +57,4 @@
line-height: 1rem;
}
.graph-text {margin: 0;}
.graph-text {margin: 0;}

View File

@ -0,0 +1,55 @@
import {describe, beforeEach, it, expect} from 'test/lib/common';
import {SemVersion, isVersionGtOrEq} from 'app/core/utils/version';
describe("SemVersion", () => {
let version = '1.0.0-alpha.1';
describe('parsing', () => {
it('should parse version properly', () => {
let semver = new SemVersion(version);
expect(semver.major).to.be(1);
expect(semver.minor).to.be(0);
expect(semver.patch).to.be(0);
expect(semver.meta).to.be('alpha.1');
});
});
describe('comparing', () => {
beforeEach(() => {
version = '3.4.5';
});
it('should detect greater version properly', () => {
let semver = new SemVersion(version);
let cases = [
{value: '3.4.5', expected: true},
{value: '3.4.4', expected: true},
{value: '3.4.6', expected: false},
{value: '4', expected: false},
{value: '3.5', expected: false},
];
cases.forEach((testCase) => {
expect(semver.isGtOrEq(testCase.value)).to.be(testCase.expected);
});
});
});
describe('isVersionGtOrEq', () => {
it('should compare versions properly (a >= b)', () => {
let cases = [
{values: ['3.4.5', '3.4.5'], expected: true},
{values: ['3.4.5', '3.4.4'] , expected: true},
{values: ['3.4.5', '3.4.6'], expected: false},
{values: ['3.4', '3.4.0'], expected: true},
{values: ['3', '3.0.0'], expected: true},
{values: ['3.1.1-beta1', '3.1'], expected: true},
{values: ['3.4.5', '4'], expected: false},
{values: ['3.4.5', '3.5'], expected: false},
];
cases.forEach((testCase) => {
expect(isVersionGtOrEq(testCase.values[0], testCase.values[1])).to.be(testCase.expected);
});
});
});
});

View File

@ -358,6 +358,8 @@
// Remove icon clicked
self.$container.on('click', '[data-role=remove]', $.proxy(function(event) {
self.remove($(event.target).closest('.tag').data('item'));
// Grafana mod, if tags input used in popover the click event will bubble up and hide popover
event.stopPropagation();
}, self));
// Only add existing value as tags when using strings as tags

View File

@ -2,8 +2,8 @@
"rules": {
"no-string-throw": true,
"no-unused-expression": true,
"no-duplicate-variable": true,
"no-unused-variable": true,
"no-duplicate-variable": true,
"curly": true,
"class-name": true,
"semicolon": [true, "always", "ignore-bound-class-methods"],