Merge branch 'master' into docs-5.1

This commit is contained in:
Marcus Efraimsson
2018-04-11 20:38:09 +02:00
99 changed files with 2123 additions and 1180 deletions

View File

@@ -10,8 +10,11 @@
* **Alerting**: Pausing/un alerts now updates new_state_date [#10942](https://github.com/grafana/grafana/pull/10942) * **Alerting**: Pausing/un alerts now updates new_state_date [#10942](https://github.com/grafana/grafana/pull/10942)
* **Alerting**: Support Pagerduty notification channel using Pagerduty V2 API [#10531](https://github.com/grafana/grafana/issues/10531), thx [@jbaublitz](https://github.com/jbaublitz) * **Alerting**: Support Pagerduty notification channel using Pagerduty V2 API [#10531](https://github.com/grafana/grafana/issues/10531), thx [@jbaublitz](https://github.com/jbaublitz)
* **Templating**: Add comma templating format [#10632](https://github.com/grafana/grafana/issues/10632), thx [@mtanda](https://github.com/mtanda) * **Templating**: Add comma templating format [#10632](https://github.com/grafana/grafana/issues/10632), thx [@mtanda](https://github.com/mtanda)
* **Prometheus**: Show template variable candidate in query editor [#9210](https://github.com/grafana/grafana/issues/9210), thx [@mtanda](https://github.com/mtanda)
* **Prometheus**: Support POST for query and query_range [#9859](https://github.com/grafana/grafana/pull/9859), thx [@mtanda](https://github.com/mtanda) * **Prometheus**: Support POST for query and query_range [#9859](https://github.com/grafana/grafana/pull/9859), thx [@mtanda](https://github.com/mtanda)
* **Alerting**: Add support for retries on alert queries [#5855](https://github.com/grafana/grafana/issues/5855), thx [@Thib17](https://github.com/Thib17) * **Alerting**: Add support for retries on alert queries [#5855](https://github.com/grafana/grafana/issues/5855), thx [@Thib17](https://github.com/Thib17)
* **Table**: Table plugin value mappings [#7119](https://github.com/grafana/grafana/issues/7119), thx [infernix](https://github.com/infernix)
* **IE11**: IE 11 compatibility [#11165](https://github.com/grafana/grafana/issues/11165)
### Minor ### Minor
* **OpsGenie**: Add triggered alerts as description [#11046](https://github.com/grafana/grafana/pull/11046), thx [@llamashoes](https://github.com/llamashoes) * **OpsGenie**: Add triggered alerts as description [#11046](https://github.com/grafana/grafana/pull/11046), thx [@llamashoes](https://github.com/llamashoes)
@@ -22,9 +25,19 @@
* **Dashboards**: Version cleanup fails on old databases with many entries [#11278](https://github.com/grafana/grafana/issues/11278) * **Dashboards**: Version cleanup fails on old databases with many entries [#11278](https://github.com/grafana/grafana/issues/11278)
* **Server**: Adjust permissions of unix socket [#11343](https://github.com/grafana/grafana/pull/11343), thx [@corny](https://github.com/corny) * **Server**: Adjust permissions of unix socket [#11343](https://github.com/grafana/grafana/pull/11343), thx [@corny](https://github.com/corny)
* **Shortcuts**: Add shortcut for duplicate panel [#11102](https://github.com/grafana/grafana/issues/11102) * **Shortcuts**: Add shortcut for duplicate panel [#11102](https://github.com/grafana/grafana/issues/11102)
* **AuthProxy**: Support IPv6 in Auth proxy white list [#11330](https://github.com/grafana/grafana/pull/11330), thx [@corny](https://github.com/corny)
* **SMTP**: Don't connect to STMP server using TLS unless configured. [#7189](https://github.com/grafana/grafana/issues/7189)
* **Prometheus**: Escape backslash in labels correctly. [#10555](https://github.com/grafana/grafana/issues/10555), thx [@roidelapluie](https://github.com/roidelapluie)
* **Variables** Case-insensitive sorting for template values [#11128](https://github.com/grafana/grafana/issues/11128) thx [@cross](https://github.com/cross)
# 5.0.4 (unreleased) # 5.0.4 (2018-03-28)
* **Dashboard** Fixed bug where collapsed panels could not be directly linked to/renderer [#11114](https://github.com/grafana/grafana/issues/11114) & [#11086](https://github.com/grafana/grafana/issues/11086)
* **Docker** Can't start Grafana on Kubernetes 1.7.14, 1.8.9, or 1.9.4 [#140 in grafana-docker repo](https://github.com/grafana/grafana-docker/issues/140) thx [@suquant](https://github.com/suquant)
* **Dashboard** Fixed bug where collapsed panels could not be directly linked to/renderer [#11114](https://github.com/grafana/grafana/issues/11114) & [#11086](https://github.com/grafana/grafana/issues/11086) & [#11296](https://github.com/grafana/grafana/issues/11296)
* **Dashboard** Provisioning dashboard with alert rules should create alerts [#11247](https://github.com/grafana/grafana/issues/11247)
* **Snapshots** For snapshots, the Graph panel renders the legend incorrectly on right hand side [#11318](https://github.com/grafana/grafana/issues/11318)
* **Alerting** Link back to Grafana returns wrong URL if root_path contains sub-path components [#11403](https://github.com/grafana/grafana/issues/11403)
* **Alerting** Incorrect default value for upload images setting for alert notifiers [#11413](https://github.com/grafana/grafana/pull/11413)
# 5.0.3 (2018-03-16) # 5.0.3 (2018-03-16)
* **Mysql**: Mysql panic occurring occasionally upon Grafana dashboard access (a bigger patch than the one in 5.0.2) [#11155](https://github.com/grafana/grafana/issues/11155) * **Mysql**: Mysql panic occurring occasionally upon Grafana dashboard access (a bigger patch than the one in 5.0.2) [#11155](https://github.com/grafana/grafana/issues/11155)

217
Gopkg.lock generated
View File

@@ -27,37 +27,7 @@
[[projects]] [[projects]]
name = "github.com/aws/aws-sdk-go" name = "github.com/aws/aws-sdk-go"
packages = [ packages = ["aws","aws/awserr","aws/awsutil","aws/client","aws/client/metadata","aws/corehandlers","aws/credentials","aws/credentials/ec2rolecreds","aws/credentials/endpointcreds","aws/credentials/stscreds","aws/defaults","aws/ec2metadata","aws/endpoints","aws/request","aws/session","aws/signer/v4","internal/shareddefaults","private/protocol","private/protocol/ec2query","private/protocol/query","private/protocol/query/queryutil","private/protocol/rest","private/protocol/restxml","private/protocol/xml/xmlutil","service/cloudwatch","service/ec2","service/ec2/ec2iface","service/s3","service/sts"]
"aws",
"aws/awserr",
"aws/awsutil",
"aws/client",
"aws/client/metadata",
"aws/corehandlers",
"aws/credentials",
"aws/credentials/ec2rolecreds",
"aws/credentials/endpointcreds",
"aws/credentials/stscreds",
"aws/defaults",
"aws/ec2metadata",
"aws/endpoints",
"aws/request",
"aws/session",
"aws/signer/v4",
"internal/shareddefaults",
"private/protocol",
"private/protocol/ec2query",
"private/protocol/query",
"private/protocol/query/queryutil",
"private/protocol/rest",
"private/protocol/restxml",
"private/protocol/xml/xmlutil",
"service/cloudwatch",
"service/ec2",
"service/ec2/ec2iface",
"service/s3",
"service/sts"
]
revision = "decd990ddc5dcdf2f73309cbcab90d06b996ca28" revision = "decd990ddc5dcdf2f73309cbcab90d06b996ca28"
version = "v1.12.67" version = "v1.12.67"
@@ -105,10 +75,7 @@
[[projects]] [[projects]]
name = "github.com/denisenkom/go-mssqldb" name = "github.com/denisenkom/go-mssqldb"
packages = [ packages = [".","internal/cp"]
".",
"internal/cp"
]
revision = "270bc3860bb94dd3a3ffd047377d746c5e276726" revision = "270bc3860bb94dd3a3ffd047377d746c5e276726"
[[projects]] [[projects]]
@@ -150,12 +117,7 @@
[[projects]] [[projects]]
branch = "master" branch = "master"
name = "github.com/go-macaron/session" name = "github.com/go-macaron/session"
packages = [ packages = [".","memcache","postgres","redis"]
".",
"memcache",
"postgres",
"redis"
]
revision = "b8e286a0dba8f4999042d6b258daf51b31d08938" revision = "b8e286a0dba8f4999042d6b258daf51b31d08938"
[[projects]] [[projects]]
@@ -190,13 +152,7 @@
[[projects]] [[projects]]
branch = "master" branch = "master"
name = "github.com/golang/protobuf" name = "github.com/golang/protobuf"
packages = [ packages = ["proto","ptypes","ptypes/any","ptypes/duration","ptypes/timestamp"]
"proto",
"ptypes",
"ptypes/any",
"ptypes/duration",
"ptypes/timestamp"
]
revision = "c65a0412e71e8b9b3bfd22925720d23c0f054237" revision = "c65a0412e71e8b9b3bfd22925720d23c0f054237"
[[projects]] [[projects]]
@@ -265,10 +221,7 @@
[[projects]] [[projects]]
name = "github.com/klauspost/compress" name = "github.com/klauspost/compress"
packages = [ packages = ["flate","gzip"]
"flate",
"gzip"
]
revision = "6c8db69c4b49dd4df1fff66996cf556176d0b9bf" revision = "6c8db69c4b49dd4df1fff66996cf556176d0b9bf"
version = "v1.2.1" version = "v1.2.1"
@@ -299,10 +252,7 @@
[[projects]] [[projects]]
branch = "master" branch = "master"
name = "github.com/lib/pq" name = "github.com/lib/pq"
packages = [ packages = [".","oid"]
".",
"oid"
]
revision = "61fe37aa2ee24fabcdbe5c4ac1d4ac566f88f345" revision = "61fe37aa2ee24fabcdbe5c4ac1d4ac566f88f345"
[[projects]] [[projects]]
@@ -337,11 +287,7 @@
[[projects]] [[projects]]
name = "github.com/opentracing/opentracing-go" name = "github.com/opentracing/opentracing-go"
packages = [ packages = [".","ext","log"]
".",
"ext",
"log"
]
revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38"
version = "v1.0.2" version = "v1.0.2"
@@ -353,12 +299,7 @@
[[projects]] [[projects]]
name = "github.com/prometheus/client_golang" name = "github.com/prometheus/client_golang"
packages = [ packages = ["api","api/prometheus/v1","prometheus","prometheus/promhttp"]
"api",
"api/prometheus/v1",
"prometheus",
"prometheus/promhttp"
]
revision = "967789050ba94deca04a5e84cce8ad472ce313c1" revision = "967789050ba94deca04a5e84cce8ad472ce313c1"
version = "v0.9.0-pre1" version = "v0.9.0-pre1"
@@ -371,22 +312,13 @@
[[projects]] [[projects]]
branch = "master" branch = "master"
name = "github.com/prometheus/common" name = "github.com/prometheus/common"
packages = [ packages = ["expfmt","internal/bitbucket.org/ww/goautoneg","model"]
"expfmt",
"internal/bitbucket.org/ww/goautoneg",
"model"
]
revision = "89604d197083d4781071d3c65855d24ecfb0a563" revision = "89604d197083d4781071d3c65855d24ecfb0a563"
[[projects]] [[projects]]
branch = "master" branch = "master"
name = "github.com/prometheus/procfs" name = "github.com/prometheus/procfs"
packages = [ packages = [".","internal/util","nfsd","xfs"]
".",
"internal/util",
"nfsd",
"xfs"
]
revision = "85fadb6e89903ef7cca6f6a804474cd5ea85b6e1" revision = "85fadb6e89903ef7cca6f6a804474cd5ea85b6e1"
[[projects]] [[projects]]
@@ -403,21 +335,13 @@
[[projects]] [[projects]]
name = "github.com/smartystreets/assertions" name = "github.com/smartystreets/assertions"
packages = [ packages = [".","internal/go-render/render","internal/oglematchers"]
".",
"internal/go-render/render",
"internal/oglematchers"
]
revision = "0b37b35ec7434b77e77a4bb29b79677cced992ea" revision = "0b37b35ec7434b77e77a4bb29b79677cced992ea"
version = "1.8.1" version = "1.8.1"
[[projects]] [[projects]]
name = "github.com/smartystreets/goconvey" name = "github.com/smartystreets/goconvey"
packages = [ packages = ["convey","convey/gotest","convey/reporting"]
"convey",
"convey/gotest",
"convey/reporting"
]
revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857" revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857"
version = "1.6.3" version = "1.6.3"
@@ -429,21 +353,7 @@
[[projects]] [[projects]]
name = "github.com/uber/jaeger-client-go" name = "github.com/uber/jaeger-client-go"
packages = [ packages = [".","config","internal/baggage","internal/baggage/remote","internal/spanlog","log","rpcmetrics","thrift-gen/agent","thrift-gen/baggage","thrift-gen/jaeger","thrift-gen/sampling","thrift-gen/zipkincore","utils"]
".",
"config",
"internal/baggage",
"internal/baggage/remote",
"internal/spanlog",
"log",
"rpcmetrics",
"thrift-gen/agent",
"thrift-gen/baggage",
"thrift-gen/jaeger",
"thrift-gen/sampling",
"thrift-gen/zipkincore",
"utils"
]
revision = "3ac96c6e679cb60a74589b0d0aa7c70a906183f7" revision = "3ac96c6e679cb60a74589b0d0aa7c70a906183f7"
version = "v2.11.2" version = "v2.11.2"
@@ -455,10 +365,7 @@
[[projects]] [[projects]]
name = "github.com/yudai/gojsondiff" name = "github.com/yudai/gojsondiff"
packages = [ packages = [".","formatter"]
".",
"formatter"
]
revision = "7b1b7adf999dab73a6eb02669c3d82dbb27a3dd6" revision = "7b1b7adf999dab73a6eb02669c3d82dbb27a3dd6"
version = "1.0.0" version = "1.0.0"
@@ -471,37 +378,19 @@
[[projects]] [[projects]]
branch = "master" branch = "master"
name = "golang.org/x/crypto" name = "golang.org/x/crypto"
packages = [ packages = ["md4","pbkdf2"]
"md4",
"pbkdf2"
]
revision = "3d37316aaa6bd9929127ac9a527abf408178ea7b" revision = "3d37316aaa6bd9929127ac9a527abf408178ea7b"
[[projects]] [[projects]]
branch = "master" branch = "master"
name = "golang.org/x/net" name = "golang.org/x/net"
packages = [ packages = ["context","context/ctxhttp","http2","http2/hpack","idna","internal/timeseries","lex/httplex","trace"]
"context",
"context/ctxhttp",
"http2",
"http2/hpack",
"idna",
"internal/timeseries",
"lex/httplex",
"trace"
]
revision = "5ccada7d0a7ba9aeb5d3aca8d3501b4c2a509fec" revision = "5ccada7d0a7ba9aeb5d3aca8d3501b4c2a509fec"
[[projects]] [[projects]]
branch = "master" branch = "master"
name = "golang.org/x/oauth2" name = "golang.org/x/oauth2"
packages = [ packages = [".","google","internal","jws","jwt"]
".",
"google",
"internal",
"jws",
"jwt"
]
revision = "b28fcf2b08a19742b43084fb40ab78ac6c3d8067" revision = "b28fcf2b08a19742b43084fb40ab78ac6c3d8067"
[[projects]] [[projects]]
@@ -519,39 +408,12 @@
[[projects]] [[projects]]
branch = "master" branch = "master"
name = "golang.org/x/text" name = "golang.org/x/text"
packages = [ packages = ["collate","collate/build","internal/colltab","internal/gen","internal/tag","internal/triegen","internal/ucd","language","secure/bidirule","transform","unicode/bidi","unicode/cldr","unicode/norm","unicode/rangetable"]
"collate",
"collate/build",
"internal/colltab",
"internal/gen",
"internal/tag",
"internal/triegen",
"internal/ucd",
"language",
"secure/bidirule",
"transform",
"unicode/bidi",
"unicode/cldr",
"unicode/norm",
"unicode/rangetable"
]
revision = "e19ae1496984b1c655b8044a65c0300a3c878dd3" revision = "e19ae1496984b1c655b8044a65c0300a3c878dd3"
[[projects]] [[projects]]
name = "google.golang.org/appengine" name = "google.golang.org/appengine"
packages = [ packages = [".","cloudsql","internal","internal/app_identity","internal/base","internal/datastore","internal/log","internal/modules","internal/remote_api","internal/urlfetch","urlfetch"]
".",
"cloudsql",
"internal",
"internal/app_identity",
"internal/base",
"internal/datastore",
"internal/log",
"internal/modules",
"internal/remote_api",
"internal/urlfetch",
"urlfetch"
]
revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a"
version = "v1.0.0" version = "v1.0.0"
@@ -563,32 +425,7 @@
[[projects]] [[projects]]
name = "google.golang.org/grpc" name = "google.golang.org/grpc"
packages = [ packages = [".","balancer","balancer/base","balancer/roundrobin","codes","connectivity","credentials","encoding","grpclb/grpc_lb_v1/messages","grpclog","health","health/grpc_health_v1","internal","keepalive","metadata","naming","peer","resolver","resolver/dns","resolver/passthrough","stats","status","tap","transport"]
".",
"balancer",
"balancer/base",
"balancer/roundrobin",
"codes",
"connectivity",
"credentials",
"encoding",
"grpclb/grpc_lb_v1/messages",
"grpclog",
"health",
"health/grpc_health_v1",
"internal",
"keepalive",
"metadata",
"naming",
"peer",
"resolver",
"resolver/dns",
"resolver/passthrough",
"stats",
"status",
"tap",
"transport"
]
revision = "6b51017f791ae1cfbec89c52efdf444b13b550ef" revision = "6b51017f791ae1cfbec89c52efdf444b13b550ef"
version = "v1.9.2" version = "v1.9.2"
@@ -610,12 +447,6 @@
revision = "567b2bfa514e796916c4747494d6ff5132a1dfce" revision = "567b2bfa514e796916c4747494d6ff5132a1dfce"
version = "v1" version = "v1"
[[projects]]
branch = "v2"
name = "gopkg.in/gomail.v2"
packages = ["."]
revision = "81ebce5c23dfd25c6c67194b37d3dd3f338c98b1"
[[projects]] [[projects]]
name = "gopkg.in/ini.v1" name = "gopkg.in/ini.v1"
packages = ["."] packages = ["."]
@@ -628,6 +459,12 @@
revision = "75f2e9b42e99652f0d82b28ccb73648f44615faa" revision = "75f2e9b42e99652f0d82b28ccb73648f44615faa"
version = "v1.2.4" version = "v1.2.4"
[[projects]]
branch = "v2"
name = "gopkg.in/mail.v2"
packages = ["."]
revision = "5bc5c8bb07bd8d2803831fbaf8cbd630fcde2c68"
[[projects]] [[projects]]
name = "gopkg.in/redis.v2" name = "gopkg.in/redis.v2"
packages = ["."] packages = ["."]
@@ -643,6 +480,6 @@
[solve-meta] [solve-meta]
analyzer-name = "dep" analyzer-name = "dep"
analyzer-version = 1 analyzer-version = 1
inputs-digest = "8a9e651fb8ea49dfd3c6ddc99bd3242b39e453ea9edd11321da79bd2c865e9d1" inputs-digest = "ad3c71fd3244369c313978e9e7464c7116faee764386439a17de0707a08103aa"
solver-name = "gps-cdcl" solver-name = "gps-cdcl"
solver-version = 1 solver-version = 1

View File

@@ -172,7 +172,7 @@ ignored = [
name = "golang.org/x/sync" name = "golang.org/x/sync"
[[constraint]] [[constraint]]
name = "gopkg.in/gomail.v2" name = "gopkg.in/mail.v2"
branch = "v2" branch = "v2"
[[constraint]] [[constraint]]

View File

@@ -9,6 +9,7 @@ upgrading Grafana please check here before creating an issue.
- [Datasource plugin written in typescript](https://github.com/grafana/typescript-template-datasource) - [Datasource plugin written in typescript](https://github.com/grafana/typescript-template-datasource)
- [Simple json dataource plugin](https://github.com/grafana/simple-json-datasource) - [Simple json dataource plugin](https://github.com/grafana/simple-json-datasource)
- [Plugin development guide](http://docs.grafana.org/plugins/developing/development/) - [Plugin development guide](http://docs.grafana.org/plugins/developing/development/)
- [Webpack Grafana plugin template project](https://github.com/CorpGlory/grafana-plugin-template-webpack)
## Changes in v4.6 ## Changes in v4.6

View File

@@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
environment: environment:
nodejs_version: "6" nodejs_version: "6"
GOPATH: c:\gopath GOPATH: c:\gopath
GOVERSION: 1.9.2 GOVERSION: 1.10
install: install:
- rmdir c:\go /s /q - rmdir c:\go /s /q

View File

@@ -17,6 +17,7 @@ EXPOSE 389
VOLUME ["/etc/ldap", "/var/lib/ldap"] VOLUME ["/etc/ldap", "/var/lib/ldap"]
COPY modules/ /etc/ldap.dist/modules COPY modules/ /etc/ldap.dist/modules
COPY prepopulate/ /etc/ldap.dist/prepopulate
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh

View File

@@ -65,7 +65,7 @@ EOF
fi fi
if [[ -n "$SLAPD_ADDITIONAL_SCHEMAS" ]]; then if [[ -n "$SLAPD_ADDITIONAL_SCHEMAS" ]]; then
IFS=","; declare -a schemas=($SLAPD_ADDITIONAL_SCHEMAS) IFS=","; declare -a schemas=($SLAPD_ADDITIONAL_SCHEMAS); unset IFS
for schema in "${schemas[@]}"; do for schema in "${schemas[@]}"; do
slapadd -n0 -F /etc/ldap/slapd.d -l "/etc/ldap/schema/${schema}.ldif" >/dev/null 2>&1 slapadd -n0 -F /etc/ldap/slapd.d -l "/etc/ldap/schema/${schema}.ldif" >/dev/null 2>&1
@@ -73,14 +73,18 @@ EOF
fi fi
if [[ -n "$SLAPD_ADDITIONAL_MODULES" ]]; then if [[ -n "$SLAPD_ADDITIONAL_MODULES" ]]; then
IFS=","; declare -a modules=($SLAPD_ADDITIONAL_MODULES) IFS=","; declare -a modules=($SLAPD_ADDITIONAL_MODULES); unset IFS
for module in "${modules[@]}"; do for module in "${modules[@]}"; do
slapadd -n0 -F /etc/ldap/slapd.d -l "/etc/ldap/modules/${module}.ldif" >/dev/null 2>&1 slapadd -n0 -F /etc/ldap/slapd.d -l "/etc/ldap/modules/${module}.ldif" >/dev/null 2>&1
done done
fi fi
chown -R openldap:openldap /etc/ldap/slapd.d/ for file in `ls /etc/ldap/prepopulate/*.ldif`; do
slapadd -F /etc/ldap/slapd.d -l "$file"
done
chown -R openldap:openldap /etc/ldap/slapd.d/ /var/lib/ldap/ /var/run/slapd/
else else
slapd_configs_in_env=`env | grep 'SLAPD_'` slapd_configs_in_env=`env | grep 'SLAPD_'`

View File

@@ -0,0 +1,13 @@
# Notes on OpenLdap Docker Block
Any ldif files added to the prepopulate subdirectory will be automatically imported into the OpenLdap database.
The ldif files add three users, `ldapviewer`, `ldapeditor` and `ldapadmin`. Two groups, `admins` and `users`, are added that correspond with the group mappings in the default conf/ldap.toml. `ldapadmin` is a member of `admins` and `ldapeditor` is a member of `users`.
Note that users that are added here need to specify a `memberOf` attribute manually as well as the `member` attribute for the group. The `memberOf` module usually does this automatically (if you add a group in Apache Directory Studio for example) but this does not work in the entrypoint script as it uses the `slapadd` command to add entries before the server has started and before the `memberOf` module is loaded.
After adding ldif files to `prepopulate`:
1. Remove your current docker image: `docker rm docker_openldap_1`
2. Build: `docker-compose build`
3. `docker-compose up`

View File

@@ -0,0 +1,10 @@
dn: cn=ldapadmin,dc=grafana,dc=org
mail: ldapadmin@grafana.com
userPassword: grafana
objectClass: person
objectClass: top
objectClass: inetOrgPerson
objectClass: organizationalPerson
sn: ldapadmin
cn: ldapadmin
memberOf: cn=admins,dc=grafana,dc=org

View File

@@ -0,0 +1,5 @@
dn: cn=admins,dc=grafana,dc=org
cn: admins
member: cn=ldapadmin,dc=grafana,dc=org
objectClass: groupOfNames
objectClass: top

View File

@@ -0,0 +1,10 @@
dn: cn=ldapeditor,dc=grafana,dc=org
mail: ldapeditor@grafana.com
userPassword: grafana
objectClass: person
objectClass: top
objectClass: inetOrgPerson
objectClass: organizationalPerson
sn: ldapeditor
cn: ldapeditor
memberOf: cn=users,dc=grafana,dc=org

View File

@@ -0,0 +1,5 @@
dn: cn=users,dc=grafana,dc=org
cn: users
member: cn=ldapeditor,dc=grafana,dc=org
objectClass: groupOfNames
objectClass: top

View File

@@ -0,0 +1,9 @@
dn: cn=ldapviewer,dc=grafana,dc=org
mail: ldapviewer@grafana.com
userPassword: grafana
objectClass: person
objectClass: top
objectClass: inetOrgPerson
objectClass: organizationalPerson
sn: ldapviewer
cn: ldapviewer

View File

@@ -41,6 +41,8 @@ Grafana ships with the following set of notification types:
To enable email notifications you have to setup [SMTP settings](/installation/configuration/#smtp) To enable email notifications you have to setup [SMTP settings](/installation/configuration/#smtp)
in the Grafana config. Email notifications will upload an image of the alert graph to an in the Grafana config. Email notifications will upload an image of the alert graph to an
external image destination if available or fallback to attaching the image to the email. external image destination if available or fallback to attaching the image to the email.
Be aware that if you use the `local` image storage email servers and clients might not be
able to access the image.
### Slack ### Slack

View File

@@ -180,14 +180,14 @@ Content-Type: application/json
```http ```http
HTTP/1.1 200 HTTP/1.1 200
Content-Type: application/json Content-Type: application/json
``` ```
Deletes the annotation that matches the specified id. Deletes the annotation that matches the specified id.
**Example Request**: **Example Request**:
```http ```http
DELETE /api/annotation/1 HTTP/1.1 DELETE /api/annotations/1 HTTP/1.1
Accept: application/json Accept: application/json
Content-Type: application/json Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
@@ -204,14 +204,14 @@ Content-Type: application/json
## Delete Annotation By RegionId ## Delete Annotation By RegionId
`DELETE /api/annotation/region/:id` `DELETE /api/annotations/region/:id`
Deletes the annotation that matches the specified region id. A region is an annotation that covers a timerange and has a start and end time. In the Grafana database, this is a stored as two annotations connected by a region id. Deletes the annotation that matches the specified region id. A region is an annotation that covers a timerange and has a start and end time. In the Grafana database, this is a stored as two annotations connected by a region id.
**Example Request**: **Example Request**:
```http ```http
DELETE /api/annotation/region/1 HTTP/1.1 DELETE /api/annotations/region/1 HTTP/1.1
Accept: application/json Accept: application/json
Content-Type: application/json Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk

View File

@@ -15,7 +15,7 @@ weight = 1
Description | Download Description | Download
------------ | ------------- ------------ | -------------
Stable for Debian-based Linux | [grafana_5.0.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.3_amd64.deb) Stable for Debian-based Linux | [grafana_5.0.4_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.4_amd64.deb)
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
installation. installation.
@@ -24,9 +24,9 @@ installation.
```bash ```bash
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.3_amd64.deb wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.4_amd64.deb
sudo apt-get install -y adduser libfontconfig sudo apt-get install -y adduser libfontconfig
sudo dpkg -i grafana_5.0.3_amd64.deb sudo dpkg -i grafana_5.0.4_amd64.deb
``` ```
## APT Repository ## APT Repository
@@ -34,7 +34,7 @@ sudo dpkg -i grafana_5.0.3_amd64.deb
Add the following line to your `/etc/apt/sources.list` file. Add the following line to your `/etc/apt/sources.list` file.
```bash ```bash
deb https://packagecloud.io/grafana/stable/debian/ jessie main deb https://packagecloud.io/grafana/stable/debian/ stretch main
``` ```
Use the above line even if you are on Ubuntu or another Debian version. Use the above line even if you are on Ubuntu or another Debian version.
@@ -42,7 +42,7 @@ There is also a testing repository if you want beta or release
candidates. candidates.
```bash ```bash
deb https://packagecloud.io/grafana/testing/debian/ jessie main deb https://packagecloud.io/grafana/testing/debian/ stretch main
``` ```
Then add the [Package Cloud](https://packagecloud.io/grafana) key. This Then add the [Package Cloud](https://packagecloud.io/grafana) key. This

View File

@@ -15,7 +15,7 @@ weight = 2
Description | Download Description | Download
------------ | ------------- ------------ | -------------
Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.3-1.x86_64.rpm) Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.4 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.4-1.x86_64.rpm)
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
@@ -26,7 +26,7 @@ installation.
You can install Grafana using Yum directly. You can install Grafana using Yum directly.
```bash ```bash
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.3-1.x86_64.rpm $ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.4-1.x86_64.rpm
``` ```
Or install manually using `rpm`. Or install manually using `rpm`.
@@ -34,15 +34,15 @@ Or install manually using `rpm`.
#### On CentOS / Fedora / Redhat: #### On CentOS / Fedora / Redhat:
```bash ```bash
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.3-1.x86_64.rpm $ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.4-1.x86_64.rpm
$ sudo yum install initscripts fontconfig $ sudo yum install initscripts fontconfig
$ sudo rpm -Uvh grafana-5.0.3-1.x86_64.rpm $ sudo rpm -Uvh grafana-5.0.4-1.x86_64.rpm
``` ```
#### On OpenSuse: #### On OpenSuse:
```bash ```bash
$ sudo rpm -i --nodeps grafana-5.0.3-1.x86_64.rpm $ sudo rpm -i --nodeps grafana-5.0.4-1.x86_64.rpm
``` ```
## Install via YUM Repository ## Install via YUM Repository
@@ -52,7 +52,7 @@ Add the following to a new file at `/etc/yum.repos.d/grafana.repo`
```bash ```bash
[grafana] [grafana]
name=grafana name=grafana
baseurl=https://packagecloud.io/grafana/stable/el/6/$basearch baseurl=https://packagecloud.io/grafana/stable/el/7/$basearch
repo_gpgcheck=1 repo_gpgcheck=1
enabled=1 enabled=1
gpgcheck=1 gpgcheck=1
@@ -64,7 +64,7 @@ sslcacert=/etc/pki/tls/certs/ca-bundle.crt
There is also a testing repository if you want beta or release candidates. There is also a testing repository if you want beta or release candidates.
```bash ```bash
baseurl=https://packagecloud.io/grafana/testing/el/6/$basearch baseurl=https://packagecloud.io/grafana/testing/el/7/$basearch
``` ```
Then install Grafana via the `yum` command. Then install Grafana via the `yum` command.

View File

@@ -23,7 +23,7 @@ Before upgrading it can be a good idea to backup your Grafana database. This wil
#### sqlite #### sqlite
If you use sqlite you only need to make a backup of you `grafana.db` file. This is usually located at `/var/lib/grafana/grafana.db` on unix system. If you use sqlite you only need to make a backup of your `grafana.db` file. This is usually located at `/var/lib/grafana/grafana.db` on unix system.
If you are unsure what database you use and where it is stored check you grafana configuration file. If you If you are unsure what database you use and where it is stored check you grafana configuration file. If you
installed grafana to custom location using a binary tar/zip it is usally in `<grafana_install_dir>/data`. installed grafana to custom location using a binary tar/zip it is usally in `<grafana_install_dir>/data`.

View File

@@ -8,12 +8,11 @@ parent = "installation"
weight = 3 weight = 3
+++ +++
# Installing on Windows # Installing on Windows
Description | Download Description | Download
------------ | ------------- ------------ | -------------
Latest stable package for Windows | [grafana-5.0.3.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.3.windows-x64.zip) Latest stable package for Windows | [grafana-5.0.4.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.4.windows-x64.zip)
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
installation. installation.

View File

@@ -71,13 +71,13 @@ Each field in the dashboard JSON is explained below with its usage:
| **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details | | **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details |
| **templating** | templating metadata, see [templating section](#templating) for details | | **templating** | templating metadata, see [templating section](#templating) for details |
| **annotations** | annotations metadata, see [annotations section](#annotations) for details | | **annotations** | annotations metadata, see [annotations section](#annotations) for details |
| **schemaVersion** | version of the JSON schema (integer), incremented each time a Grafana update brings changes to the said schema | | **schemaVersion** | version of the JSON schema (integer), incremented each time a Grafana update brings changes to said schema |
| **version** | version of the dashboard (integer), incremented each time the dashboard is updated | | **version** | version of the dashboard (integer), incremented each time the dashboard is updated |
| **panels** | panels array, see below for detail. | | **panels** | panels array, see below for detail. |
## Panels ## Panels
Panels are the building blocks a dashboard. It consists of datasource queries, type of graphs, aliases, etc. Panel JSON consists of an array of JSON objects, each representing a different panel. Most of the fields are common for all panels but some fields depends on the panel type. Following is an example of panel JSON of a text panel. Panels are the building blocks of a dashboard. It consists of datasource queries, type of graphs, aliases, etc. Panel JSON consists of an array of JSON objects, each representing a different panel. Most of the fields are common for all panels but some fields depend on the panel type. Following is an example of panel JSON of a text panel.
```json ```json
"panels": [ "panels": [
@@ -105,7 +105,7 @@ The gridPos property describes the panel size and position in grid coordinates.
- `x` The x position, in same unit as `w`. - `x` The x position, in same unit as `w`.
- `y` The y position, in same unit as `h`. - `y` The y position, in same unit as `h`.
The grid has a negative gravity that moves panels up if there i empty space above a panel. The grid has a negative gravity that moves panels up if there is empty space above a panel.
### timepicker ### timepicker
@@ -161,7 +161,7 @@ Usage of the fields is explained below:
### templating ### templating
`templating` fields contains array of template variables with their saved values along with some other metadata, for example: The `templating` field contains an array of template variables with their saved values along with some other metadata, for example:
```json ```json
"templating": { "templating": {
@@ -236,7 +236,7 @@ Usage of the above mentioned fields in the templating section is explained below
| Name | Usage | | Name | Usage |
| ---- | ----- | | ---- | ----- |
| **enable** | whether templating is enabled or not | | **enable** | whether templating is enabled or not |
| **list** | an array of objects representing, each representing one template variable | | **list** | an array of objects each representing one template variable |
| **allFormat** | format to use while fetching all values from datasource, eg: `wildcard`, `glob`, `regex`, `pipe`, etc. | | **allFormat** | format to use while fetching all values from datasource, eg: `wildcard`, `glob`, `regex`, `pipe`, etc. |
| **current** | shows current selected variable text/value on the dashboard | | **current** | shows current selected variable text/value on the dashboard |
| **datasource** | shows datasource for the variables | | **datasource** | shows datasource for the variables |

View File

@@ -174,6 +174,8 @@ Interpolating a variable with multiple values selected is tricky as it is not st
is valid in the given context where the variable is used. Grafana tries to solve this by allowing each data source plugin to is valid in the given context where the variable is used. Grafana tries to solve this by allowing each data source plugin to
inform the templating interpolation engine what format to use for multiple values. inform the templating interpolation engine what format to use for multiple values.
Note that the *Custom all value* option on the variable will have to be left blank for Grafana to format all values into a single string.
**Graphite**, for example, uses glob expressions. A variable with multiple values would, in this case, be interpolated as `{host1,host2,host3}` if **Graphite**, for example, uses glob expressions. A variable with multiple values would, in this case, be interpolated as `{host1,host2,host3}` if
the current variable value was *host1*, *host2* and *host3*. the current variable value was *host1*, *host2* and *host3*.

View File

@@ -104,10 +104,10 @@
"test": "grunt test", "test": "grunt test",
"test:coverage": "grunt test --coverage=true", "test:coverage": "grunt test --coverage=true",
"lint": "tslint -c tslint.json --project tsconfig.json --type-check", "lint": "tslint -c tslint.json --project tsconfig.json --type-check",
"karma": "node ./node_modules/grunt-cli/bin/grunt karma:dev", "karma": "grunt karma:dev",
"jest": "node ./node_modules/jest-cli/bin/jest.js --notify --watch", "jest": "jest --notify --watch",
"api-tests": "node ./node_modules/jest-cli/bin/jest.js --notify --watch --config=tests/api/jest.js", "api-tests": "jest --notify --watch --config=tests/api/jest.js",
"precommit": "lint-staged && node ./node_modules/grunt-cli/bin/grunt precommit" "precommit": "lint-staged && grunt precommit"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx}": [ "*.{ts,tsx}": [

View File

@@ -50,6 +50,10 @@ type UserStars struct {
} }
func GetGravatarUrl(text string) string { func GetGravatarUrl(text string) string {
if setting.DisableGravatar {
return "/public/img/user_profile.png"
}
if text == "" { if text == "" {
return "" return ""
} }

View File

@@ -111,7 +111,7 @@ func (g *GrafanaServerImpl) initLogging() {
}) })
if err != nil { if err != nil {
g.log.Error(err.Error()) fmt.Fprintf(os.Stderr, "Failed to start grafana. error: %s\n", err.Error())
os.Exit(1) os.Exit(1)
} }

View File

@@ -1,8 +1,8 @@
package middleware package middleware
import ( import (
"errors"
"fmt" "fmt"
"net"
"strings" "strings"
"time" "time"
@@ -25,7 +25,7 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
} }
// if auth proxy ip(s) defined, check if request comes from one of those // if auth proxy ip(s) defined, check if request comes from one of those
if err := checkAuthenticationProxy(ctx, proxyHeaderValue); err != nil { if err := checkAuthenticationProxy(ctx.Req.RemoteAddr, proxyHeaderValue); err != nil {
ctx.Handle(407, "Proxy authentication required", err) ctx.Handle(407, "Proxy authentication required", err)
return true return true
} }
@@ -123,29 +123,25 @@ var syncGrafanaUserWithLdapUser = func(ctx *m.ReqContext, query *m.GetSignedInUs
return nil return nil
} }
func checkAuthenticationProxy(ctx *m.ReqContext, proxyHeaderValue string) error { func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error {
if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 { if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 {
return nil return nil
} }
proxies := strings.Split(setting.AuthProxyWhitelist, ",") proxies := strings.Split(setting.AuthProxyWhitelist, ",")
remoteAddrSplit := strings.Split(ctx.Req.RemoteAddr, ":") sourceIP, _, err := net.SplitHostPort(remoteAddr)
sourceIP := remoteAddrSplit[0] if err != nil {
found := false
for _, proxyIP := range proxies {
if sourceIP == strings.TrimSpace(proxyIP) {
found = true
break
}
}
if !found {
msg := fmt.Sprintf("Request for user (%s) is not from the authentication proxy", proxyHeaderValue)
err := errors.New(msg)
return err return err
} }
return nil // Compare allowed IP addresses to actual address
for _, proxyIP := range proxies {
if sourceIP == strings.TrimSpace(proxyIP) {
return nil
}
}
return fmt.Errorf("Request for user (%s) from %s is not from the authentication proxy", proxyHeaderValue, sourceIP)
} }
func getSignedInUserQueryForProxyAuth(headerVal string) *m.GetSignedInUserQuery { func getSignedInUserQueryForProxyAuth(headerVal string) *m.GetSignedInUserQuery {

View File

@@ -226,11 +226,11 @@ func TestMiddlewareContext(t *testing.T) {
}) })
}) })
middlewareScenario("When auth_proxy is enabled and request RemoteAddr is not trusted", func(sc *scenarioContext) { middlewareScenario("When auth_proxy is enabled and IPv4 request RemoteAddr is not trusted", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER" setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
setting.AuthProxyHeaderProperty = "username" setting.AuthProxyHeaderProperty = "username"
setting.AuthProxyWhitelist = "192.168.1.1, 192.168.2.1" setting.AuthProxyWhitelist = "192.168.1.1, 2001::23"
sc.fakeReq("GET", "/") sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
@@ -239,6 +239,24 @@ func TestMiddlewareContext(t *testing.T) {
Convey("should return 407 status code", func() { Convey("should return 407 status code", func() {
So(sc.resp.Code, ShouldEqual, 407) So(sc.resp.Code, ShouldEqual, 407)
So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 192.168.3.1 is not from the authentication proxy")
})
})
middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is not trusted", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
setting.AuthProxyHeaderProperty = "username"
setting.AuthProxyWhitelist = "192.168.1.1, 2001::23"
sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
sc.req.RemoteAddr = "[2001:23]:12345"
sc.exec()
Convey("should return 407 status code", func() {
So(sc.resp.Code, ShouldEqual, 407)
So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 2001:23 is not from the authentication proxy")
}) })
}) })
@@ -246,7 +264,7 @@ func TestMiddlewareContext(t *testing.T) {
setting.AuthProxyEnabled = true setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER" setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
setting.AuthProxyHeaderProperty = "username" setting.AuthProxyHeaderProperty = "username"
setting.AuthProxyWhitelist = "192.168.1.1, 192.168.2.1" setting.AuthProxyWhitelist = "192.168.1.1, 2001::23"
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 4, UserId: 33} query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
@@ -255,7 +273,7 @@ func TestMiddlewareContext(t *testing.T) {
sc.fakeReq("GET", "/") sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
sc.req.RemoteAddr = "192.168.2.1:12345" sc.req.RemoteAddr = "[2001::23]:12345"
sc.exec() sc.exec()
Convey("Should init context with user info", func() { Convey("Should init context with user info", func() {

View File

@@ -209,14 +209,14 @@ func GetDashboardFolderUrl(isFolder bool, uid string, slug string) string {
return GetDashboardUrl(uid, slug) return GetDashboardUrl(uid, slug)
} }
// Return the html url for a dashboard // GetDashboardUrl return the html url for a dashboard
func GetDashboardUrl(uid string, slug string) string { func GetDashboardUrl(uid string, slug string) string {
return fmt.Sprintf("%s/d/%s/%s", setting.AppSubUrl, uid, slug) return fmt.Sprintf("%s/d/%s/%s", setting.AppSubUrl, uid, slug)
} }
// Return the full url for a dashboard // GetFullDashboardUrl return the full url for a dashboard
func GetFullDashboardUrl(uid string, slug string) string { func GetFullDashboardUrl(uid string, slug string) string {
return fmt.Sprintf("%s%s", setting.AppUrl, GetDashboardUrl(uid, slug)) return fmt.Sprintf("%sd/%s/%s", setting.AppUrl, uid, slug)
} }
// GetFolderUrl return the html url for a folder // GetFolderUrl return the html url for a folder

View File

@@ -4,11 +4,24 @@ import (
"testing" "testing"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
) )
func TestDashboardModel(t *testing.T) { func TestDashboardModel(t *testing.T) {
Convey("Generate full dashboard url", t, func() {
setting.AppUrl = "http://grafana.local/"
fullUrl := GetFullDashboardUrl("uid", "my-dashboard")
So(fullUrl, ShouldEqual, "http://grafana.local/d/uid/my-dashboard")
})
Convey("Generate relative dashboard url", t, func() {
setting.AppUrl = ""
fullUrl := GetDashboardUrl("uid", "my-dashboard")
So(fullUrl, ShouldEqual, "/d/uid/my-dashboard")
})
Convey("When generating slug", t, func() { Convey("When generating slug", t, func() {
dashboard := NewDashboard("Grafana Play Home") dashboard := NewDashboard("Grafana Play Home")
dashboard.UpdateSlug() dashboard.UpdateSlug()

View File

@@ -13,11 +13,7 @@ func init() {
func validateDashboardAlerts(cmd *m.ValidateDashboardAlertsCommand) error { func validateDashboardAlerts(cmd *m.ValidateDashboardAlertsCommand) error {
extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId) extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId)
if _, err := extractor.GetAlerts(); err != nil { return extractor.ValidateAlerts()
return err
}
return nil
} }
func updateDashboardAlerts(cmd *m.UpdateDashboardAlertsCommand) error { func updateDashboardAlerts(cmd *m.UpdateDashboardAlertsCommand) error {
@@ -29,15 +25,12 @@ func updateDashboardAlerts(cmd *m.UpdateDashboardAlertsCommand) error {
extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId) extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId)
if alerts, err := extractor.GetAlerts(); err != nil { alerts, err := extractor.GetAlerts()
return err if err != nil {
} else {
saveAlerts.Alerts = alerts
}
if err := bus.Dispatch(&saveAlerts); err != nil {
return err return err
} }
return nil saveAlerts.Alerts = alerts
return bus.Dispatch(&saveAlerts)
} }

View File

@@ -11,76 +11,78 @@ import (
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
) )
// DashAlertExtractor extracts alerts from the dashboard json
type DashAlertExtractor struct { type DashAlertExtractor struct {
Dash *m.Dashboard Dash *m.Dashboard
OrgId int64 OrgID int64
log log.Logger log log.Logger
} }
func NewDashAlertExtractor(dash *m.Dashboard, orgId int64) *DashAlertExtractor { // NewDashAlertExtractor returns a new DashAlertExtractor
func NewDashAlertExtractor(dash *m.Dashboard, orgID int64) *DashAlertExtractor {
return &DashAlertExtractor{ return &DashAlertExtractor{
Dash: dash, Dash: dash,
OrgId: orgId, OrgID: orgID,
log: log.New("alerting.extractor"), log: log.New("alerting.extractor"),
} }
} }
func (e *DashAlertExtractor) lookupDatasourceId(dsName string) (*m.DataSource, error) { func (e *DashAlertExtractor) lookupDatasourceID(dsName string) (*m.DataSource, error) {
if dsName == "" { if dsName == "" {
query := &m.GetDataSourcesQuery{OrgId: e.OrgId} query := &m.GetDataSourcesQuery{OrgId: e.OrgID}
if err := bus.Dispatch(query); err != nil { if err := bus.Dispatch(query); err != nil {
return nil, err return nil, err
} else { }
for _, ds := range query.Result {
if ds.IsDefault { for _, ds := range query.Result {
return ds, nil if ds.IsDefault {
} return ds, nil
} }
} }
} else { } else {
query := &m.GetDataSourceByNameQuery{Name: dsName, OrgId: e.OrgId} query := &m.GetDataSourceByNameQuery{Name: dsName, OrgId: e.OrgID}
if err := bus.Dispatch(query); err != nil { if err := bus.Dispatch(query); err != nil {
return nil, err return nil, err
} else {
return query.Result, nil
} }
return query.Result, nil
} }
return nil, errors.New("Could not find datasource id for " + dsName) return nil, errors.New("Could not find datasource id for " + dsName)
} }
func findPanelQueryByRefId(panel *simplejson.Json, refId string) *simplejson.Json { func findPanelQueryByRefID(panel *simplejson.Json, refID string) *simplejson.Json {
for _, targetsObj := range panel.Get("targets").MustArray() { for _, targetsObj := range panel.Get("targets").MustArray() {
target := simplejson.NewFromAny(targetsObj) target := simplejson.NewFromAny(targetsObj)
if target.Get("refId").MustString() == refId { if target.Get("refId").MustString() == refID {
return target return target
} }
} }
return nil return nil
} }
func copyJson(in *simplejson.Json) (*simplejson.Json, error) { func copyJSON(in *simplejson.Json) (*simplejson.Json, error) {
rawJson, err := in.MarshalJSON() rawJSON, err := in.MarshalJSON()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return simplejson.NewJson(rawJson) return simplejson.NewJson(rawJSON)
} }
func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json) ([]*m.Alert, error) { func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json, validateAlertFunc func(*m.Alert) bool) ([]*m.Alert, error) {
alerts := make([]*m.Alert, 0) alerts := make([]*m.Alert, 0)
for _, panelObj := range jsonWithPanels.Get("panels").MustArray() { for _, panelObj := range jsonWithPanels.Get("panels").MustArray() {
panel := simplejson.NewFromAny(panelObj) panel := simplejson.NewFromAny(panelObj)
collapsedJson, collapsed := panel.CheckGet("collapsed") collapsedJSON, collapsed := panel.CheckGet("collapsed")
// check if the panel is collapsed // check if the panel is collapsed
if collapsed && collapsedJson.MustBool() { if collapsed && collapsedJSON.MustBool() {
// extract alerts from sub panels for collapsed panels // extract alerts from sub panels for collapsed panels
als, err := e.GetAlertFromPanels(panel) als, err := e.getAlertFromPanels(panel, validateAlertFunc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -95,7 +97,7 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json)
continue continue
} }
panelId, err := panel.Get("id").Int64() panelID, err := panel.Get("id").Int64()
if err != nil { if err != nil {
return nil, fmt.Errorf("panel id is required. err %v", err) return nil, fmt.Errorf("panel id is required. err %v", err)
} }
@@ -113,8 +115,8 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json)
alert := &m.Alert{ alert := &m.Alert{
DashboardId: e.Dash.Id, DashboardId: e.Dash.Id,
OrgId: e.OrgId, OrgId: e.OrgID,
PanelId: panelId, PanelId: panelID,
Id: jsonAlert.Get("id").MustInt64(), Id: jsonAlert.Get("id").MustInt64(),
Name: jsonAlert.Get("name").MustString(), Name: jsonAlert.Get("name").MustString(),
Handler: jsonAlert.Get("handler").MustInt64(), Handler: jsonAlert.Get("handler").MustInt64(),
@@ -126,11 +128,11 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json)
jsonCondition := simplejson.NewFromAny(condition) jsonCondition := simplejson.NewFromAny(condition)
jsonQuery := jsonCondition.Get("query") jsonQuery := jsonCondition.Get("query")
queryRefId := jsonQuery.Get("params").MustArray()[0].(string) queryRefID := jsonQuery.Get("params").MustArray()[0].(string)
panelQuery := findPanelQueryByRefId(panel, queryRefId) panelQuery := findPanelQueryByRefID(panel, queryRefID)
if panelQuery == nil { if panelQuery == nil {
reason := fmt.Sprintf("Alert on PanelId: %v refers to query(%s) that cannot be found", alert.PanelId, queryRefId) reason := fmt.Sprintf("Alert on PanelId: %v refers to query(%s) that cannot be found", alert.PanelId, queryRefID)
return nil, ValidationError{Reason: reason} return nil, ValidationError{Reason: reason}
} }
@@ -141,12 +143,13 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json)
dsName = panel.Get("datasource").MustString() dsName = panel.Get("datasource").MustString()
} }
if datasource, err := e.lookupDatasourceId(dsName); err != nil { datasource, err := e.lookupDatasourceID(dsName)
if err != nil {
return nil, err return nil, err
} else {
jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id)
} }
jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id)
if interval, err := panel.Get("interval").String(); err == nil { if interval, err := panel.Get("interval").String(); err == nil {
panelQuery.Set("interval", interval) panelQuery.Set("interval", interval)
} }
@@ -162,21 +165,28 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json)
return nil, err return nil, err
} }
if alert.ValidToSave() { if !validateAlertFunc(alert) {
alerts = append(alerts, alert)
} else {
e.log.Debug("Invalid Alert Data. Dashboard, Org or Panel ID is not correct", "alertName", alert.Name, "panelId", alert.PanelId) e.log.Debug("Invalid Alert Data. Dashboard, Org or Panel ID is not correct", "alertName", alert.Name, "panelId", alert.PanelId)
return nil, m.ErrDashboardContainsInvalidAlertData return nil, m.ErrDashboardContainsInvalidAlertData
} }
alerts = append(alerts, alert)
} }
return alerts, nil return alerts, nil
} }
func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) { func validateAlertRule(alert *m.Alert) bool {
e.log.Debug("GetAlerts") return alert.ValidToSave()
}
dashboardJson, err := copyJson(e.Dash.Data) // GetAlerts extracts alerts from the dashboard json and does full validation on the alert json data
func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) {
return e.extractAlerts(validateAlertRule)
}
func (e *DashAlertExtractor) extractAlerts(validateFunc func(alert *m.Alert) bool) ([]*m.Alert, error) {
dashboardJSON, err := copyJSON(e.Dash.Data)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -185,11 +195,11 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) {
// We extract alerts from rows to be backwards compatible // We extract alerts from rows to be backwards compatible
// with the old dashboard json model. // with the old dashboard json model.
rows := dashboardJson.Get("rows").MustArray() rows := dashboardJSON.Get("rows").MustArray()
if len(rows) > 0 { if len(rows) > 0 {
for _, rowObj := range rows { for _, rowObj := range rows {
row := simplejson.NewFromAny(rowObj) row := simplejson.NewFromAny(rowObj)
a, err := e.GetAlertFromPanels(row) a, err := e.getAlertFromPanels(row, validateFunc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -197,7 +207,7 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) {
alerts = append(alerts, a...) alerts = append(alerts, a...)
} }
} else { } else {
a, err := e.GetAlertFromPanels(dashboardJson) a, err := e.getAlertFromPanels(dashboardJSON, validateFunc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -208,3 +218,10 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) {
e.log.Debug("Extracted alerts from dashboard", "alertCount", len(alerts)) e.log.Debug("Extracted alerts from dashboard", "alertCount", len(alerts))
return alerts, nil return alerts, nil
} }
// ValidateAlerts validates alerts in the dashboard json but does not require a valid dashboard id
// in the first validation pass
func (e *DashAlertExtractor) ValidateAlerts() error {
_, err := e.extractAlerts(func(alert *m.Alert) bool { return alert.OrgId != 0 && alert.PanelId != 0 })
return err
}

View File

@@ -240,5 +240,26 @@ func TestAlertRuleExtraction(t *testing.T) {
So(len(alerts), ShouldEqual, 4) So(len(alerts), ShouldEqual, 4)
}) })
}) })
Convey("Parse and validate dashboard without id and containing an alert", func() {
json, err := ioutil.ReadFile("./test-data/dash-without-id.json")
So(err, ShouldBeNil)
dashJSON, err := simplejson.NewJson(json)
So(err, ShouldBeNil)
dash := m.NewDashboardFromJson(dashJSON)
extractor := NewDashAlertExtractor(dash, 1)
err = extractor.ValidateAlerts()
Convey("Should validate without error", func() {
So(err, ShouldBeNil)
})
Convey("Should fail on save", func() {
_, err := extractor.GetAlerts()
So(err, ShouldEqual, m.ErrDashboardContainsInvalidAlertData)
})
})
}) })
} }

View File

@@ -15,7 +15,11 @@ type NotifierBase struct {
} }
func NewNotifierBase(id int64, isDefault bool, name, notifierType string, model *simplejson.Json) NotifierBase { func NewNotifierBase(id int64, isDefault bool, name, notifierType string, model *simplejson.Json) NotifierBase {
uploadImage := model.Get("uploadImage").MustBool(false) uploadImage := true
value, exist := model.CheckGet("uploadImage")
if exist {
uploadImage = value.MustBool()
}
return NotifierBase{ return NotifierBase{
Id: id, Id: id,

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"testing" "testing"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/alerting"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
@@ -11,6 +12,29 @@ import (
func TestBaseNotifier(t *testing.T) { func TestBaseNotifier(t *testing.T) {
Convey("Base notifier tests", t, func() { Convey("Base notifier tests", t, func() {
Convey("default constructor for notifiers", func() {
bJson := simplejson.New()
Convey("can parse false value", func() {
bJson.Set("uploadImage", false)
base := NewNotifierBase(1, false, "name", "email", bJson)
So(base.UploadImage, ShouldBeFalse)
})
Convey("can parse true value", func() {
bJson.Set("uploadImage", true)
base := NewNotifierBase(1, false, "name", "email", bJson)
So(base.UploadImage, ShouldBeTrue)
})
Convey("default value should be true for backwards compatibility", func() {
base := NewNotifierBase(1, false, "name", "email", bJson)
So(base.UploadImage, ShouldBeTrue)
})
})
Convey("should notify", func() { Convey("should notify", func() {
Convey("pending -> ok", func() { Convey("pending -> ok", func() {
context := alerting.NewEvalContext(context.TODO(), &alerting.Rule{ context := alerting.NewEvalContext(context.TODO(), &alerting.Rule{

View File

@@ -72,7 +72,10 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
this.log.Error("Failed to create Json data", "error", err, "dingding", this.Name) this.log.Error("Failed to create Json data", "error", err, "dingding", this.Name)
} }
body, _ := bodyJSON.MarshalJSON() body, err := bodyJSON.MarshalJSON()
if err != nil {
return err
}
cmd := &m.SendWebhookSync{ cmd := &m.SendWebhookSync{
Url: this.Url, Url: this.Url,

View File

@@ -0,0 +1,281 @@
{
"title": "Influxdb",
"tags": [
"apa"
],
"style": "dark",
"timezone": "browser",
"editable": true,
"hideControls": false,
"sharedCrosshair": false,
"rows": [
{
"collapse": false,
"editable": true,
"height": "450px",
"panels": [
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
10
],
"type": "gt"
},
"query": {
"params": [
"B",
"5m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"frequency": "3s",
"handler": 1,
"name": "Influxdb",
"noDataState": "no_data",
"notifications": [
{
"id": 6
}
]
},
"alerting": {},
"aliasColors": {
"logins.count.count": "#890F02"
},
"bars": false,
"datasource": "InfluxDB",
"editable": true,
"error": false,
"fill": 1,
"grid": {},
"id": 1,
"interval": ">10s",
"isNew": true,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "connected",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"span": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"groupBy": [
{
"params": [
"$interval"
],
"type": "time"
},
{
"params": [
"datacenter"
],
"type": "tag"
},
{
"params": [
"none"
],
"type": "fill"
}
],
"hide": false,
"measurement": "logins.count",
"policy": "default",
"query": "SELECT 8 * count(\"value\") FROM \"logins.count\" WHERE $timeFilter GROUP BY time($interval), \"datacenter\" fill(none)",
"rawQuery": true,
"refId": "B",
"resultFormat": "time_series",
"select": [
[
{
"params": [
"value"
],
"type": "field"
},
{
"params": [],
"type": "count"
}
]
],
"tags": []
},
{
"groupBy": [
{
"params": [
"$interval"
],
"type": "time"
},
{
"params": [
"null"
],
"type": "fill"
}
],
"hide": true,
"measurement": "cpu",
"policy": "default",
"refId": "A",
"resultFormat": "time_series",
"select": [
[
{
"params": [
"value"
],
"type": "field"
},
{
"params": [],
"type": "mean"
}
],
[
{
"params": [
"value"
],
"type": "field"
},
{
"params": [],
"type": "sum"
}
]
],
"tags": []
}
],
"thresholds": [
{
"colorMode": "critical",
"fill": true,
"line": true,
"op": "gt",
"value": 10
}
],
"timeFrom": null,
"timeShift": null,
"title": "Panel Title",
"tooltip": {
"msResolution": false,
"ordering": "alphabetical",
"shared": true,
"sort": 0,
"value_type": "cumulative"
},
"type": "graph",
"xaxis": {
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"logBase": 1,
"max": null,
"min": null,
"show": true
}
]
},
{
"editable": true,
"error": false,
"id": 2,
"isNew": true,
"limit": 10,
"links": [],
"show": "current",
"span": 2,
"stateFilter": [
"alerting"
],
"title": "Alert status",
"type": "alertlist"
}
],
"title": "Row"
}
],
"time": {
"from": "now-5m",
"to": "now"
},
"timepicker": {
"now": true,
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d"
]
},
"templating": {
"list": []
},
"annotations": {
"list": []
},
"schemaVersion": 13,
"version": 120,
"links": [],
"gnetId": null
}

View File

@@ -279,4 +279,4 @@
"version": 120, "version": 120,
"links": [], "links": [],
"gnetId": null "gnetId": null
} }

View File

@@ -17,7 +17,7 @@ import (
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"gopkg.in/gomail.v2" gomail "gopkg.in/mail.v2"
) )
var mailQueue chan *Message var mailQueue chan *Message

View File

@@ -170,8 +170,8 @@ func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.Fil
} }
if dash.Dashboard.Id != 0 { if dash.Dashboard.Id != 0 {
fr.log.Error("provisioned dashboard json files cannot contain id") dash.Dashboard.Data.Set("id", nil)
return provisioningMetadata, nil dash.Dashboard.Id = 0
} }
if alreadyProvisioned { if alreadyProvisioned {

View File

@@ -15,9 +15,10 @@ import (
) )
var ( var (
defaultDashboards string = "./test-dashboards/folder-one" defaultDashboards = "./test-dashboards/folder-one"
brokenDashboards string = "./test-dashboards/broken-dashboards" brokenDashboards = "./test-dashboards/broken-dashboards"
oneDashboard string = "./test-dashboards/one-dashboard" oneDashboard = "./test-dashboards/one-dashboard"
containingId = "./test-dashboards/containing-id"
fakeService *fakeDashboardProvisioningService fakeService *fakeDashboardProvisioningService
) )
@@ -85,6 +86,18 @@ func TestDashboardFileReader(t *testing.T) {
So(len(fakeService.inserted), ShouldEqual, 1) So(len(fakeService.inserted), ShouldEqual, 1)
}) })
Convey("Overrides id from dashboard.json files", func() {
cfg.Options["path"] = containingId
reader, err := NewDashboardFileReader(cfg, logger)
So(err, ShouldBeNil)
err = reader.startWalkingDisk()
So(err, ShouldBeNil)
So(len(fakeService.inserted), ShouldEqual, 1)
})
Convey("Invalid configuration should return error", func() { Convey("Invalid configuration should return error", func() {
cfg := &DashboardsAsConfig{ cfg := &DashboardsAsConfig{
Name: "Default", Name: "Default",

View File

@@ -0,0 +1,68 @@
{
"title": "Grafana1",
"tags": [],
"id": 3,
"style": "dark",
"timezone": "browser",
"editable": true,
"rows": [
{
"title": "New row",
"height": "150px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 1,
"span": 12,
"editable": true,
"type": "text",
"mode": "html",
"content": "<div class=\"text-center\" style=\"padding-top: 15px\">\n<img src=\"img/logo_transparent_200x.png\"> \n</div>",
"style": {},
"title": "Welcome to"
}
]
}
],
"nav": [
{
"type": "timepicker",
"collapse": false,
"enable": true,
"status": "Stable",
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d"
],
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"now": true
}
],
"time": {
"from": "now-6h",
"to": "now"
},
"templating": {
"list": []
},
"version": 5
}

View File

@@ -223,7 +223,7 @@ func shouldRedactURLKey(s string) bool {
return strings.Contains(uppercased, "DATABASE_URL") return strings.Contains(uppercased, "DATABASE_URL")
} }
func applyEnvVariableOverrides() { func applyEnvVariableOverrides() error {
appliedEnvOverrides = make([]string, 0) appliedEnvOverrides = make([]string, 0)
for _, section := range Cfg.Sections() { for _, section := range Cfg.Sections() {
for _, key := range section.Keys() { for _, key := range section.Keys() {
@@ -238,7 +238,10 @@ func applyEnvVariableOverrides() {
envValue = "*********" envValue = "*********"
} }
if shouldRedactURLKey(envKey) { if shouldRedactURLKey(envKey) {
u, _ := url.Parse(envValue) u, err := url.Parse(envValue)
if err != nil {
return fmt.Errorf("could not parse environment variable. key: %s, value: %s. error: %v", envKey, envValue, err)
}
ui := u.User ui := u.User
if ui != nil { if ui != nil {
_, exists := ui.Password() _, exists := ui.Password()
@@ -252,6 +255,8 @@ func applyEnvVariableOverrides() {
} }
} }
} }
return nil
} }
func applyCommandLineDefaultProperties(props map[string]string) { func applyCommandLineDefaultProperties(props map[string]string) {
@@ -377,7 +382,7 @@ func loadSpecifedConfigFile(configFile string) error {
return nil return nil
} }
func loadConfiguration(args *CommandLineArgs) { func loadConfiguration(args *CommandLineArgs) error {
var err error var err error
// load config defaults // load config defaults
@@ -395,7 +400,7 @@ func loadConfiguration(args *CommandLineArgs) {
if err != nil { if err != nil {
fmt.Println(fmt.Sprintf("Failed to parse defaults.ini, %v", err)) fmt.Println(fmt.Sprintf("Failed to parse defaults.ini, %v", err))
os.Exit(1) os.Exit(1)
return return err
} }
Cfg.BlockMode = false Cfg.BlockMode = false
@@ -413,7 +418,10 @@ func loadConfiguration(args *CommandLineArgs) {
} }
// apply environment overrides // apply environment overrides
applyEnvVariableOverrides() err = applyEnvVariableOverrides()
if err != nil {
return err
}
// apply command line overrides // apply command line overrides
applyCommandLineProperties(commandLineProps) applyCommandLineProperties(commandLineProps)
@@ -424,6 +432,8 @@ func loadConfiguration(args *CommandLineArgs) {
// update data path and logging config // update data path and logging config
DataPath = makeAbsolute(Cfg.Section("paths").Key("data").String(), HomePath) DataPath = makeAbsolute(Cfg.Section("paths").Key("data").String(), HomePath)
initLogging() initLogging()
return err
} }
func pathExists(path string) bool { func pathExists(path string) bool {
@@ -471,7 +481,10 @@ func validateStaticRootPath() error {
func NewConfigContext(args *CommandLineArgs) error { func NewConfigContext(args *CommandLineArgs) error {
setHomePath(args) setHomePath(args)
loadConfiguration(args) err := loadConfiguration(args)
if err != nil {
return err
}
Env = Cfg.Section("").Key("app_mode").MustString("development") Env = Cfg.Section("").Key("app_mode").MustString("development")
InstanceName = Cfg.Section("").Key("instance_name").MustString("unknown_instance_name") InstanceName = Cfg.Section("").Key("instance_name").MustString("unknown_instance_name")

View File

@@ -37,6 +37,13 @@ func TestLoadingSettings(t *testing.T) {
So(appliedEnvOverrides, ShouldContain, "GF_SECURITY_ADMIN_PASSWORD=*********") So(appliedEnvOverrides, ShouldContain, "GF_SECURITY_ADMIN_PASSWORD=*********")
}) })
Convey("Should return an error when url is invalid", func() {
os.Setenv("GF_DATABASE_URL", "postgres.%31://grafana:secret@postgres:5432/grafana")
err := NewConfigContext(&CommandLineArgs{HomePath: "../../"})
So(err, ShouldNotBeNil)
})
Convey("Should replace password in URL when url environment is defined", func() { Convey("Should replace password in URL when url environment is defined", func() {
os.Setenv("GF_DATABASE_URL", "mysql://user:secret@localhost:3306/database") os.Setenv("GF_DATABASE_URL", "mysql://user:secret@localhost:3306/database")
NewConfigContext(&CommandLineArgs{HomePath: "../../"}) NewConfigContext(&CommandLineArgs{HomePath: "../../"})

View File

@@ -20,7 +20,7 @@
<div class="search-section__header" ng-show="section.hideHeader"></div> <div class="search-section__header" ng-show="section.hideHeader"></div>
<div ng-if="section.expanded"> <div ng-if="section.expanded">
<a ng-repeat="item in section.items" class="search-item" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}" > <a ng-repeat="item in section.items" class="search-item search-item--indent" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}" >
<div ng-click="ctrl.toggleSelection(item, $event)"> <div ng-click="ctrl.toggleSelection(item, $event)">
<gf-form-switch <gf-form-switch
ng-show="ctrl.editable" ng-show="ctrl.editable"

View File

@@ -1,36 +0,0 @@
define([
'lodash',
'jquery',
'../core_module',
],
function (_, $, coreModule) {
'use strict';
coreModule.default.directive('dashClass', function() {
return {
link: function($scope, elem) {
$scope.onAppEvent('panel-fullscreen-enter', function() {
elem.toggleClass('panel-in-fullscreen', true);
});
$scope.onAppEvent('panel-fullscreen-exit', function() {
elem.toggleClass('panel-in-fullscreen', false);
});
$scope.$watch('ctrl.dashboardViewState.state.editview', function(newValue) {
if (newValue) {
elem.toggleClass('dashboard-page--settings-opening', _.isString(newValue));
setTimeout(function() {
elem.toggleClass('dashboard-page--settings-open', _.isString(newValue));
}, 10);
} else {
elem.removeClass('dashboard-page--settings-opening');
elem.removeClass('dashboard-page--settings-open');
}
});
}
};
});
});

View File

@@ -0,0 +1,31 @@
import _ from 'lodash';
import coreModule from '../core_module';
/** @ngInject */
export function dashClass() {
return {
link: function($scope, elem) {
$scope.onAppEvent('panel-fullscreen-enter', function() {
elem.toggleClass('panel-in-fullscreen', true);
});
$scope.onAppEvent('panel-fullscreen-exit', function() {
elem.toggleClass('panel-in-fullscreen', false);
});
$scope.$watch('ctrl.dashboardViewState.state.editview', function(newValue) {
if (newValue) {
elem.toggleClass('dashboard-page--settings-opening', _.isString(newValue));
setTimeout(function() {
elem.toggleClass('dashboard-page--settings-open', _.isString(newValue));
}, 10);
} else {
elem.removeClass('dashboard-page--settings-opening');
elem.removeClass('dashboard-page--settings-open');
}
});
},
};
}
coreModule.directive('dashClass', dashClass);

View File

@@ -1,246 +0,0 @@
define([
'lodash',
'jquery',
'../core_module',
],
function (_, $, coreModule) {
'use strict';
coreModule.default.directive('metricSegment', function($compile, $sce) {
var inputTemplate = '<input type="text" data-provide="typeahead" ' +
' class="gf-form-input input-medium"' +
' spellcheck="false" style="display:none"></input>';
var linkTemplate = '<a class="gf-form-label" ng-class="segment.cssClass" ' +
'tabindex="1" give-focus="segment.focus" ng-bind-html="segment.html"></a>';
var selectTemplate = '<a class="gf-form-input gf-form-input--dropdown" ng-class="segment.cssClass" ' +
'tabindex="1" give-focus="segment.focus" ng-bind-html="segment.html"></a>';
return {
scope: {
segment: "=",
getOptions: "&",
onChange: "&",
debounce: "@",
},
link: function($scope, elem) {
var $input = $(inputTemplate);
var segment = $scope.segment;
var $button = $(segment.selectMode ? selectTemplate : linkTemplate);
var options = null;
var cancelBlur = null;
var linkMode = true;
var debounceLookup = $scope.debounce;
$input.appendTo(elem);
$button.appendTo(elem);
$scope.updateVariableValue = function(value) {
if (value === '' || segment.value === value) {
return;
}
value = _.unescape(value);
$scope.$apply(function() {
var selected = _.find($scope.altSegments, {value: value});
if (selected) {
segment.value = selected.value;
segment.html = selected.html || selected.value;
segment.fake = false;
segment.expandable = selected.expandable;
if (selected.type) {
segment.type = selected.type;
}
}
else if (segment.custom !== 'false') {
segment.value = value;
segment.html = $sce.trustAsHtml(value);
segment.expandable = true;
segment.fake = false;
}
$scope.onChange();
});
};
$scope.switchToLink = function(fromClick) {
if (linkMode && !fromClick) { return; }
clearTimeout(cancelBlur);
cancelBlur = null;
linkMode = true;
$input.hide();
$button.show();
$scope.updateVariableValue($input.val());
};
$scope.inputBlur = function() {
// happens long before the click event on the typeahead options
// need to have long delay because the blur
cancelBlur = setTimeout($scope.switchToLink, 200);
};
$scope.source = function(query, callback) {
$scope.$apply(function() {
$scope.getOptions({ $query: query }).then(function(altSegments) {
$scope.altSegments = altSegments;
options = _.map($scope.altSegments, function(alt) {
return _.escape(alt.value);
});
// add custom values
if (segment.custom !== 'false') {
if (!segment.fake && _.indexOf(options, segment.value) === -1) {
options.unshift(segment.value);
}
}
callback(options);
});
});
};
$scope.updater = function(value) {
if (value === segment.value) {
clearTimeout(cancelBlur);
$input.focus();
return value;
}
$input.val(value);
$scope.switchToLink(true);
return value;
};
$scope.matcher = function(item) {
var str = this.query;
if (str[0] === '/') { str = str.substring(1); }
if (str[str.length - 1] === '/') { str = str.substring(0, str.length-1); }
try {
return item.toLowerCase().match(str.toLowerCase());
} catch(e) {
return false;
}
};
$input.attr('data-provide', 'typeahead');
$input.typeahead({ source: $scope.source, minLength: 0, items: 10000, updater: $scope.updater, matcher: $scope.matcher });
var typeahead = $input.data('typeahead');
typeahead.lookup = function () {
this.query = this.$element.val() || '';
var items = this.source(this.query, $.proxy(this.process, this));
return items ? this.process(items) : items;
};
if (debounceLookup) {
typeahead.lookup = _.debounce(typeahead.lookup, 500, {leading: true});
}
$button.keydown(function(evt) {
// trigger typeahead on down arrow or enter key
if (evt.keyCode === 40 || evt.keyCode === 13) {
$button.click();
}
});
$button.click(function() {
options = null;
$input.css('width', (Math.max($button.width(), 80) + 16) + 'px');
$button.hide();
$input.show();
$input.focus();
linkMode = false;
var typeahead = $input.data('typeahead');
if (typeahead) {
$input.val('');
typeahead.lookup();
}
});
$input.blur($scope.inputBlur);
$compile(elem.contents())($scope);
}
};
});
coreModule.default.directive('metricSegmentModel', function(uiSegmentSrv, $q) {
return {
template: '<metric-segment segment="segment" get-options="getOptionsInternal()" on-change="onSegmentChange()"></metric-segment>',
restrict: 'E',
scope: {
property: "=",
options: "=",
getOptions: "&",
onChange: "&",
},
link: {
pre: function postLink($scope, elem, attrs) {
var cachedOptions;
$scope.valueToSegment = function(value) {
var option = _.find($scope.options, {value: value});
var segment = {
cssClass: attrs.cssClass,
custom: attrs.custom,
value: option ? option.text : value,
selectMode: attrs.selectMode,
};
return uiSegmentSrv.newSegment(segment);
};
$scope.getOptionsInternal = function() {
if ($scope.options) {
cachedOptions = $scope.options;
return $q.when(_.map($scope.options, function(option) {
return {value: option.text};
}));
} else {
return $scope.getOptions().then(function(options) {
cachedOptions = options;
return _.map(options, function(option) {
if (option.html) {
return option;
}
return {value: option.text};
});
});
}
};
$scope.onSegmentChange = function() {
if (cachedOptions) {
var option = _.find(cachedOptions, {text: $scope.segment.value});
if (option && option.value !== $scope.property) {
$scope.property = option.value;
} else if (attrs.custom !== 'false') {
$scope.property = $scope.segment.value;
}
} else {
$scope.property = $scope.segment.value;
}
// needs to call this after digest so
// property is synced with outerscope
$scope.$$postDigest(function() {
$scope.$apply(function() {
$scope.onChange();
});
});
};
$scope.segment = $scope.valueToSegment($scope.property);
}
}
};
});
});

View File

@@ -0,0 +1,263 @@
import _ from 'lodash';
import $ from 'jquery';
import coreModule from '../core_module';
/** @ngInject */
export function metricSegment($compile, $sce) {
let inputTemplate =
'<input type="text" data-provide="typeahead" ' +
' class="gf-form-input input-medium"' +
' spellcheck="false" style="display:none"></input>';
let linkTemplate =
'<a class="gf-form-label" ng-class="segment.cssClass" ' +
'tabindex="1" give-focus="segment.focus" ng-bind-html="segment.html"></a>';
let selectTemplate =
'<a class="gf-form-input gf-form-input--dropdown" ng-class="segment.cssClass" ' +
'tabindex="1" give-focus="segment.focus" ng-bind-html="segment.html"></a>';
return {
scope: {
segment: '=',
getOptions: '&',
onChange: '&',
debounce: '@',
},
link: function($scope, elem) {
let $input = $(inputTemplate);
let segment = $scope.segment;
let $button = $(segment.selectMode ? selectTemplate : linkTemplate);
let options = null;
let cancelBlur = null;
let linkMode = true;
let debounceLookup = $scope.debounce;
$input.appendTo(elem);
$button.appendTo(elem);
$scope.updateVariableValue = function(value) {
if (value === '' || segment.value === value) {
return;
}
value = _.unescape(value);
$scope.$apply(function() {
let selected = _.find($scope.altSegments, { value: value });
if (selected) {
segment.value = selected.value;
segment.html = selected.html || selected.value;
segment.fake = false;
segment.expandable = selected.expandable;
if (selected.type) {
segment.type = selected.type;
}
} else if (segment.custom !== 'false') {
segment.value = value;
segment.html = $sce.trustAsHtml(value);
segment.expandable = true;
segment.fake = false;
}
$scope.onChange();
});
};
$scope.switchToLink = function(fromClick) {
if (linkMode && !fromClick) {
return;
}
clearTimeout(cancelBlur);
cancelBlur = null;
linkMode = true;
$input.hide();
$button.show();
$scope.updateVariableValue($input.val());
};
$scope.inputBlur = function() {
// happens long before the click event on the typeahead options
// need to have long delay because the blur
cancelBlur = setTimeout($scope.switchToLink, 200);
};
$scope.source = function(query, callback) {
$scope.$apply(function() {
$scope.getOptions({ $query: query }).then(function(altSegments) {
$scope.altSegments = altSegments;
options = _.map($scope.altSegments, function(alt) {
return _.escape(alt.value);
});
// add custom values
if (segment.custom !== 'false') {
if (!segment.fake && _.indexOf(options, segment.value) === -1) {
options.unshift(segment.value);
}
}
callback(options);
});
});
};
$scope.updater = function(value) {
if (value === segment.value) {
clearTimeout(cancelBlur);
$input.focus();
return value;
}
$input.val(value);
$scope.switchToLink(true);
return value;
};
$scope.matcher = function(item) {
let str = this.query;
if (str[0] === '/') {
str = str.substring(1);
}
if (str[str.length - 1] === '/') {
str = str.substring(0, str.length - 1);
}
try {
return item.toLowerCase().match(str.toLowerCase());
} catch (e) {
return false;
}
};
$input.attr('data-provide', 'typeahead');
$input.typeahead({
source: $scope.source,
minLength: 0,
items: 10000,
updater: $scope.updater,
matcher: $scope.matcher,
});
let typeahead = $input.data('typeahead');
typeahead.lookup = function() {
this.query = this.$element.val() || '';
let items = this.source(this.query, $.proxy(this.process, this));
return items ? this.process(items) : items;
};
if (debounceLookup) {
typeahead.lookup = _.debounce(typeahead.lookup, 500, { leading: true });
}
$button.keydown(function(evt) {
// trigger typeahead on down arrow or enter key
if (evt.keyCode === 40 || evt.keyCode === 13) {
$button.click();
}
});
$button.click(function() {
options = null;
$input.css('width', Math.max($button.width(), 80) + 16 + 'px');
$button.hide();
$input.show();
$input.focus();
linkMode = false;
let typeahead = $input.data('typeahead');
if (typeahead) {
$input.val('');
typeahead.lookup();
}
});
$input.blur($scope.inputBlur);
$compile(elem.contents())($scope);
},
};
}
/** @ngInject */
export function metricSegmentModel(uiSegmentSrv, $q) {
return {
template:
'<metric-segment segment="segment" get-options="getOptionsInternal()" on-change="onSegmentChange()"></metric-segment>',
restrict: 'E',
scope: {
property: '=',
options: '=',
getOptions: '&',
onChange: '&',
},
link: {
pre: function postLink($scope, elem, attrs) {
let cachedOptions;
$scope.valueToSegment = function(value) {
let option = _.find($scope.options, { value: value });
let segment = {
cssClass: attrs.cssClass,
custom: attrs.custom,
value: option ? option.text : value,
selectMode: attrs.selectMode,
};
return uiSegmentSrv.newSegment(segment);
};
$scope.getOptionsInternal = function() {
if ($scope.options) {
cachedOptions = $scope.options;
return $q.when(
_.map($scope.options, function(option) {
return { value: option.text };
})
);
} else {
return $scope.getOptions().then(function(options) {
cachedOptions = options;
return _.map(options, function(option) {
if (option.html) {
return option;
}
return { value: option.text };
});
});
}
};
$scope.onSegmentChange = function() {
if (cachedOptions) {
let option = _.find(cachedOptions, { text: $scope.segment.value });
if (option && option.value !== $scope.property) {
$scope.property = option.value;
} else if (attrs.custom !== 'false') {
$scope.property = $scope.segment.value;
}
} else {
$scope.property = $scope.segment.value;
}
// needs to call this after digest so
// property is synced with outerscope
$scope.$$postDigest(function() {
$scope.$apply(function() {
$scope.onChange();
});
});
};
$scope.segment = $scope.valueToSegment($scope.property);
},
},
};
}
coreModule.directive('metricSegment', metricSegment);
coreModule.directive('metricSegmentModel', metricSegmentModel);

View File

@@ -1,13 +0,0 @@
define([
'./alert_srv',
'./util_srv',
'./context_srv',
'./timer',
'./analytics',
'./popover_srv',
'./segment_srv',
'./backend_srv',
'./dynamic_directive_srv',
'./bridge_srv'
],
function () {});

View File

@@ -0,0 +1,10 @@
import './alert_srv';
import './util_srv';
import './context_srv';
import './timer';
import './analytics';
import './popover_srv';
import './segment_srv';
import './backend_srv';
import './dynamic_directive_srv';
import './bridge_srv';

View File

@@ -10,6 +10,7 @@ import 'mousetrap-global-bind';
export class KeybindingSrv { export class KeybindingSrv {
helpModal: boolean; helpModal: boolean;
modalOpen = false; modalOpen = false;
timepickerOpen = false;
/** @ngInject */ /** @ngInject */
constructor(private $rootScope, private $location) { constructor(private $rootScope, private $location) {
@@ -22,6 +23,8 @@ export class KeybindingSrv {
this.setupGlobal(); this.setupGlobal();
appEvents.on('show-modal', () => (this.modalOpen = true)); appEvents.on('show-modal', () => (this.modalOpen = true));
$rootScope.onAppEvent('timepickerOpen', () => (this.timepickerOpen = true));
$rootScope.onAppEvent('timepickerClosed', () => (this.timepickerOpen = false));
} }
setupGlobal() { setupGlobal() {
@@ -73,7 +76,12 @@ export class KeybindingSrv {
appEvents.emit('hide-modal'); appEvents.emit('hide-modal');
if (!this.modalOpen) { if (!this.modalOpen) {
this.$rootScope.appEvent('panel-change-view', { fullscreen: false, edit: false }); if (this.timepickerOpen) {
this.$rootScope.appEvent('closeTimepicker');
this.timepickerOpen = false;
} else {
this.$rootScope.appEvent('panel-change-view', { fullscreen: false, edit: false });
}
} else { } else {
this.modalOpen = false; this.modalOpen = false;
} }

View File

@@ -1,111 +0,0 @@
define([
'angular',
'lodash',
'../core_module',
],
function (angular, _, coreModule) {
'use strict';
coreModule.default.service('uiSegmentSrv', function($sce, templateSrv) {
var self = this;
function MetricSegment(options) {
if (options === '*' || options.value === '*') {
this.value = '*';
this.html = $sce.trustAsHtml('<i class="fa fa-asterisk"><i>');
this.type = options.type;
this.expandable = true;
return;
}
if (_.isString(options)) {
this.value = options;
this.html = $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(this.value));
return;
}
// temp hack to work around legacy inconsistency in segment model
this.text = options.value;
this.cssClass = options.cssClass;
this.custom = options.custom;
this.type = options.type;
this.fake = options.fake;
this.value = options.value;
this.selectMode = options.selectMode;
this.type = options.type;
this.expandable = options.expandable;
this.html = options.html || $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(this.value));
}
this.getSegmentForValue = function(value, fallbackText) {
if (value) {
return this.newSegment(value);
} else {
return this.newSegment({value: fallbackText, fake: true});
}
};
this.newSelectMeasurement = function() {
return new MetricSegment({value: 'select measurement', fake: true});
};
this.newFake = function(text, type, cssClass) {
return new MetricSegment({value: text, fake: true, type: type, cssClass: cssClass});
};
this.newSegment = function(options) {
return new MetricSegment(options);
};
this.newKey = function(key) {
return new MetricSegment({value: key, type: 'key', cssClass: 'query-segment-key' });
};
this.newKeyValue = function(value) {
return new MetricSegment({value: value, type: 'value', cssClass: 'query-segment-value' });
};
this.newCondition = function(condition) {
return new MetricSegment({value: condition, type: 'condition', cssClass: 'query-keyword' });
};
this.newOperator = function(op) {
return new MetricSegment({value: op, type: 'operator', cssClass: 'query-segment-operator' });
};
this.newOperators = function(ops) {
return _.map(ops, function(op) {
return new MetricSegment({value: op, type: 'operator', cssClass: 'query-segment-operator' });
});
};
this.transformToSegments = function(addTemplateVars, variableTypeFilter) {
return function(results) {
var segments = _.map(results, function(segment) {
return self.newSegment({value: segment.text, expandable: segment.expandable});
});
if (addTemplateVars) {
_.each(templateSrv.variables, function(variable) {
if (variableTypeFilter === void 0 || variableTypeFilter === variable.type) {
segments.unshift(self.newSegment({ type: 'value', value: '$' + variable.name, expandable: true }));
}
});
}
return segments;
};
};
this.newSelectMetric = function() {
return new MetricSegment({value: 'select metric', fake: true});
};
this.newPlusButton = function() {
return new MetricSegment({fake: true, html: '<i class="fa fa-plus "></i>', type: 'plus-button', cssClass: 'query-part' });
};
});
});

View File

@@ -0,0 +1,111 @@
import _ from 'lodash';
import coreModule from '../core_module';
/** @ngInject */
export function uiSegmentSrv($sce, templateSrv) {
let self = this;
function MetricSegment(options) {
if (options === '*' || options.value === '*') {
this.value = '*';
this.html = $sce.trustAsHtml('<i class="fa fa-asterisk"><i>');
this.type = options.type;
this.expandable = true;
return;
}
if (_.isString(options)) {
this.value = options;
this.html = $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(this.value));
return;
}
// temp hack to work around legacy inconsistency in segment model
this.text = options.value;
this.cssClass = options.cssClass;
this.custom = options.custom;
this.type = options.type;
this.fake = options.fake;
this.value = options.value;
this.selectMode = options.selectMode;
this.type = options.type;
this.expandable = options.expandable;
this.html = options.html || $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(this.value));
}
this.getSegmentForValue = function(value, fallbackText) {
if (value) {
return this.newSegment(value);
} else {
return this.newSegment({ value: fallbackText, fake: true });
}
};
this.newSelectMeasurement = function() {
return new MetricSegment({ value: 'select measurement', fake: true });
};
this.newFake = function(text, type, cssClass) {
return new MetricSegment({ value: text, fake: true, type: type, cssClass: cssClass });
};
this.newSegment = function(options) {
return new MetricSegment(options);
};
this.newKey = function(key) {
return new MetricSegment({ value: key, type: 'key', cssClass: 'query-segment-key' });
};
this.newKeyValue = function(value) {
return new MetricSegment({ value: value, type: 'value', cssClass: 'query-segment-value' });
};
this.newCondition = function(condition) {
return new MetricSegment({ value: condition, type: 'condition', cssClass: 'query-keyword' });
};
this.newOperator = function(op) {
return new MetricSegment({ value: op, type: 'operator', cssClass: 'query-segment-operator' });
};
this.newOperators = function(ops) {
return _.map(ops, function(op) {
return new MetricSegment({ value: op, type: 'operator', cssClass: 'query-segment-operator' });
});
};
this.transformToSegments = function(addTemplateVars, variableTypeFilter) {
return function(results) {
let segments = _.map(results, function(segment) {
return self.newSegment({ value: segment.text, expandable: segment.expandable });
});
if (addTemplateVars) {
_.each(templateSrv.variables, function(variable) {
if (variableTypeFilter === void 0 || variableTypeFilter === variable.type) {
segments.unshift(self.newSegment({ type: 'value', value: '$' + variable.name, expandable: true }));
}
});
}
return segments;
};
};
this.newSelectMetric = function() {
return new MetricSegment({ value: 'select metric', fake: true });
};
this.newPlusButton = function() {
return new MetricSegment({
fake: true,
html: '<i class="fa fa-plus "></i>',
type: 'plus-button',
cssClass: 'query-part',
});
};
}
coreModule.service('uiSegmentSrv', uiSegmentSrv);

View File

@@ -43,6 +43,7 @@ export class AlertNotificationEditCtrl {
return this.backendSrv.get(`/api/alert-notifications/${this.$routeParams.id}`).then(result => { return this.backendSrv.get(`/api/alert-notifications/${this.$routeParams.id}`).then(result => {
this.navModel.breadcrumbs.push({ text: result.name }); this.navModel.breadcrumbs.push({ text: result.name });
this.navModel.node = { text: result.name }; this.navModel.node = { text: result.name };
result.settings = _.defaults(result.settings, this.defaults.settings);
return result; return result;
}); });
}) })
@@ -89,7 +90,7 @@ export class AlertNotificationEditCtrl {
} }
typeChanged() { typeChanged() {
this.model.settings = {}; this.model.settings = _.defaults({}, this.defaults.settings);
this.notifierTemplateId = this.getNotifierTemplateId(this.model.type); this.notifierTemplateId = this.getNotifierTemplateId(this.model.type);
} }

View File

@@ -1,9 +1,5 @@
export class ThresholdMapper { export class ThresholdMapper {
static alertToGraphThresholds(panel) { static alertToGraphThresholds(panel) {
if (panel.type !== 'graph') {
return false;
}
for (var i = 0; i < panel.alert.conditions.length; i++) { for (var i = 0; i < panel.alert.conditions.length; i++) {
let condition = panel.alert.conditions[i]; let condition = panel.alert.conditions[i];
if (condition.type !== 'query') { if (condition.type !== 'query') {

View File

@@ -1,15 +0,0 @@
define([
'./panellinks/module',
'./dashlinks/module',
'./annotations/all',
'./templating/all',
'./plugins/all',
'./dashboard/all',
'./playlist/all',
'./snapshot/all',
'./panel/all',
'./org/all',
'./admin/admin',
'./alerting/all',
'./styleguide/styleguide',
], function () {});

View File

@@ -0,0 +1,13 @@
import './panellinks/module';
import './dashlinks/module';
import './annotations/all';
import './templating/all';
import './plugins/all';
import './dashboard/all';
import './playlist/all';
import './snapshot/all';
import './panel/all';
import './org/all';
import './admin/admin';
import './alerting/all';
import './styleguide/styleguide';

View File

@@ -22,7 +22,6 @@ export class TimePickerCtrl {
refresh: any; refresh: any;
isUtc: boolean; isUtc: boolean;
firstDayOfWeek: number; firstDayOfWeek: number;
closeDropdown: any;
isOpen: boolean; isOpen: boolean;
/** @ngInject */ /** @ngInject */
@@ -32,6 +31,7 @@ export class TimePickerCtrl {
$rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope); $rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope);
$rootScope.onAppEvent('shift-time-backward', () => this.move(-1), $scope); $rootScope.onAppEvent('shift-time-backward', () => this.move(-1), $scope);
$rootScope.onAppEvent('refresh', this.onRefresh.bind(this), $scope); $rootScope.onAppEvent('refresh', this.onRefresh.bind(this), $scope);
$rootScope.onAppEvent('closeTimepicker', this.openDropdown.bind(this), $scope);
// init options // init options
this.panel = this.dashboard.timepicker; this.panel = this.dashboard.timepicker;
@@ -96,7 +96,7 @@ export class TimePickerCtrl {
openDropdown() { openDropdown() {
if (this.isOpen) { if (this.isOpen) {
this.isOpen = false; this.closeDropdown();
return; return;
} }
@@ -112,6 +112,12 @@ export class TimePickerCtrl {
this.refresh.options.unshift({ text: 'off' }); this.refresh.options.unshift({ text: 'off' });
this.isOpen = true; this.isOpen = true;
this.$rootScope.appEvent('timepickerOpen');
}
closeDropdown() {
this.isOpen = false;
this.$rootScope.appEvent('timepickerClosed');
} }
applyCustom() { applyCustom() {
@@ -120,7 +126,7 @@ export class TimePickerCtrl {
} }
this.timeSrv.setTime(this.editTimeRaw); this.timeSrv.setTime(this.editTimeRaw);
this.isOpen = false; this.closeDropdown();
} }
absoluteFromChanged() { absoluteFromChanged() {
@@ -143,7 +149,7 @@ export class TimePickerCtrl {
} }
this.timeSrv.setTime(range); this.timeSrv.setTime(range);
this.isOpen = false; this.closeDropdown();
} }
} }

View File

@@ -35,12 +35,12 @@ export class Tracker {
$window.onbeforeunload = () => { $window.onbeforeunload = () => {
if (this.ignoreChanges()) { if (this.ignoreChanges()) {
return null; return undefined;
} }
if (this.hasChanges()) { if (this.hasChanges()) {
return 'There are unsaved changes to this dashboard'; return 'There are unsaved changes to this dashboard';
} }
return null; return undefined;
}; };
scope.$on('$locationChangeStart', (event, next) => { scope.$on('$locationChangeStart', (event, next) => {

View File

@@ -1,9 +0,0 @@
define([
'./panel_header',
'./panel_directive',
'./solo_panel_ctrl',
'./query_ctrl',
'./panel_editor_tab',
'./query_editor_row',
'./query_troubleshooter',
], function () {});

View File

@@ -0,0 +1,7 @@
import './panel_header';
import './panel_directive';
import './solo_panel_ctrl';
import './query_ctrl';
import './panel_editor_tab';
import './query_editor_row';
import './query_troubleshooter';

View File

@@ -1,7 +0,0 @@
define([
'./playlists_ctrl',
'./playlist_search',
'./playlist_srv',
'./playlist_edit_ctrl',
'./playlist_routes'
], function () {});

View File

@@ -0,0 +1,5 @@
import './playlists_ctrl';
import './playlist_search';
import './playlist_srv';
import './playlist_edit_ctrl';
import './playlist_routes';

View File

@@ -1,39 +0,0 @@
define([
'angular',
'lodash'
],
function (angular) {
'use strict';
var module = angular.module('grafana.routes');
module.config(function($routeProvider) {
$routeProvider
.when('/playlists', {
templateUrl: 'public/app/features/playlist/partials/playlists.html',
controllerAs: 'ctrl',
controller : 'PlaylistsCtrl'
})
.when('/playlists/create', {
templateUrl: 'public/app/features/playlist/partials/playlist.html',
controllerAs: 'ctrl',
controller : 'PlaylistEditCtrl'
})
.when('/playlists/edit/:id', {
templateUrl: 'public/app/features/playlist/partials/playlist.html',
controllerAs: 'ctrl',
controller : 'PlaylistEditCtrl'
})
.when('/playlists/play/:id', {
templateUrl: 'public/app/features/playlist/partials/playlists.html',
controllerAs: 'ctrl',
controller : 'PlaylistsCtrl',
resolve: {
init: function(playlistSrv, $route) {
var playlistId = $route.current.params.id;
playlistSrv.start(playlistId);
}
}
});
});
});

View File

@@ -0,0 +1,34 @@
import angular from 'angular';
/** @ngInject */
function grafanaRoutes($routeProvider) {
$routeProvider
.when('/playlists', {
templateUrl: 'public/app/features/playlist/partials/playlists.html',
controllerAs: 'ctrl',
controller: 'PlaylistsCtrl',
})
.when('/playlists/create', {
templateUrl: 'public/app/features/playlist/partials/playlist.html',
controllerAs: 'ctrl',
controller: 'PlaylistEditCtrl',
})
.when('/playlists/edit/:id', {
templateUrl: 'public/app/features/playlist/partials/playlist.html',
controllerAs: 'ctrl',
controller: 'PlaylistEditCtrl',
})
.when('/playlists/play/:id', {
templateUrl: 'public/app/features/playlist/partials/playlists.html',
controllerAs: 'ctrl',
controller: 'PlaylistsCtrl',
resolve: {
init: function(playlistSrv, $route) {
let playlistId = $route.current.params.id;
playlistSrv.start(playlistId);
},
},
});
}
angular.module('grafana.routes').config(grafanaRoutes);

View File

@@ -1,5 +1,3 @@
<div class="gf-form-group"> <div class="gf-form-group">
<h3 class="page-heading">HTTP</h3> <h3 class="page-heading">HTTP</h3>
<div class="gf-form-group"> <div class="gf-form-group">
@@ -13,12 +11,12 @@
<info-popover mode="right-absolute"> <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'"> <span ng-show="current.access === 'direct'">
Your access method is <em>Direct</em>, this means the URL Your access method is <em>Browser</em>, this means the URL
needs to be accessible from the browser. needs to be accessible from the browser.
</span> </span>
<span ng-show="current.access === 'proxy'"> <span ng-show="current.access === 'proxy'">
Your access method is currently <em>Proxy</em>, this means the URL Your access method is <em>Server</em>, this means the URL
needs to be accessible from the grafana backend. needs to be accessible from the grafana backend/server.
</span> </span>
</info-popover> </info-popover>
</div> </div>
@@ -27,14 +25,38 @@
<div class="gf-form-inline"> <div class="gf-form-inline">
<div class="gf-form max-width-30"> <div class="gf-form max-width-30">
<span class="gf-form-label width-7">Access</span> <span class="gf-form-label width-7">Access</span>
<div class="gf-form-select-wrapper gf-form-select-wrapper--has-help-icon max-width-24"> <div class="gf-form-select-wrapper max-width-24">
<select class="gf-form-input" ng-model="current.access" ng-options="f for f in ['direct', 'proxy']"></select> <select class="gf-form-input" ng-model="current.access" ng-options="f.key as f.value for f in [{key: 'proxy', value: 'Server (Default)'}, { key: 'direct', value: 'Browser'}]"></select>
<info-popover mode="right-absolute">
Direct = URL is used directly from browser<br>
Proxy = Grafana backend will proxy the request
</info-popover>
</div> </div>
</div> </div>
<div class="gf-form">
<label class="gf-form-label query-keyword pointer" ng-click="ctrl.showAccessHelp = !ctrl.showAccessHelp">
Help&nbsp;
<i class="fa fa-caret-down" ng-show="ctrl.showAccessHelp"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showAccessHelp">&nbsp;</i>
</label>
</div>
</div>
<div class="alert alert-info" ng-show="ctrl.showAccessHelp">
<div class="alert-body">
<p>
Access mode controls how requests to the data source will be handled.
<strong><i>Server</i></strong> should be the preferred way if nothing else stated.
</p>
<div class="alert-title">Server access mode (Default):</div>
<p>
All requests will be made from the browser to Grafana backend/server which in turn will forward the requests to the data source
and by that circumvent possible Cross-Origin Resource Sharing (CORS) requirements.
The URL needs to be accessible from the grafana backend/server if you select this access mode.
</p>
<div class="alert-title">Browser access mode:</div>
<p>
All requests will be made from the browser directly to the data source and may be subject to
Cross-Origin Resource Sharing (CORS) requirements. The URL needs to be accessible from the browser if you select this
access mode.
</p>
</div>
</div> </div>
</div> </div>
@@ -135,4 +157,3 @@
</div> </div>
</div> </div>
</div> </div>

View File

@@ -23,6 +23,8 @@ export class VariableEditorCtrl {
{ value: 2, text: 'Alphabetical (desc)' }, { value: 2, text: 'Alphabetical (desc)' },
{ value: 3, text: 'Numerical (asc)' }, { value: 3, text: 'Numerical (asc)' },
{ value: 4, text: 'Numerical (desc)' }, { value: 4, text: 'Numerical (desc)' },
{ value: 5, text: 'Alphabetical (case-insensitive, asc)' },
{ value: 6, text: 'Alphabetical (case-insensitive, desc)' },
]; ];
$scope.hideOptions = [{ value: 0, text: '' }, { value: 1, text: 'Label' }, { value: 2, text: 'Variable' }]; $scope.hideOptions = [{ value: 0, text: '' }, { value: 1, text: 'Label' }, { value: 2, text: 'Variable' }];

View File

@@ -197,6 +197,8 @@ export class QueryVariable implements Variable {
return parseInt(matches[1], 10); return parseInt(matches[1], 10);
} }
}); });
} else if (sortType === 3) {
options = _.sortBy(options, opt => { return _.toLower(opt.text); });
} }
if (reverseSort) { if (reverseSort) {

View File

@@ -40,11 +40,11 @@ describe('QueryVariable', () => {
}); });
describe('can convert and sort metric names', () => { describe('can convert and sort metric names', () => {
var variable = new QueryVariable({}, null, null, null, null); const variable = new QueryVariable({}, null, null, null, null);
variable.sort = 3; // Numerical (asc) let input;
describe('can sort a mixed array of metric variables', () => { beforeEach(() => {
var input = [ input = [
{ text: '0', value: '0' }, { text: '0', value: '0' },
{ text: '1', value: '1' }, { text: '1', value: '1' },
{ text: null, value: 3 }, { text: null, value: 3 },
@@ -58,11 +58,18 @@ describe('QueryVariable', () => {
{ text: '', value: undefined }, { text: '', value: undefined },
{ text: undefined, value: '' }, { text: undefined, value: '' },
]; ];
});
describe('can sort a mixed array of metric variables in numeric order', () => {
let result;
beforeEach(() => {
variable.sort = 3; // Numerical (asc)
result = variable.metricNamesToVariableValues(input);
});
var result = variable.metricNamesToVariableValues(input);
it('should return in same order', () => { it('should return in same order', () => {
var i = 0; var i = 0;
expect(result.length).toBe(11); expect(result.length).toBe(11);
expect(result[i++].text).toBe(''); expect(result[i++].text).toBe('');
expect(result[i++].text).toBe('0'); expect(result[i++].text).toBe('0');
@@ -73,5 +80,27 @@ describe('QueryVariable', () => {
expect(result[i++].text).toBe('6'); expect(result[i++].text).toBe('6');
}); });
}); });
describe('can sort a mixed array of metric variables in alphabetical order', () => {
let result;
beforeEach(() => {
variable.sort = 5; // Alphabetical CI (asc)
result = variable.metricNamesToVariableValues(input);
});
it('should return in same order', () => {
var i = 0;
console.log(result);
expect(result.length).toBe(11);
expect(result[i++].text).toBe('');
expect(result[i++].text).toBe('0');
expect(result[i++].text).toBe('1');
expect(result[i++].text).toBe('10');
expect(result[i++].text).toBe('3');
expect(result[i++].text).toBe('4');
expect(result[i++].text).toBe('5');
});
});
}); });
}); });

View File

@@ -4,6 +4,7 @@ import $ from 'jquery';
import rst2html from 'rst2html'; import rst2html from 'rst2html';
import Drop from 'tether-drop'; import Drop from 'tether-drop';
/** @ngInject */
export function graphiteAddFunc($compile) { export function graphiteAddFunc($compile) {
const inputTemplate = const inputTemplate =
'<input type="text"' + ' class="gf-form-input"' + ' spellcheck="false" style="display:none"></input>'; '<input type="text"' + ' class="gf-form-input"' + ' spellcheck="false" style="display:none"></input>';

View File

@@ -3,6 +3,7 @@ import _ from 'lodash';
import $ from 'jquery'; import $ from 'jquery';
import rst2html from 'rst2html'; import rst2html from 'rst2html';
/** @ngInject */
export function graphiteFuncEditor($compile, templateSrv, popoverSrv) { export function graphiteFuncEditor($compile, templateSrv, popoverSrv) {
const funcSpanTemplate = '<a ng-click="">{{func.def.name}}</a><span>(</span>'; const funcSpanTemplate = '<a ng-click="">{{func.def.name}}</a><span>(</span>';
const paramTemplate = const paramTemplate =

View File

@@ -28,7 +28,7 @@ An annotation is an event that is overlayed on top of graphs. The query can have
Macros: Macros:
- $__time(column) -&gt; column AS time - $__time(column) -&gt; column AS time
- $__timeEpoch(column) -&gt; DATEDIFF(second, '1970-01-01', column) AS time - $__timeEpoch(column) -&gt; DATEDIFF(second, '1970-01-01', column) AS time
- $__timeFilter(column) -&gt; column &gt;= DATEADD(s, 18446744066914186738, '1970-01-01') AND column &t;= DATEADD(s, 18446744066914187038, '1970-01-01') - $__timeFilter(column) -&gt; column &gt;= DATEADD(s, 18446744066914186738, '1970-01-01') AND column &lt;= DATEADD(s, 18446744066914187038, '1970-01-01')
- $__unixEpochFilter(column) -&gt; column &gt;= 1492750877 AND column &lt;= 1492750877 - $__unixEpochFilter(column) -&gt; column &gt;= 1492750877 AND column &lt;= 1492750877
Or build your own conditionals using these macros which just return the values: Or build your own conditionals using these macros which just return the values:

View File

@@ -49,7 +49,7 @@ Table:
Macros: Macros:
- $__time(column) -&gt; column AS time - $__time(column) -&gt; column AS time
- $__timeEpoch(column) -&gt; DATEDIFF(second, '1970-01-01', column) AS time - $__timeEpoch(column) -&gt; DATEDIFF(second, '1970-01-01', column) AS time
- $__timeFilter(column) -&gt; column &gt;= DATEADD(s, 18446744066914186738, '1970-01-01') AND column &t;= DATEADD(s, 18446744066914187038, '1970-01-01') - $__timeFilter(column) -&gt; column &gt;= DATEADD(s, 18446744066914186738, '1970-01-01') AND column &lt;= DATEADD(s, 18446744066914187038, '1970-01-01')
- $__unixEpochFilter(column) -&gt; column &gt;= 1492750877 AND column &lt;= 1492750877 - $__unixEpochFilter(column) -&gt; column &gt;= 1492750877 AND column &lt;= 1492750877
- $__timeGroup(column, '5m'[, fillvalue]) -&gt; CAST(ROUND(DATEDIFF(second, '1970-01-01', column)/300.0, 0) as bigint)*300. Providing a <i>fillValue</i> of <i>NULL</i> or floating value will automatically fill empty series in timerange with that value. - $__timeGroup(column, '5m'[, fillvalue]) -&gt; CAST(ROUND(DATEDIFF(second, '1970-01-01', column)/300.0, 0) as bigint)*300. Providing a <i>fillValue</i> of <i>NULL</i> or floating value will automatically fill empty series in timerange with that value.

View File

@@ -6,8 +6,12 @@ import * as dateMath from 'app/core/utils/datemath';
import PrometheusMetricFindQuery from './metric_find_query'; import PrometheusMetricFindQuery from './metric_find_query';
import { ResultTransformer } from './result_transformer'; import { ResultTransformer } from './result_transformer';
function prometheusSpecialRegexEscape(value) { export function prometheusRegularEscape(value) {
return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\\\$&'); return value.replace(/'/g, "\\\\'");
}
export function prometheusSpecialRegexEscape(value) {
return prometheusRegularEscape(value.replace(/\\/g, '\\\\\\\\').replace(/[$^*{}\[\]+?.()]/g, '\\\\$&'));
} }
export class PrometheusDatasource { export class PrometheusDatasource {
@@ -80,7 +84,7 @@ export class PrometheusDatasource {
interpolateQueryExpr(value, variable, defaultFormatFn) { interpolateQueryExpr(value, variable, defaultFormatFn) {
// if no multi or include all do not regexEscape // if no multi or include all do not regexEscape
if (!variable.multi && !variable.includeAll) { if (!variable.multi && !variable.includeAll) {
return value; return prometheusRegularEscape(value);
} }
if (typeof value === 'string') { if (typeof value === 'string') {

View File

@@ -14,7 +14,7 @@
data-min-length=0 data-items=1000 ng-model-onblur ng-change="ctrl.refreshMetricData()"> data-min-length=0 data-items=1000 ng-model-onblur ng-change="ctrl.refreshMetricData()">
</input> </input>
<info-popover mode="right-absolute"> <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 Controls the name of the time series, using name or pattern. For example <span ng-non-bindable>{{hostname}}</span> will be replaced with label value for
the label hostname. the label hostname.
</info-popover> </info-popover>
</div> </div>

View File

@@ -1,7 +1,7 @@
import _ from 'lodash'; import _ from 'lodash';
import moment from 'moment'; import moment from 'moment';
import q from 'q'; import q from 'q';
import { PrometheusDatasource } from '../datasource'; import { PrometheusDatasource, prometheusSpecialRegexEscape, prometheusRegularEscape } from '../datasource';
describe('PrometheusDatasource', () => { describe('PrometheusDatasource', () => {
let ctx: any = {}; let ctx: any = {};
@@ -101,4 +101,41 @@ describe('PrometheusDatasource', () => {
}); });
}); });
}); });
describe('Prometheus regular escaping', function() {
it('should not escape simple string', function() {
expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression');
});
it("should escape '", function() {
expect(prometheusRegularEscape("looking'glass")).toEqual("looking\\\\'glass");
});
it('should escape multiple characters', function() {
expect(prometheusRegularEscape("'looking'glass'")).toEqual("\\\\'looking\\\\'glass\\\\'");
});
});
describe('Prometheus regexes escaping', function() {
it('should not escape simple string', function() {
expect(prometheusSpecialRegexEscape('cryptodepression')).toEqual('cryptodepression');
});
it('should escape $^*+?.()\\', function() {
expect(prometheusSpecialRegexEscape("looking'glass")).toEqual("looking\\\\'glass");
expect(prometheusSpecialRegexEscape('looking{glass')).toEqual('looking\\\\{glass');
expect(prometheusSpecialRegexEscape('looking}glass')).toEqual('looking\\\\}glass');
expect(prometheusSpecialRegexEscape('looking[glass')).toEqual('looking\\\\[glass');
expect(prometheusSpecialRegexEscape('looking]glass')).toEqual('looking\\\\]glass');
expect(prometheusSpecialRegexEscape('looking$glass')).toEqual('looking\\\\$glass');
expect(prometheusSpecialRegexEscape('looking^glass')).toEqual('looking\\\\^glass');
expect(prometheusSpecialRegexEscape('looking*glass')).toEqual('looking\\\\*glass');
expect(prometheusSpecialRegexEscape('looking+glass')).toEqual('looking\\\\+glass');
expect(prometheusSpecialRegexEscape('looking?glass')).toEqual('looking\\\\?glass');
expect(prometheusSpecialRegexEscape('looking.glass')).toEqual('looking\\\\.glass');
expect(prometheusSpecialRegexEscape('looking(glass')).toEqual('looking\\\\(glass');
expect(prometheusSpecialRegexEscape('looking)glass')).toEqual('looking\\\\)glass');
expect(prometheusSpecialRegexEscape('looking\\glass')).toEqual('looking\\\\\\\\glass');
});
it('should escape multiple special characters', function() {
expect(prometheusSpecialRegexEscape('+looking$glass?')).toEqual('\\\\+looking\\\\$glass\\\\?');
});
});
}); });

View File

@@ -1,292 +0,0 @@
define([
'jquery',
'app/core/core',
],
function ($, core) {
'use strict';
var appEvents = core.appEvents;
function GraphTooltip(elem, dashboard, scope, getSeriesFn) {
var self = this;
var ctrl = scope.ctrl;
var panel = ctrl.panel;
var $tooltip = $('<div class="graph-tooltip">');
this.destroy = function() {
$tooltip.remove();
};
this.findHoverIndexFromDataPoints = function(posX, series, last) {
var ps = series.datapoints.pointsize;
var initial = last*ps;
var len = series.datapoints.points.length;
for (var j = initial; j < len; j += ps) {
// Special case of a non stepped line, highlight the very last point just before a null point
if ((!series.lines.steps && series.datapoints.points[initial] != null && series.datapoints.points[j] == null)
//normal case
|| series.datapoints.points[j] > posX) {
return Math.max(j - ps, 0)/ps;
}
}
return j/ps - 1;
};
this.findHoverIndexFromData = function(posX, series) {
var lower = 0;
var upper = series.data.length - 1;
var middle;
while (true) {
if (lower > upper) {
return Math.max(upper, 0);
}
middle = Math.floor((lower + upper) / 2);
if (series.data[middle][0] === posX) {
return middle;
} else if (series.data[middle][0] < posX) {
lower = middle + 1;
} else {
upper = middle - 1;
}
}
};
this.renderAndShow = function(absoluteTime, innerHtml, pos, xMode) {
if (xMode === 'time') {
innerHtml = '<div class="graph-tooltip-time">'+ absoluteTime + '</div>' + innerHtml;
}
$tooltip.html(innerHtml).place_tt(pos.pageX + 20, pos.pageY);
};
this.getMultiSeriesPlotHoverInfo = function(seriesList, pos) {
var value, i, series, hoverIndex, hoverDistance, pointTime, yaxis;
// 3 sub-arrays, 1st for hidden series, 2nd for left yaxis, 3rd for right yaxis.
var results = [[],[],[]];
//now we know the current X (j) position for X and Y values
var last_value = 0; //needed for stacked values
var minDistance, minTime;
for (i = 0; i < seriesList.length; i++) {
series = seriesList[i];
if (!series.data.length || (panel.legend.hideEmpty && series.allIsNull)) {
// Init value so that it does not brake series sorting
results[0].push({ hidden: true, value: 0 });
continue;
}
if (!series.data.length || (panel.legend.hideZero && series.allIsZero)) {
// Init value so that it does not brake series sorting
results[0].push({ hidden: true, value: 0 });
continue;
}
hoverIndex = this.findHoverIndexFromData(pos.x, series);
hoverDistance = pos.x - series.data[hoverIndex][0];
pointTime = series.data[hoverIndex][0];
// Take the closest point before the cursor, or if it does not exist, the closest after
if (! minDistance
|| (hoverDistance >=0 && (hoverDistance < minDistance || minDistance < 0))
|| (hoverDistance < 0 && hoverDistance > minDistance)) {
minDistance = hoverDistance;
minTime = pointTime;
}
if (series.stack) {
if (panel.tooltip.value_type === 'individual') {
value = series.data[hoverIndex][1];
} else if (!series.stack) {
value = series.data[hoverIndex][1];
} else {
last_value += series.data[hoverIndex][1];
value = last_value;
}
} else {
value = series.data[hoverIndex][1];
}
// Highlighting multiple Points depending on the plot type
if (series.lines.steps || series.stack) {
// stacked and steppedLine plots can have series with different length.
// Stacked series can increase its length on each new stacked serie if null points found,
// to speed the index search we begin always on the last found hoverIndex.
hoverIndex = this.findHoverIndexFromDataPoints(pos.x, series, hoverIndex);
}
// Be sure we have a yaxis so that it does not brake series sorting
yaxis = 0;
if (series.yaxis) {
yaxis = series.yaxis.n;
}
results[yaxis].push({
value: value,
hoverIndex: hoverIndex,
color: series.color,
label: series.aliasEscaped,
time: pointTime,
distance: hoverDistance,
index: i
});
}
// Contat the 3 sub-arrays
results = results[0].concat(results[1],results[2]);
// Time of the point closer to pointer
results.time = minTime;
return results;
};
elem.mouseleave(function () {
if (panel.tooltip.shared) {
var plot = elem.data().plot;
if (plot) {
$tooltip.detach();
plot.unhighlight();
}
}
appEvents.emit('graph-hover-clear');
});
elem.bind("plothover", function (event, pos, item) {
self.show(pos, item);
// broadcast to other graph panels that we are hovering!
pos.panelRelY = (pos.pageY - elem.offset().top) / elem.height();
appEvents.emit('graph-hover', {pos: pos, panel: panel});
});
elem.bind("plotclick", function (event, pos, item) {
appEvents.emit('graph-click', {pos: pos, panel: panel, item: item});
});
this.clear = function(plot) {
$tooltip.detach();
plot.clearCrosshair();
plot.unhighlight();
};
this.show = function(pos, item) {
var plot = elem.data().plot;
var plotData = plot.getData();
var xAxes = plot.getXAxes();
var xMode = xAxes[0].options.mode;
var seriesList = getSeriesFn();
var allSeriesMode = panel.tooltip.shared;
var group, value, absoluteTime, hoverInfo, i, series, seriesHtml, tooltipFormat;
// if panelRelY is defined another panel wants us to show a tooltip
// get pageX from position on x axis and pageY from relative position in original panel
if (pos.panelRelY) {
var pointOffset = plot.pointOffset({x: pos.x});
if (Number.isNaN(pointOffset.left) || pointOffset.left < 0 || pointOffset.left > elem.width()) {
self.clear(plot);
return;
}
pos.pageX = elem.offset().left + pointOffset.left;
pos.pageY = elem.offset().top + elem.height() * pos.panelRelY;
var isVisible = pos.pageY >= $(window).scrollTop() && pos.pageY <= $(window).innerHeight() + $(window).scrollTop();
if (!isVisible) {
self.clear(plot);
return;
}
plot.setCrosshair(pos);
allSeriesMode = true;
if (dashboard.sharedCrosshairModeOnly()) {
// if only crosshair mode we are done
return;
}
}
if (seriesList.length === 0) {
return;
}
if (seriesList[0].hasMsResolution) {
tooltipFormat = 'YYYY-MM-DD HH:mm:ss.SSS';
} else {
tooltipFormat = 'YYYY-MM-DD HH:mm:ss';
}
if (allSeriesMode) {
plot.unhighlight();
var seriesHoverInfo = self.getMultiSeriesPlotHoverInfo(plotData, pos);
seriesHtml = '';
absoluteTime = dashboard.formatDate(seriesHoverInfo.time, tooltipFormat);
// Dynamically reorder the hovercard for the current time point if the
// option is enabled.
if (panel.tooltip.sort === 2) {
seriesHoverInfo.sort(function(a, b) {
return b.value - a.value;
});
} else if (panel.tooltip.sort === 1) {
seriesHoverInfo.sort(function(a, b) {
return a.value - b.value;
});
}
for (i = 0; i < seriesHoverInfo.length; i++) {
hoverInfo = seriesHoverInfo[i];
if (hoverInfo.hidden) {
continue;
}
var highlightClass = '';
if (item && hoverInfo.index === item.seriesIndex) {
highlightClass = 'graph-tooltip-list-item--highlight';
}
series = seriesList[hoverInfo.index];
value = series.formatValue(hoverInfo.value);
seriesHtml += '<div class="graph-tooltip-list-item ' + highlightClass + '"><div class="graph-tooltip-series-name">';
seriesHtml += '<i class="fa fa-minus" style="color:' + hoverInfo.color +';"></i> ' + hoverInfo.label + ':</div>';
seriesHtml += '<div class="graph-tooltip-value">' + value + '</div></div>';
plot.highlight(hoverInfo.index, hoverInfo.hoverIndex);
}
self.renderAndShow(absoluteTime, seriesHtml, pos, xMode);
}
// single series tooltip
else if (item) {
series = seriesList[item.seriesIndex];
group = '<div class="graph-tooltip-list-item"><div class="graph-tooltip-series-name">';
group += '<i class="fa fa-minus" style="color:' + item.series.color +';"></i> ' + series.aliasEscaped + ':</div>';
if (panel.stack && panel.tooltip.value_type === 'individual') {
value = item.datapoint[1] - item.datapoint[2];
}
else {
value = item.datapoint[1];
}
value = series.formatValue(value);
absoluteTime = dashboard.formatDate(item.datapoint[0], tooltipFormat);
group += '<div class="graph-tooltip-value">' + value + '</div>';
self.renderAndShow(absoluteTime, group, pos, xMode);
}
// no hit
else {
$tooltip.detach();
}
};
}
return GraphTooltip;
});

View File

@@ -0,0 +1,289 @@
import $ from 'jquery';
import { appEvents } from 'app/core/core';
export default function GraphTooltip(elem, dashboard, scope, getSeriesFn) {
let self = this;
let ctrl = scope.ctrl;
let panel = ctrl.panel;
let $tooltip = $('<div class="graph-tooltip">');
this.destroy = function() {
$tooltip.remove();
};
this.findHoverIndexFromDataPoints = function(posX, series, last) {
let ps = series.datapoints.pointsize;
let initial = last * ps;
let len = series.datapoints.points.length;
let j;
for (j = initial; j < len; j += ps) {
// Special case of a non stepped line, highlight the very last point just before a null point
if (
(!series.lines.steps && series.datapoints.points[initial] != null && series.datapoints.points[j] == null) ||
//normal case
series.datapoints.points[j] > posX
) {
return Math.max(j - ps, 0) / ps;
}
}
return j / ps - 1;
};
this.findHoverIndexFromData = function(posX, series) {
let lower = 0;
let upper = series.data.length - 1;
let middle;
while (true) {
if (lower > upper) {
return Math.max(upper, 0);
}
middle = Math.floor((lower + upper) / 2);
if (series.data[middle][0] === posX) {
return middle;
} else if (series.data[middle][0] < posX) {
lower = middle + 1;
} else {
upper = middle - 1;
}
}
};
this.renderAndShow = function(absoluteTime, innerHtml, pos, xMode) {
if (xMode === 'time') {
innerHtml = '<div class="graph-tooltip-time">' + absoluteTime + '</div>' + innerHtml;
}
$tooltip.html(innerHtml).place_tt(pos.pageX + 20, pos.pageY);
};
this.getMultiSeriesPlotHoverInfo = function(seriesList, pos) {
let value, i, series, hoverIndex, hoverDistance, pointTime, yaxis;
// 3 sub-arrays, 1st for hidden series, 2nd for left yaxis, 3rd for right yaxis.
let results: any = [[], [], []];
//now we know the current X (j) position for X and Y values
let last_value = 0; //needed for stacked values
let minDistance, minTime;
for (i = 0; i < seriesList.length; i++) {
series = seriesList[i];
if (!series.data.length || (panel.legend.hideEmpty && series.allIsNull)) {
// Init value so that it does not brake series sorting
results[0].push({ hidden: true, value: 0 });
continue;
}
if (!series.data.length || (panel.legend.hideZero && series.allIsZero)) {
// Init value so that it does not brake series sorting
results[0].push({ hidden: true, value: 0 });
continue;
}
hoverIndex = this.findHoverIndexFromData(pos.x, series);
hoverDistance = pos.x - series.data[hoverIndex][0];
pointTime = series.data[hoverIndex][0];
// Take the closest point before the cursor, or if it does not exist, the closest after
if (
!minDistance ||
(hoverDistance >= 0 && (hoverDistance < minDistance || minDistance < 0)) ||
(hoverDistance < 0 && hoverDistance > minDistance)
) {
minDistance = hoverDistance;
minTime = pointTime;
}
if (series.stack) {
if (panel.tooltip.value_type === 'individual') {
value = series.data[hoverIndex][1];
} else if (!series.stack) {
value = series.data[hoverIndex][1];
} else {
last_value += series.data[hoverIndex][1];
value = last_value;
}
} else {
value = series.data[hoverIndex][1];
}
// Highlighting multiple Points depending on the plot type
if (series.lines.steps || series.stack) {
// stacked and steppedLine plots can have series with different length.
// Stacked series can increase its length on each new stacked serie if null points found,
// to speed the index search we begin always on the last found hoverIndex.
hoverIndex = this.findHoverIndexFromDataPoints(pos.x, series, hoverIndex);
}
// Be sure we have a yaxis so that it does not brake series sorting
yaxis = 0;
if (series.yaxis) {
yaxis = series.yaxis.n;
}
results[yaxis].push({
value: value,
hoverIndex: hoverIndex,
color: series.color,
label: series.aliasEscaped,
time: pointTime,
distance: hoverDistance,
index: i,
});
}
// Contat the 3 sub-arrays
results = results[0].concat(results[1], results[2]);
// Time of the point closer to pointer
results.time = minTime;
return results;
};
elem.mouseleave(function() {
if (panel.tooltip.shared) {
let plot = elem.data().plot;
if (plot) {
$tooltip.detach();
plot.unhighlight();
}
}
appEvents.emit('graph-hover-clear');
});
elem.bind('plothover', function(event, pos, item) {
self.show(pos, item);
// broadcast to other graph panels that we are hovering!
pos.panelRelY = (pos.pageY - elem.offset().top) / elem.height();
appEvents.emit('graph-hover', { pos: pos, panel: panel });
});
elem.bind('plotclick', function(event, pos, item) {
appEvents.emit('graph-click', { pos: pos, panel: panel, item: item });
});
this.clear = function(plot) {
$tooltip.detach();
plot.clearCrosshair();
plot.unhighlight();
};
this.show = function(pos, item) {
let plot = elem.data().plot;
let plotData = plot.getData();
let xAxes = plot.getXAxes();
let xMode = xAxes[0].options.mode;
let seriesList = getSeriesFn();
let allSeriesMode = panel.tooltip.shared;
let group, value, absoluteTime, hoverInfo, i, series, seriesHtml, tooltipFormat;
// if panelRelY is defined another panel wants us to show a tooltip
// get pageX from position on x axis and pageY from relative position in original panel
if (pos.panelRelY) {
let pointOffset = plot.pointOffset({ x: pos.x });
if (Number.isNaN(pointOffset.left) || pointOffset.left < 0 || pointOffset.left > elem.width()) {
self.clear(plot);
return;
}
pos.pageX = elem.offset().left + pointOffset.left;
pos.pageY = elem.offset().top + elem.height() * pos.panelRelY;
let isVisible =
pos.pageY >= $(window).scrollTop() && pos.pageY <= $(window).innerHeight() + $(window).scrollTop();
if (!isVisible) {
self.clear(plot);
return;
}
plot.setCrosshair(pos);
allSeriesMode = true;
if (dashboard.sharedCrosshairModeOnly()) {
// if only crosshair mode we are done
return;
}
}
if (seriesList.length === 0) {
return;
}
if (seriesList[0].hasMsResolution) {
tooltipFormat = 'YYYY-MM-DD HH:mm:ss.SSS';
} else {
tooltipFormat = 'YYYY-MM-DD HH:mm:ss';
}
if (allSeriesMode) {
plot.unhighlight();
let seriesHoverInfo = self.getMultiSeriesPlotHoverInfo(plotData, pos);
seriesHtml = '';
absoluteTime = dashboard.formatDate(seriesHoverInfo.time, tooltipFormat);
// Dynamically reorder the hovercard for the current time point if the
// option is enabled.
if (panel.tooltip.sort === 2) {
seriesHoverInfo.sort(function(a, b) {
return b.value - a.value;
});
} else if (panel.tooltip.sort === 1) {
seriesHoverInfo.sort(function(a, b) {
return a.value - b.value;
});
}
for (i = 0; i < seriesHoverInfo.length; i++) {
hoverInfo = seriesHoverInfo[i];
if (hoverInfo.hidden) {
continue;
}
let highlightClass = '';
if (item && hoverInfo.index === item.seriesIndex) {
highlightClass = 'graph-tooltip-list-item--highlight';
}
series = seriesList[hoverInfo.index];
value = series.formatValue(hoverInfo.value);
seriesHtml +=
'<div class="graph-tooltip-list-item ' + highlightClass + '"><div class="graph-tooltip-series-name">';
seriesHtml +=
'<i class="fa fa-minus" style="color:' + hoverInfo.color + ';"></i> ' + hoverInfo.label + ':</div>';
seriesHtml += '<div class="graph-tooltip-value">' + value + '</div></div>';
plot.highlight(hoverInfo.index, hoverInfo.hoverIndex);
}
self.renderAndShow(absoluteTime, seriesHtml, pos, xMode);
} else if (item) {
// single series tooltip
series = seriesList[item.seriesIndex];
group = '<div class="graph-tooltip-list-item"><div class="graph-tooltip-series-name">';
group +=
'<i class="fa fa-minus" style="color:' + item.series.color + ';"></i> ' + series.aliasEscaped + ':</div>';
if (panel.stack && panel.tooltip.value_type === 'individual') {
value = item.datapoint[1] - item.datapoint[2];
} else {
value = item.datapoint[1];
}
value = series.formatValue(value);
absoluteTime = dashboard.formatDate(item.datapoint[0], tooltipFormat);
group += '<div class="graph-tooltip-value">' + value + '</div>';
self.renderAndShow(absoluteTime, group, pos, xMode);
} else {
// no hit
$tooltip.detach();
}
};
}

View File

@@ -131,8 +131,11 @@ module.directive('graphLegend', function(popoverSrv, $timeout) {
elem.empty(); elem.empty();
// Set min-width if side style and there is a value, otherwise remove the CSS propery // Set min-width if side style and there is a value, otherwise remove the CSS propery
var width = panel.legend.rightSide && panel.legend.sideWidth ? panel.legend.sideWidth + 'px' : ''; // Set width so it works with IE11
var width: any = panel.legend.rightSide && panel.legend.sideWidth ? panel.legend.sideWidth + 'px' : '';
var ieWidth: any = panel.legend.rightSide && panel.legend.sideWidth ? panel.legend.sideWidth - 1 + 'px' : '';
elem.css('min-width', width); elem.css('min-width', width);
elem.css('width', ieWidth);
elem.toggleClass('graph-legend-table', panel.legend.alignAsTable === true); elem.toggleClass('graph-legend-table', panel.legend.alignAsTable === true);

View File

@@ -11,6 +11,7 @@ var scope = {
var elem = $('<div></div>'); var elem = $('<div></div>');
var dashboard = {}; var dashboard = {};
var getSeriesFn;
function describeSharedTooltip(desc, fn) { function describeSharedTooltip(desc, fn) {
var ctx: any = {}; var ctx: any = {};
@@ -30,7 +31,7 @@ function describeSharedTooltip(desc, fn) {
describe(desc, function() { describe(desc, function() {
beforeEach(function() { beforeEach(function() {
ctx.setupFn(); ctx.setupFn();
var tooltip = new GraphTooltip(elem, dashboard, scope); var tooltip = new GraphTooltip(elem, dashboard, scope, getSeriesFn);
ctx.results = tooltip.getMultiSeriesPlotHoverInfo(ctx.data, ctx.pos); ctx.results = tooltip.getMultiSeriesPlotHoverInfo(ctx.data, ctx.pos);
}); });
@@ -39,7 +40,7 @@ function describeSharedTooltip(desc, fn) {
} }
describe('findHoverIndexFromData', function() { describe('findHoverIndexFromData', function() {
var tooltip = new GraphTooltip(elem, dashboard, scope); var tooltip = new GraphTooltip(elem, dashboard, scope, getSeriesFn);
var series = { var series = {
data: [[100, 0], [101, 0], [102, 0], [103, 0], [104, 0], [105, 0], [106, 0], [107, 0]], data: [[100, 0], [101, 0], [102, 0], [103, 0], [104, 0], [105, 0], [106, 0], [107, 0]],
}; };

View File

@@ -69,7 +69,59 @@
</div> </div>
</div> </div>
<div class="section gf-form-group" ng-if="style.type === 'number'"> <div class="section gf-form-group" ng-if="style.type === 'string'">
<h5 class="section-heading">Value Mappings</h5>
<div class="editor-row">
<div class="gf-form-group">
<div class="gf-form">
<span class="gf-form-label">
Type
</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="style.mappingType"
ng-options="c.value as c.text for c in editor.mappingTypes" ng-change="editor.render()"></select>
</div>
</div>
<div class="gf-form-group" ng-if="style.mappingType==1">
<div class="gf-form" ng-repeat="map in style.valueMaps">
<span class="gf-form-label">
<i class="fa fa-remove pointer" ng-click="editor.removeValueMap(style, $index)"></i>
</span>
<input type="text" class="gf-form-input max-width-6" ng-model="map.value" placeholder="Value" ng-blur="editor.render()">
<label class="gf-form-label">
<i class="fa fa-arrow-right"></i>
</label>
<input type="text" class="gf-form-input max-width-8" ng-model="map.text" placeholder="Text" ng-blur="editor.render()">
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" ng-click="editor.addValueMap(style)"><i class="fa fa-plus"></i></a>
</label>
</div>
</div>
<div class="gf-form-group" ng-if="style.mappingType==2">
<div class="gf-form" ng-repeat="rangeMap in style.rangeMaps">
<span class="gf-form-label">
<i class="fa fa-remove pointer" ng-click="editor.removeRangeMap(style, $index)"></i>
</span>
<span class="gf-form-label">From</span>
<input type="text" ng-model="rangeMap.from" class="gf-form-input max-width-6" ng-blur="editor.render()">
<span class="gf-form-label">To</span>
<input type="text" ng-model="rangeMap.to" class="gf-form-input max-width-6" ng-blur="editor.render()">
<span class="gf-form-label">Text</span>
<input type="text" ng-model="rangeMap.text" class="gf-form-input max-width-8" ng-blur="editor.render()">
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" ng-click="editor.addRangeMap(style)"><i class="fa fa-plus"></i></a>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="section gf-form-group" ng-if="['number', 'string'].indexOf(style.type) !== -1">
<h5 class="section-heading">Thresholds</h5> <h5 class="section-heading">Thresholds</h5>
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label width-8">Thresholds <label class="gf-form-label width-8">Thresholds
@@ -111,10 +163,10 @@
<span> <span>
Use special variables to specify cell values: Use special variables to specify cell values:
<br> <br>
<em>$__cell</em> refers to current cell value <em>${__cell}</em> refers to current cell value
<br> <br>
<em>$__cell_n</em> refers to Nth column value in current row. Column indexes are started from 0. For instance, <em>${__cell_n}</em> refers to Nth column value in current row. Column indexes are started from 0. For instance,
<em>$__cell_1</em> refers to second column's value. <em>${__cell_1}</em> refers to second column's value.
</span> </span>
</info-popover> </info-popover>
</div> </div>

View File

@@ -13,6 +13,7 @@ export class ColumnOptionsCtrl {
unitFormats: any; unitFormats: any;
getColumnNames: any; getColumnNames: any;
activeStyleIndex: number; activeStyleIndex: number;
mappingTypes: any;
/** @ngInject */ /** @ngInject */
constructor($scope) { constructor($scope) {
@@ -41,6 +42,7 @@ export class ColumnOptionsCtrl {
{ text: 'MM/DD/YY h:mm:ss a', value: 'MM/DD/YY h:mm:ss a' }, { text: 'MM/DD/YY h:mm:ss a', value: 'MM/DD/YY h:mm:ss a' },
{ text: 'MMMM D, YYYY LT', value: 'MMMM D, YYYY LT' }, { text: 'MMMM D, YYYY LT', value: 'MMMM D, YYYY LT' },
]; ];
this.mappingTypes = [{ text: 'Value to text', value: 1 }, { text: 'Range to text', value: 2 }];
this.getColumnNames = () => { this.getColumnNames = () => {
if (!this.panelCtrl.table) { if (!this.panelCtrl.table) {
@@ -74,6 +76,7 @@ export class ColumnOptionsCtrl {
pattern: '', pattern: '',
dateFormat: 'YYYY-MM-DD HH:mm:ss', dateFormat: 'YYYY-MM-DD HH:mm:ss',
thresholds: [], thresholds: [],
mappingType: 1,
}; };
var styles = this.panel.styles; var styles = this.panel.styles;
@@ -110,6 +113,32 @@ export class ColumnOptionsCtrl {
this.render(); this.render();
}; };
} }
addValueMap(style) {
if (!style.valueMaps) {
style.valueMaps = [];
}
style.valueMaps.push({ value: '', text: '' });
this.panelCtrl.render();
}
removeValueMap(style, index) {
style.valueMaps.splice(index, 1);
this.panelCtrl.render();
}
addRangeMap(style) {
if (!style.rangeMaps) {
style.rangeMaps = [];
}
style.rangeMaps.push({ from: '', to: '', text: '' });
this.panelCtrl.render();
}
removeRangeMap(style, index) {
style.rangeMaps.splice(index, 1);
this.panelCtrl.render();
}
} }
/** @ngInject */ /** @ngInject */

View File

@@ -47,7 +47,6 @@ export class TableRenderer {
if (!style.thresholds) { if (!style.thresholds) {
return null; return null;
} }
for (var i = style.thresholds.length; i > 0; i--) { for (var i = style.thresholds.length; i > 0; i--) {
if (value >= style.thresholds[i - 1]) { if (value >= style.thresholds[i - 1]) {
return style.colors[i]; return style.colors[i];
@@ -100,6 +99,60 @@ export class TableRenderer {
}; };
} }
if (column.style.type === 'string') {
return v => {
if (_.isArray(v)) {
v = v.join(', ');
}
const mappingType = column.style.mappingType || 0;
if (mappingType === 1 && column.style.valueMaps) {
for (let i = 0; i < column.style.valueMaps.length; i++) {
const map = column.style.valueMaps[i];
if (v === null) {
if (map.value === 'null') {
return map.text;
}
continue;
}
// Allow both numeric and string values to be mapped
if ((!_.isString(v) && Number(map.value) === Number(v)) || map.value === v) {
this.setColorState(v, column.style);
return this.defaultCellFormatter(map.text, column.style);
}
}
}
if (mappingType === 2 && column.style.rangeMaps) {
for (let i = 0; i < column.style.rangeMaps.length; i++) {
const map = column.style.rangeMaps[i];
if (v === null) {
if (map.from === 'null' && map.to === 'null') {
return map.text;
}
continue;
}
if (Number(map.from) <= Number(v) && Number(map.to) >= Number(v)) {
this.setColorState(v, column.style);
return this.defaultCellFormatter(map.text, column.style);
}
}
}
if (v === null || v === void 0) {
return '-';
}
this.setColorState(v, column.style);
return this.defaultCellFormatter(v, column.style);
};
}
if (column.style.type === 'number') { if (column.style.type === 'number') {
let valueFormatter = kbn.valueFormats[column.unit || column.style.unit]; let valueFormatter = kbn.valueFormats[column.unit || column.style.unit];
@@ -112,10 +165,7 @@ export class TableRenderer {
return this.defaultCellFormatter(v, column.style); return this.defaultCellFormatter(v, column.style);
} }
if (column.style.colorMode) { this.setColorState(v, column.style);
this.colorState[column.style.colorMode] = this.getColorForValue(v, column.style);
}
return valueFormatter(v, column.style.decimals, null); return valueFormatter(v, column.style.decimals, null);
}; };
} }
@@ -125,6 +175,23 @@ export class TableRenderer {
}; };
} }
setColorState(value, style) {
if (!style.colorMode) {
return;
}
if (value === null || value === void 0 || _.isArray(value)) {
return;
}
var numericValue = Number(value);
if (numericValue === NaN) {
return;
}
this.colorState[style.colorMode] = this.getColorForValue(numericValue, style);
}
renderRowVariables(rowIndex) { renderRowVariables(rowIndex) {
let scopedVars = {}; let scopedVars = {};
let cell_variable; let cell_variable;

View File

@@ -3,7 +3,7 @@ import TableModel from 'app/core/table_model';
import { TableRenderer } from '../renderer'; import { TableRenderer } from '../renderer';
describe('when rendering table', () => { describe('when rendering table', () => {
describe('given 2 columns', () => { describe('given 13 columns', () => {
var table = new TableModel(); var table = new TableModel();
table.columns = [ table.columns = [
{ text: 'Time' }, { text: 'Time' },
@@ -15,8 +15,14 @@ describe('when rendering table', () => {
{ text: 'Sanitized' }, { text: 'Sanitized' },
{ text: 'Link' }, { text: 'Link' },
{ text: 'Array' }, { text: 'Array' },
{ text: 'Mapping' },
{ text: 'RangeMapping' },
{ text: 'MappingColored' },
{ text: 'RangeMappingColored' },
];
table.rows = [
[1388556366666, 1230, 40, undefined, '', '', 'my.host.com', 'host1', ['value1', 'value2'], 1, 2, 1, 2],
]; ];
table.rows = [[1388556366666, 1230, 40, undefined, '', '', 'my.host.com', 'host1', ['value1', 'value2']]];
var panel = { var panel = {
pageSize: 10, pageSize: 10,
@@ -47,6 +53,10 @@ describe('when rendering table', () => {
pattern: 'String', pattern: 'String',
type: 'string', type: 'string',
}, },
{
pattern: 'String',
type: 'string',
},
{ {
pattern: 'United', pattern: 'United',
type: 'number', type: 'number',
@@ -72,6 +82,84 @@ describe('when rendering table', () => {
unit: 'ms', unit: 'ms',
decimals: 3, decimals: 3,
}, },
{
pattern: 'Mapping',
type: 'string',
mappingType: 1,
valueMaps: [
{
value: '1',
text: 'on',
},
{
value: '0',
text: 'off',
},
{
value: 'HELLO WORLD',
text: 'HELLO GRAFANA',
},
{
value: 'value1, value2',
text: 'value3, value4',
},
],
},
{
pattern: 'RangeMapping',
type: 'string',
mappingType: 2,
rangeMaps: [
{
from: '1',
to: '3',
text: 'on',
},
{
from: '3',
to: '6',
text: 'off',
},
],
},
{
pattern: 'MappingColored',
type: 'string',
mappingType: 1,
valueMaps: [
{
value: '1',
text: 'on',
},
{
value: '0',
text: 'off',
},
],
colorMode: 'value',
thresholds: [1, 2],
colors: ['green', 'orange', 'red'],
},
{
pattern: 'RangeMappingColored',
type: 'string',
mappingType: 2,
rangeMaps: [
{
from: '1',
to: '3',
text: 'on',
},
{
from: '3',
to: '6',
text: 'off',
},
],
colorMode: 'value',
thresholds: [2, 5],
colors: ['green', 'orange', 'red'],
},
], ],
}; };
@@ -192,6 +280,86 @@ describe('when rendering table', () => {
var html = renderer.renderCell(8, 0, ['value1', 'value2']); var html = renderer.renderCell(8, 0, ['value1', 'value2']);
expect(html).toBe('<td>value1, value2</td>'); expect(html).toBe('<td>value1, value2</td>');
}); });
it('numeric value should be mapped to text', () => {
var html = renderer.renderCell(9, 0, 1);
expect(html).toBe('<td>on</td>');
});
it('string numeric value should be mapped to text', () => {
var html = renderer.renderCell(9, 0, '0');
expect(html).toBe('<td>off</td>');
});
it('string value should be mapped to text', () => {
var html = renderer.renderCell(9, 0, 'HELLO WORLD');
expect(html).toBe('<td>HELLO GRAFANA</td>');
});
it('array column value should be mapped to text', () => {
var html = renderer.renderCell(9, 0, ['value1', 'value2']);
expect(html).toBe('<td>value3, value4</td>');
});
it('value should be mapped to text (range)', () => {
var html = renderer.renderCell(10, 0, 2);
expect(html).toBe('<td>on</td>');
});
it('value should be mapped to text (range)', () => {
var html = renderer.renderCell(10, 0, 5);
expect(html).toBe('<td>off</td>');
});
it('array column value should not be mapped to text', () => {
var html = renderer.renderCell(10, 0, ['value1', 'value2']);
expect(html).toBe('<td>value1, value2</td>');
});
it('value should be mapped to text and colored cell should have style', () => {
var html = renderer.renderCell(11, 0, 1);
expect(html).toBe('<td style="color:orange">on</td>');
});
it('value should be mapped to text and colored cell should have style', () => {
var html = renderer.renderCell(11, 0, '1');
expect(html).toBe('<td style="color:orange">on</td>');
});
it('value should be mapped to text and colored cell should have style', () => {
var html = renderer.renderCell(11, 0, 0);
expect(html).toBe('<td style="color:green">off</td>');
});
it('value should be mapped to text and colored cell should have style', () => {
var html = renderer.renderCell(11, 0, '0');
expect(html).toBe('<td style="color:green">off</td>');
});
it('value should be mapped to text and colored cell should have style', () => {
var html = renderer.renderCell(11, 0, '2.1');
expect(html).toBe('<td style="color:red">2.1</td>');
});
it('value should be mapped to text (range) and colored cell should have style', () => {
var html = renderer.renderCell(12, 0, 0);
expect(html).toBe('<td style="color:green">0</td>');
});
it('value should be mapped to text (range) and colored cell should have style', () => {
var html = renderer.renderCell(12, 0, 1);
expect(html).toBe('<td style="color:green">on</td>');
});
it('value should be mapped to text (range) and colored cell should have style', () => {
var html = renderer.renderCell(12, 0, 4);
expect(html).toBe('<td style="color:orange">off</td>');
});
it('value should be mapped to text (range) and colored cell should have style', () => {
var html = renderer.renderCell(12, 0, '7.1');
expect(html).toBe('<td style="color:red">7.1</td>');
});
}); });
}); });

View File

@@ -59,9 +59,8 @@ $critical: #ec2128;
$body-bg: $gray-7; $body-bg: $gray-7;
$page-bg: $gray-7; $page-bg: $gray-7;
$body-color: $gray-1; $body-color: $gray-1;
//$text-color: $dark-4;
$text-color: $gray-1; $text-color: $gray-1;
$text-color-strong: $white; $text-color-strong: $dark-2;
$text-color-weak: $gray-2; $text-color-weak: $gray-2;
$text-color-faint: $gray-4; $text-color-faint: $gray-4;
$text-color-emphasis: $dark-5; $text-color-emphasis: $dark-5;

View File

@@ -31,7 +31,6 @@
//padding: 0.5rem 1.5rem 0.5rem 0; //padding: 0.5rem 1.5rem 0.5rem 0;
padding: 1rem 1rem 0.75rem 1rem; padding: 1rem 1rem 0.75rem 1rem;
height: 51px; height: 51px;
line-height: 51px;
box-sizing: border-box; box-sizing: border-box;
outline: none; outline: none;
background: $side-menu-bg; background: $side-menu-bg;
@@ -61,6 +60,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
.search-item--indent {
margin-left: 14px;
}
} }
.search-dropdown__col_2 { .search-dropdown__col_2 {

View File

@@ -123,6 +123,8 @@
position: relative; position: relative;
opacity: 0.7; opacity: 0.7;
font-size: 130%; font-size: 130%;
height: 22px;
width: 22px;
} }
.fa { .fa {
@@ -178,6 +180,7 @@ li.sidemenu-org-switcher {
padding: 0.4rem 1rem 0.4rem 0.65rem; padding: 0.4rem 1rem 0.4rem 0.65rem;
min-height: $navbarHeight; min-height: $navbarHeight;
position: relative; position: relative;
height: $navbarHeight - 1px;
&:hover { &:hover {
background: $navbarButtonBackgroundHighlight; background: $navbarButtonBackgroundHighlight;

View File

@@ -43,7 +43,7 @@
font-size: 120%; font-size: 120%;
} }
&:hover { &:hover {
color: $white; color: $text-color-strong;
} }
} }

View File

@@ -108,7 +108,8 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 40px; width: 40px;
padding: 0 28px 0 16px; //margin-right: 8px;
padding: 0 4px 0 2px;
.icon-gf, .icon-gf,
.fa { .fa {
font-size: 200%; font-size: 200%;

View File

@@ -3,6 +3,7 @@ $login-border: #8daac5;
.login { .login {
background-position: center; background-position: center;
min-height: 85vh; min-height: 85vh;
height: 80vh;
background-repeat: no-repeat; background-repeat: no-repeat;
min-width: 100%; min-width: 100%;
margin-left: 0; margin-left: 0;
@@ -290,9 +291,14 @@ select:-webkit-autofill:focus {
} }
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
.login-content {
flex: 1 0 100%;
}
.login-branding { .login-branding {
width: 45%; width: 45%;
padding: 2rem 4rem; padding: 2rem 4rem;
flex-grow: 1;
.logo-icon { .logo-icon {
width: 130px; width: 130px;
@@ -371,7 +377,7 @@ select:-webkit-autofill:focus {
left: 0; left: 0;
right: 0; right: 0;
height: 100%; height: 100%;
content: ""; content: '';
display: block; display: block;
} }

View File

@@ -1,4 +1,4 @@
package gomail package mail
import ( import (
"bytes" "bytes"

View File

@@ -1,5 +1,6 @@
// Package gomail provides a simple interface to compose emails and to mail them // Package gomail provides a simple interface to compose emails and to mail them
// efficiently. // efficiently.
// //
// More info on Github: https://github.com/go-gomail/gomail // More info on Github: https://github.com/go-mail/mail
package gomail //
package mail

16
vendor/gopkg.in/mail.v2/errors.go generated vendored Normal file
View File

@@ -0,0 +1,16 @@
package mail
import "fmt"
// A SendError represents the failure to transmit a Message, detailing the cause
// of the failure and index of the Message within a batch.
type SendError struct {
// Index specifies the index of the Message within a batch.
Index uint
Cause error
}
func (err *SendError) Error() string {
return fmt.Sprintf("gomail: could not send email %d: %v",
err.Index+1, err.Cause)
}

View File

@@ -1,4 +1,4 @@
package gomail package mail
import ( import (
"bytes" "bytes"
@@ -18,6 +18,7 @@ type Message struct {
encoding Encoding encoding Encoding
hEncoder mimeEncoder hEncoder mimeEncoder
buf bytes.Buffer buf bytes.Buffer
boundary string
} }
type header map[string][]string type header map[string][]string
@@ -97,6 +98,11 @@ const (
Unencoded Encoding = "8bit" Unencoded Encoding = "8bit"
) )
// SetBoundary sets a custom multipart boundary.
func (m *Message) SetBoundary(boundary string) {
m.boundary = boundary
}
// SetHeader sets a value to the given header field. // SetHeader sets a value to the given header field.
func (m *Message) SetHeader(field string, value ...string) { func (m *Message) SetHeader(field string, value ...string) {
m.encodeHeader(value) m.encodeHeader(value)
@@ -183,9 +189,15 @@ func (m *Message) GetHeader(field string) []string {
} }
// SetBody sets the body of the message. It replaces any content previously set // SetBody sets the body of the message. It replaces any content previously set
// by SetBody, AddAlternative or AddAlternativeWriter. // by SetBody, SetBodyWriter, AddAlternative or AddAlternativeWriter.
func (m *Message) SetBody(contentType, body string, settings ...PartSetting) { func (m *Message) SetBody(contentType, body string, settings ...PartSetting) {
m.parts = []*part{m.newPart(contentType, newCopier(body), settings)} m.SetBodyWriter(contentType, newCopier(body), settings...)
}
// SetBodyWriter sets the body of the message. It can be useful with the
// text/template or html/template packages.
func (m *Message) SetBodyWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) {
m.parts = []*part{m.newPart(contentType, f, settings)}
} }
// AddAlternative adds an alternative part to the message. // AddAlternative adds an alternative part to the message.
@@ -226,8 +238,8 @@ func (m *Message) newPart(contentType string, f func(io.Writer) error, settings
} }
// A PartSetting can be used as an argument in Message.SetBody, // A PartSetting can be used as an argument in Message.SetBody,
// Message.AddAlternative or Message.AddAlternativeWriter to configure the part // Message.SetBodyWriter, Message.AddAlternative or Message.AddAlternativeWriter
// added to a message. // to configure the part added to a message.
type PartSetting func(*part) type PartSetting func(*part)
// SetPartEncoding sets the encoding of the part added to the message. By // SetPartEncoding sets the encoding of the part added to the message. By

View File

@@ -1,6 +1,6 @@
// +build go1.5 // +build go1.5
package gomail package mail
import ( import (
"mime" "mime"

View File

@@ -1,6 +1,6 @@
// +build !go1.5 // +build !go1.5
package gomail package mail
import "gopkg.in/alexcesaro/quotedprintable.v3" import "gopkg.in/alexcesaro/quotedprintable.v3"

View File

@@ -1,10 +1,10 @@
package gomail package mail
import ( import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/mail" stdmail "net/mail"
) )
// Sender is the interface that wraps the Send method. // Sender is the interface that wraps the Send method.
@@ -36,7 +36,7 @@ func (f SendFunc) Send(from string, to []string, msg io.WriterTo) error {
func Send(s Sender, msg ...*Message) error { func Send(s Sender, msg ...*Message) error {
for i, m := range msg { for i, m := range msg {
if err := send(s, m); err != nil { if err := send(s, m); err != nil {
return fmt.Errorf("gomail: could not send email %d: %v", i+1, err) return &SendError{Cause: err, Index: uint(i)}
} }
} }
@@ -108,7 +108,7 @@ func addAddress(list []string, addr string) []string {
} }
func parseAddress(field string) (string, error) { func parseAddress(field string) (string, error) {
addr, err := mail.ParseAddress(field) addr, err := stdmail.ParseAddress(field)
if err != nil { if err != nil {
return "", fmt.Errorf("gomail: invalid address %q: %v", field, err) return "", fmt.Errorf("gomail: invalid address %q: %v", field, err)
} }

View File

@@ -1,4 +1,4 @@
package gomail package mail
import ( import (
"crypto/tls" "crypto/tls"
@@ -27,23 +27,39 @@ type Dialer struct {
// most cases since the authentication mechanism should use the STARTTLS // most cases since the authentication mechanism should use the STARTTLS
// extension instead. // extension instead.
SSL bool SSL bool
// TSLConfig represents the TLS configuration used for the TLS (when the // TLSConfig represents the TLS configuration used for the TLS (when the
// STARTTLS extension is used) or SSL connection. // STARTTLS extension is used) or SSL connection.
TLSConfig *tls.Config TLSConfig *tls.Config
// StartTLSPolicy represents the TLS security level required to
// communicate with the SMTP server.
//
// This defaults to OpportunisticStartTLS for backwards compatibility,
// but we recommend MandatoryStartTLS for all modern SMTP servers.
//
// This option has no effect if SSL is set to true.
StartTLSPolicy StartTLSPolicy
// LocalName is the hostname sent to the SMTP server with the HELO command. // LocalName is the hostname sent to the SMTP server with the HELO command.
// By default, "localhost" is sent. // By default, "localhost" is sent.
LocalName string LocalName string
// Timeout to use for read/write operations. Defaults to 10 seconds, can
// be set to 0 to disable timeouts.
Timeout time.Duration
// Whether we should retry mailing if the connection returned an error,
// defaults to true.
RetryFailure bool
} }
// NewDialer returns a new SMTP Dialer. The given parameters are used to connect // NewDialer returns a new SMTP Dialer. The given parameters are used to connect
// to the SMTP server. // to the SMTP server.
func NewDialer(host string, port int, username, password string) *Dialer { func NewDialer(host string, port int, username, password string) *Dialer {
return &Dialer{ return &Dialer{
Host: host, Host: host,
Port: port, Port: port,
Username: username, Username: username,
Password: password, Password: password,
SSL: port == 465, SSL: port == 465,
Timeout: 10 * time.Second,
RetryFailure: true,
} }
} }
@@ -55,10 +71,15 @@ func NewPlainDialer(host string, port int, username, password string) *Dialer {
return NewDialer(host, port, username, password) return NewDialer(host, port, username, password)
} }
// NetDialTimeout specifies the DialTimeout function to establish a connection
// to the SMTP server. This can be used to override dialing in the case that a
// proxy or other special behavior is needed.
var NetDialTimeout = net.DialTimeout
// Dial dials and authenticates to an SMTP server. The returned SendCloser // Dial dials and authenticates to an SMTP server. The returned SendCloser
// should be closed when done using it. // should be closed when done using it.
func (d *Dialer) Dial() (SendCloser, error) { func (d *Dialer) Dial() (SendCloser, error) {
conn, err := netDialTimeout("tcp", addr(d.Host, d.Port), 10*time.Second) conn, err := NetDialTimeout("tcp", addr(d.Host, d.Port), d.Timeout)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -72,14 +93,25 @@ func (d *Dialer) Dial() (SendCloser, error) {
return nil, err return nil, err
} }
if d.Timeout > 0 {
conn.SetDeadline(time.Now().Add(d.Timeout))
}
if d.LocalName != "" { if d.LocalName != "" {
if err := c.Hello(d.LocalName); err != nil { if err := c.Hello(d.LocalName); err != nil {
return nil, err return nil, err
} }
} }
if !d.SSL { if !d.SSL && d.StartTLSPolicy != NoStartTLS {
if ok, _ := c.Extension("STARTTLS"); ok { ok, _ := c.Extension("STARTTLS")
if !ok && d.StartTLSPolicy == MandatoryStartTLS {
err := StartTLSUnsupportedError{
Policy: d.StartTLSPolicy}
return nil, err
}
if ok {
if err := c.StartTLS(d.tlsConfig()); err != nil { if err := c.StartTLS(d.tlsConfig()); err != nil {
c.Close() c.Close()
return nil, err return nil, err
@@ -111,7 +143,7 @@ func (d *Dialer) Dial() (SendCloser, error) {
} }
} }
return &smtpSender{c, d}, nil return &smtpSender{c, conn, d}, nil
} }
func (d *Dialer) tlsConfig() *tls.Config { func (d *Dialer) tlsConfig() *tls.Config {
@@ -121,6 +153,47 @@ func (d *Dialer) tlsConfig() *tls.Config {
return d.TLSConfig return d.TLSConfig
} }
// StartTLSPolicy constants are valid values for Dialer.StartTLSPolicy.
type StartTLSPolicy int
const (
// OpportunisticStartTLS means that SMTP transactions are encrypted if
// STARTTLS is supported by the SMTP server. Otherwise, messages are
// sent in the clear. This is the default setting.
OpportunisticStartTLS StartTLSPolicy = iota
// MandatoryStartTLS means that SMTP transactions must be encrypted.
// SMTP transactions are aborted unless STARTTLS is supported by the
// SMTP server.
MandatoryStartTLS
// NoStartTLS means encryption is disabled and messages are sent in the
// clear.
NoStartTLS = -1
)
func (policy *StartTLSPolicy) String() string {
switch *policy {
case OpportunisticStartTLS:
return "OpportunisticStartTLS"
case MandatoryStartTLS:
return "MandatoryStartTLS"
case NoStartTLS:
return "NoStartTLS"
default:
return fmt.Sprintf("StartTLSPolicy:%v", *policy)
}
}
// StartTLSUnsupportedError is returned by Dial when connecting to an SMTP
// server that does not support STARTTLS.
type StartTLSUnsupportedError struct {
Policy StartTLSPolicy
}
func (e StartTLSUnsupportedError) Error() string {
return "gomail: " + e.Policy.String() + " required, but " +
"SMTP server does not support STARTTLS"
}
func addr(host string, port int) string { func addr(host string, port int) string {
return fmt.Sprintf("%s:%d", host, port) return fmt.Sprintf("%s:%d", host, port)
} }
@@ -139,12 +212,29 @@ func (d *Dialer) DialAndSend(m ...*Message) error {
type smtpSender struct { type smtpSender struct {
smtpClient smtpClient
d *Dialer conn net.Conn
d *Dialer
}
func (c *smtpSender) retryError(err error) bool {
if !c.d.RetryFailure {
return false
}
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
return true
}
return err == io.EOF
} }
func (c *smtpSender) Send(from string, to []string, msg io.WriterTo) error { func (c *smtpSender) Send(from string, to []string, msg io.WriterTo) error {
if c.d.Timeout > 0 {
c.conn.SetDeadline(time.Now().Add(c.d.Timeout))
}
if err := c.Mail(from); err != nil { if err := c.Mail(from); err != nil {
if err == io.EOF { if c.retryError(err) {
// This is probably due to a timeout, so reconnect and try again. // This is probably due to a timeout, so reconnect and try again.
sc, derr := c.d.Dial() sc, derr := c.d.Dial()
if derr == nil { if derr == nil {
@@ -154,6 +244,7 @@ func (c *smtpSender) Send(from string, to []string, msg io.WriterTo) error {
} }
} }
} }
return err return err
} }
@@ -182,9 +273,8 @@ func (c *smtpSender) Close() error {
// Stubbed out for tests. // Stubbed out for tests.
var ( var (
netDialTimeout = net.DialTimeout tlsClient = tls.Client
tlsClient = tls.Client smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) {
smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) {
return smtp.NewClient(conn, host) return smtp.NewClient(conn, host)
} }
) )

View File

@@ -1,4 +1,4 @@
package gomail package mail
import ( import (
"encoding/base64" "encoding/base64"
@@ -28,15 +28,15 @@ func (w *messageWriter) writeMessage(m *Message) {
w.writeHeaders(m.header) w.writeHeaders(m.header)
if m.hasMixedPart() { if m.hasMixedPart() {
w.openMultipart("mixed") w.openMultipart("mixed", m.boundary)
} }
if m.hasRelatedPart() { if m.hasRelatedPart() {
w.openMultipart("related") w.openMultipart("related", m.boundary)
} }
if m.hasAlternativePart() { if m.hasAlternativePart() {
w.openMultipart("alternative") w.openMultipart("alternative", m.boundary)
} }
for _, part := range m.parts { for _, part := range m.parts {
w.writePart(part, m.charset) w.writePart(part, m.charset)
@@ -77,8 +77,11 @@ type messageWriter struct {
err error err error
} }
func (w *messageWriter) openMultipart(mimeType string) { func (w *messageWriter) openMultipart(mimeType, boundary string) {
mw := multipart.NewWriter(w) mw := multipart.NewWriter(w)
if boundary != "" {
mw.SetBoundary(boundary)
}
contentType := "multipart/" + mimeType + ";\r\n boundary=" + mw.Boundary() contentType := "multipart/" + mimeType + ";\r\n boundary=" + mw.Boundary()
w.writers[w.depth] = mw w.writers[w.depth] = mw