dashfolders: merge conflict

This commit is contained in:
Daniel Lee 2018-01-25 14:54:50 +01:00
commit bc5fae5367
1824 changed files with 262505 additions and 173763 deletions

View File

@ -8,7 +8,6 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 120
insert_final_newline = true
[*.go]
indent_style = tab

View File

@ -16,6 +16,13 @@ The new grid engine is major upgrade for how you can position and move panels. I
Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: 24, h: 5}`. Units are in grid dimensions (24 columns, 1 height unit 30px). Rows and Panels objects exist (together) in a flat array directly on the dashboard root object. Rows are not needed for layouts anymore and are mainly there for backward compatibility. Some panel plugins that do not respect their panel height might require an update.
## New Features
* **Alerting**: Add support for internal image store [#6922](https://github.com/grafana/grafana/issues/6922), thx [@FunkyM](https://github.com/FunkyM)
## Minor
* **Graph**: Don't hide graph display options (Lines/Points) when draw mode is unchecked [#9770](https://github.com/grafana/grafana/issues/9770), thx [@Jonnymcc](https://github.com/Jonnymcc)
* **Prometheus**: Show label name in paren after by/without/on/ignoring/group_left/group_right [#9664](https://github.com/grafana/grafana/pull/9664), thx [@mtanda](https://github.com/mtanda)
# 4.7.0 (unreleased / v4.7.x branch)
## Breaking changes

630
Gopkg.lock generated Normal file
View File

@ -0,0 +1,630 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "cloud.google.com/go"
packages = ["compute/metadata"]
revision = "767c40d6a2e058483c25fa193e963a22da17236d"
version = "v0.18.0"
[[projects]]
name = "github.com/BurntSushi/toml"
packages = ["."]
revision = "b26d9c308763d68093482582cea63d69be07a0f0"
version = "v0.3.0"
[[projects]]
branch = "master"
name = "github.com/Unknwon/com"
packages = ["."]
revision = "7677a1d7c1137cd3dd5ba7a076d0c898a1ef4520"
[[projects]]
name = "github.com/apache/thrift"
packages = ["lib/go/thrift"]
revision = "b2a4d4ae21c789b689dd162deb819665567f481c"
version = "0.10.0"
[[projects]]
name = "github.com/aws/aws-sdk-go"
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"
]
revision = "decd990ddc5dcdf2f73309cbcab90d06b996ca28"
version = "v1.12.67"
[[projects]]
branch = "master"
name = "github.com/benbjohnson/clock"
packages = ["."]
revision = "7dc76406b6d3c05b5f71a86293cbcf3c4ea03b19"
[[projects]]
branch = "master"
name = "github.com/beorn7/perks"
packages = ["quantile"]
revision = "4c0e84591b9aa9e6dcfdf3e020114cd81f89d5f9"
[[projects]]
branch = "master"
name = "github.com/bmizerany/assert"
packages = ["."]
revision = "b7ed37b82869576c289d7d97fb2bbd8b64a0cb28"
[[projects]]
branch = "master"
name = "github.com/bradfitz/gomemcache"
packages = ["memcache"]
revision = "1952afaa557dc08e8e0d89eafab110fb501c1a2b"
[[projects]]
branch = "master"
name = "github.com/codahale/hdrhistogram"
packages = ["."]
revision = "3a0bb77429bd3a61596f5e8a3172445844342120"
[[projects]]
name = "github.com/codegangsta/cli"
packages = ["."]
revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1"
version = "v1.20.0"
[[projects]]
name = "github.com/davecgh/go-spew"
packages = ["spew"]
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
name = "github.com/fatih/color"
packages = ["."]
revision = "570b54cabe6b8eb0bc2dfce68d964677d63b5260"
version = "v1.5.0"
[[projects]]
name = "github.com/go-ini/ini"
packages = ["."]
revision = "32e4c1e6bc4e7d0d8451aa6b75200d19e37a536a"
version = "v1.32.0"
[[projects]]
name = "github.com/go-ldap/ldap"
packages = ["."]
revision = "bb7a9ca6e4fbc2129e3db588a34bc970ffe811a9"
version = "v2.5.1"
[[projects]]
branch = "master"
name = "github.com/go-macaron/binding"
packages = ["."]
revision = "ac54ee249c27dca7e76fad851a4a04b73bd1b183"
[[projects]]
branch = "master"
name = "github.com/go-macaron/gzip"
packages = ["."]
revision = "cad1c6580a07c56f5f6bc52d66002a05985c5854"
[[projects]]
branch = "master"
name = "github.com/go-macaron/inject"
packages = ["."]
revision = "d8a0b8677191f4380287cfebd08e462217bac7ad"
[[projects]]
branch = "master"
name = "github.com/go-macaron/session"
packages = [
".",
"memcache",
"mysql",
"postgres",
"redis"
]
revision = "b8e286a0dba8f4999042d6b258daf51b31d08938"
[[projects]]
name = "github.com/go-sql-driver/mysql"
packages = ["."]
revision = "2cc627ac8defc45d65066ae98f898166f580f9a4"
[[projects]]
name = "github.com/go-stack/stack"
packages = ["."]
revision = "259ab82a6cad3992b4e21ff5cac294ccb06474bc"
version = "v1.7.0"
[[projects]]
branch = "master"
name = "github.com/go-xorm/builder"
packages = ["."]
revision = "488224409dd8aa2ce7a5baf8d10d55764a913738"
[[projects]]
name = "github.com/go-xorm/core"
packages = ["."]
revision = "e8409d73255791843585964791443dbad877058c"
[[projects]]
name = "github.com/go-xorm/xorm"
packages = ["."]
revision = "6687a2b4e824f4d87f2d65060ec5cb0d896dff1e"
[[projects]]
branch = "master"
name = "github.com/golang/protobuf"
packages = [
"proto",
"ptypes",
"ptypes/any",
"ptypes/duration",
"ptypes/timestamp"
]
revision = "c65a0412e71e8b9b3bfd22925720d23c0f054237"
[[projects]]
branch = "master"
name = "github.com/gopherjs/gopherjs"
packages = ["js"]
revision = "178c176a91fe05e3e6c58fa5c989bad19e6cdcb3"
[[projects]]
name = "github.com/gorilla/websocket"
packages = ["."]
revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b"
version = "v1.2.0"
[[projects]]
name = "github.com/gosimple/slug"
packages = ["."]
revision = "e9f42fa127660e552d0ad2b589868d403a9be7c6"
version = "v1.1.1"
[[projects]]
branch = "master"
name = "github.com/grafana/grafana_plugin_model"
packages = ["go/datasource"]
revision = "dfe5dc0a6ce05825ba7fe2d0323d92e631bffa89"
[[projects]]
branch = "master"
name = "github.com/hashicorp/go-hclog"
packages = ["."]
revision = "5bcb0f17e36442247290887cc914a6e507afa5c4"
[[projects]]
name = "github.com/hashicorp/go-plugin"
packages = ["."]
revision = "3e6d191694b5a3a2b99755f31b47fa209e4bcd09"
[[projects]]
branch = "master"
name = "github.com/hashicorp/go-version"
packages = ["."]
revision = "4fe82ae3040f80a03d04d2cccb5606a626b8e1ee"
[[projects]]
branch = "master"
name = "github.com/hashicorp/yamux"
packages = ["."]
revision = "683f49123a33db61abfb241b7ac5e4af4dc54d55"
[[projects]]
name = "github.com/inconshreveable/log15"
packages = ["."]
revision = "0decfc6c20d9ca0ad143b0e89dcaa20f810b4fb3"
version = "v2.13"
[[projects]]
name = "github.com/jmespath/go-jmespath"
packages = ["."]
revision = "0b12d6b5"
[[projects]]
name = "github.com/jtolds/gls"
packages = ["."]
revision = "77f18212c9c7edc9bd6a33d383a7b545ce62f064"
version = "v4.2.1"
[[projects]]
name = "github.com/klauspost/compress"
packages = [
"flate",
"gzip"
]
revision = "6c8db69c4b49dd4df1fff66996cf556176d0b9bf"
version = "v1.2.1"
[[projects]]
name = "github.com/klauspost/cpuid"
packages = ["."]
revision = "ae7887de9fa5d2db4eaa8174a7eff2c1ac00f2da"
version = "v1.1"
[[projects]]
name = "github.com/klauspost/crc32"
packages = ["."]
revision = "cb6bfca970f6908083f26f39a79009d608efd5cd"
version = "v1.1"
[[projects]]
branch = "master"
name = "github.com/kr/pretty"
packages = ["."]
revision = "cfb55aafdaf3ec08f0db22699ab822c50091b1c4"
[[projects]]
branch = "master"
name = "github.com/kr/text"
packages = ["."]
revision = "7cafcd837844e784b526369c9bce262804aebc60"
[[projects]]
branch = "master"
name = "github.com/lib/pq"
packages = [
".",
"oid"
]
revision = "61fe37aa2ee24fabcdbe5c4ac1d4ac566f88f345"
[[projects]]
name = "github.com/mattn/go-colorable"
packages = ["."]
revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072"
version = "v0.0.9"
[[projects]]
name = "github.com/mattn/go-isatty"
packages = ["."]
revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39"
version = "v0.0.3"
[[projects]]
name = "github.com/mattn/go-sqlite3"
packages = ["."]
revision = "6c771bb9887719704b210e87e934f08be014bdb1"
version = "v1.6.0"
[[projects]]
name = "github.com/matttproud/golang_protobuf_extensions"
packages = ["pbutil"]
revision = "3247c84500bff8d9fb6d579d800f20b3e091582c"
version = "v1.0.0"
[[projects]]
branch = "master"
name = "github.com/mitchellh/go-testing-interface"
packages = ["."]
revision = "a61a99592b77c9ba629d254a693acffaeb4b7e28"
[[projects]]
name = "github.com/opentracing/opentracing-go"
packages = [
".",
"ext",
"log"
]
revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38"
version = "v1.0.2"
[[projects]]
name = "github.com/patrickmn/go-cache"
packages = ["."]
revision = "a3647f8e31d79543b2d0f0ae2fe5c379d72cedc0"
version = "v2.1.0"
[[projects]]
name = "github.com/prometheus/client_golang"
packages = [
"api",
"api/prometheus/v1",
"prometheus",
"prometheus/promhttp"
]
revision = "967789050ba94deca04a5e84cce8ad472ce313c1"
version = "v0.9.0-pre1"
[[projects]]
branch = "master"
name = "github.com/prometheus/client_model"
packages = ["go"]
revision = "99fa1f4be8e564e8a6b613da7fa6f46c9edafc6c"
[[projects]]
branch = "master"
name = "github.com/prometheus/common"
packages = [
"expfmt",
"internal/bitbucket.org/ww/goautoneg",
"model"
]
revision = "89604d197083d4781071d3c65855d24ecfb0a563"
[[projects]]
branch = "master"
name = "github.com/prometheus/procfs"
packages = [
".",
"internal/util",
"nfsd",
"xfs"
]
revision = "85fadb6e89903ef7cca6f6a804474cd5ea85b6e1"
[[projects]]
branch = "master"
name = "github.com/rainycape/unidecode"
packages = ["."]
revision = "cb7f23ec59bec0d61b19c56cd88cee3d0cc1870c"
[[projects]]
branch = "master"
name = "github.com/sergi/go-diff"
packages = ["diffmatchpatch"]
revision = "1744e2970ca51c86172c8190fadad617561ed6e7"
[[projects]]
name = "github.com/smartystreets/assertions"
packages = [
".",
"internal/go-render/render",
"internal/oglematchers"
]
revision = "0b37b35ec7434b77e77a4bb29b79677cced992ea"
version = "1.8.1"
[[projects]]
name = "github.com/smartystreets/goconvey"
packages = [
"convey",
"convey/gotest",
"convey/reporting"
]
revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857"
version = "1.6.3"
[[projects]]
name = "github.com/uber/jaeger-client-go"
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"
]
revision = "3ac96c6e679cb60a74589b0d0aa7c70a906183f7"
version = "v2.11.2"
[[projects]]
name = "github.com/uber/jaeger-lib"
packages = ["metrics"]
revision = "7f95f4f7e80028096410abddaae2556e4c61b59f"
version = "v1.3.1"
[[projects]]
name = "github.com/yudai/gojsondiff"
packages = [
".",
"formatter"
]
revision = "7b1b7adf999dab73a6eb02669c3d82dbb27a3dd6"
version = "1.0.0"
[[projects]]
branch = "master"
name = "github.com/yudai/golcs"
packages = ["."]
revision = "ecda9a501e8220fae3b4b600c3db4b0ba22cfc68"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = ["pbkdf2"]
revision = "3d37316aaa6bd9929127ac9a527abf408178ea7b"
[[projects]]
branch = "master"
name = "golang.org/x/net"
packages = [
"context",
"context/ctxhttp",
"http2",
"http2/hpack",
"idna",
"internal/timeseries",
"lex/httplex",
"trace"
]
revision = "5ccada7d0a7ba9aeb5d3aca8d3501b4c2a509fec"
[[projects]]
branch = "master"
name = "golang.org/x/oauth2"
packages = [
".",
"google",
"internal",
"jws",
"jwt"
]
revision = "b28fcf2b08a19742b43084fb40ab78ac6c3d8067"
[[projects]]
branch = "master"
name = "golang.org/x/sync"
packages = ["errgroup"]
revision = "fd80eb99c8f653c847d294a001bdf2a3a6f768f5"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = ["unix"]
revision = "af50095a40f9041b3b38960738837185c26e9419"
[[projects]]
branch = "master"
name = "golang.org/x/text"
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"
]
revision = "e19ae1496984b1c655b8044a65c0300a3c878dd3"
[[projects]]
name = "google.golang.org/appengine"
packages = [
".",
"cloudsql",
"internal",
"internal/app_identity",
"internal/base",
"internal/datastore",
"internal/log",
"internal/modules",
"internal/remote_api",
"internal/urlfetch",
"urlfetch"
]
revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a"
version = "v1.0.0"
[[projects]]
branch = "master"
name = "google.golang.org/genproto"
packages = ["googleapis/rpc/status"]
revision = "a8101f21cf983e773d0c1133ebc5424792003214"
[[projects]]
name = "google.golang.org/grpc"
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"
]
revision = "6b51017f791ae1cfbec89c52efdf444b13b550ef"
version = "v1.9.2"
[[projects]]
branch = "v3"
name = "gopkg.in/alexcesaro/quotedprintable.v3"
packages = ["."]
revision = "2caba252f4dc53eaf6b553000885530023f54623"
[[projects]]
name = "gopkg.in/asn1-ber.v1"
packages = ["."]
revision = "379148ca0225df7a432012b8df0355c2a2063ac0"
version = "v1.2"
[[projects]]
name = "gopkg.in/bufio.v1"
packages = ["."]
revision = "567b2bfa514e796916c4747494d6ff5132a1dfce"
version = "v1"
[[projects]]
branch = "v2"
name = "gopkg.in/gomail.v2"
packages = ["."]
revision = "81ebce5c23dfd25c6c67194b37d3dd3f338c98b1"
[[projects]]
name = "gopkg.in/ini.v1"
packages = ["."]
revision = "32e4c1e6bc4e7d0d8451aa6b75200d19e37a536a"
version = "v1.32.0"
[[projects]]
name = "gopkg.in/macaron.v1"
packages = ["."]
revision = "75f2e9b42e99652f0d82b28ccb73648f44615faa"
version = "v1.2.4"
[[projects]]
name = "gopkg.in/redis.v2"
packages = ["."]
revision = "e6179049628164864e6e84e973cfb56335748dea"
version = "v2.3.2"
[[projects]]
branch = "v2"
name = "gopkg.in/yaml.v2"
packages = ["."]
revision = "d670f9405373e636a5a2765eea47fac0c9bc91a4"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "98e8d8f5fb21fe448aeb3db41c9fed85fe3bf80400e553211cf39a9c05720e01"
solver-name = "gps-cdcl"
solver-version = 1

195
Gopkg.toml Normal file
View File

@ -0,0 +1,195 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
ignored = [
"github.com/grafana/grafana/data/*",
"github.com/grafana/grafana/public/*",
"github.com/grafana/grafana/node_modules/*"
]
[[constraint]]
name = "github.com/BurntSushi/toml"
version = "0.3.0"
[[constraint]]
branch = "master"
name = "github.com/Unknwon/com"
#version = "1.0.0"
[[constraint]]
name = "github.com/aws/aws-sdk-go"
version = "1.12.65"
[[constraint]]
branch = "master"
name = "github.com/benbjohnson/clock"
[[constraint]]
branch = "master"
name = "github.com/bmizerany/assert"
[[constraint]]
name = "github.com/codegangsta/cli"
version = "1.20.0"
[[constraint]]
name = "github.com/davecgh/go-spew"
version = "1.1.0"
[[constraint]]
name = "github.com/fatih/color"
version = "1.5.0"
[[constraint]]
name = "github.com/go-ldap/ldap"
version = "2.5.1"
[[constraint]]
branch = "master"
name = "github.com/go-macaron/binding"
[[constraint]]
branch = "master"
name = "github.com/go-macaron/gzip"
[[constraint]]
branch = "master"
name = "github.com/go-macaron/session"
[[constraint]]
name = "github.com/go-sql-driver/mysql"
revision = "2cc627ac8defc45d65066ae98f898166f580f9a4"
#version = "1.3.0" //keeping this since we would rather depend on version then commit
[[constraint]]
name = "github.com/go-stack/stack"
version = "1.7.0"
[[constraint]]
name = "github.com/go-xorm/core"
revision = "e8409d73255791843585964791443dbad877058c"
#version = "0.5.7" //keeping this since we would rather depend on version then commit
[[constraint]]
name = "github.com/go-xorm/xorm"
revision = "6687a2b4e824f4d87f2d65060ec5cb0d896dff1e"
#version = "0.6.4" //keeping this since we would rather depend on version then commit
[[constraint]]
name = "github.com/gorilla/websocket"
version = "1.2.0"
[[constraint]]
name = "github.com/gosimple/slug"
version = "1.1.1"
[[constraint]]
branch = "master"
name = "github.com/grafana/grafana_plugin_model"
[[constraint]]
branch = "master"
name = "github.com/hashicorp/go-hclog"
[[constraint]]
branch = "master"
name = "github.com/hashicorp/go-version"
[[constraint]]
name = "github.com/inconshreveable/log15"
version = "2.13.0"
[[constraint]]
branch = "master"
name = "github.com/lib/pq"
[[constraint]]
name = "github.com/mattn/go-isatty"
version = "0.0.3"
[[constraint]]
name = "github.com/mattn/go-sqlite3"
version = "1.6.0"
[[constraint]]
name = "github.com/opentracing/opentracing-go"
version = "1.0.2"
[[constraint]]
name = "github.com/patrickmn/go-cache"
version = "2.1.0"
[[constraint]]
name = "github.com/prometheus/client_golang"
version = "0.9.0-pre1"
[[constraint]]
branch = "master"
name = "github.com/prometheus/client_model"
[[constraint]]
branch = "master"
name = "github.com/prometheus/common"
[[constraint]]
name = "github.com/smartystreets/goconvey"
version = "1.6.3"
[[constraint]]
name = "github.com/uber/jaeger-client-go"
version = "2.11.2"
[[constraint]]
name = "github.com/yudai/gojsondiff"
version = "1.0.0"
[[constraint]]
branch = "master"
name = "golang.org/x/net"
[[constraint]]
branch = "master"
name = "golang.org/x/oauth2"
[[constraint]]
branch = "master"
name = "golang.org/x/sync"
[[constraint]]
name = "gopkg.in/gomail.v2"
branch = "v2"
[[constraint]]
name = "gopkg.in/ini.v1"
version = "1.32.0"
[[constraint]]
name = "gopkg.in/macaron.v1"
version = "1.2.4"
[[constraint]]
branch = "v2"
name = "gopkg.in/yaml.v2"
[prune]
non-go = true
go-tests = true
unused-packages = true

View File

@ -45,23 +45,17 @@ For this you need nodejs (v.6+).
```bash
npm install -g yarn
yarn install --pure-lockfile
npm run build
```
To rebuild frontend assets (typescript, sass etc) as you change them start the watcher via.
```bash
npm run watch
```
Run tests
Run tests
```bash
npm run test
npm run jest
```
Run tests in watch mode
Run karma tests
```bash
npm run watch-test
npm run karma
```
### Recompile backend on source change

View File

@ -347,11 +347,11 @@ func ChangeWorkingDir(dir string) {
}
func grunt(params ...string) {
if runtime.GOOS == "windows" {
runPrint(`.\node_modules\.bin\grunt`, params...)
} else {
runPrint("./node_modules/.bin/grunt", params...)
}
if runtime.GOOS == "windows" {
runPrint(`.\node_modules\.bin\grunt`, params...)
} else {
runPrint("./node_modules/.bin/grunt", params...)
}
}
func gruntBuildArg(task string) []string {
@ -371,7 +371,7 @@ func gruntBuildArg(task string) []string {
}
func setup() {
runPrint("go", "get", "-v", "github.com/kardianos/govendor")
runPrint("go", "get", "-v", "github.com/golang/dep")
runPrint("go", "install", "-v", "./pkg/cmd/grafana-server")
}

View File

@ -473,7 +473,7 @@ sampler_param = 1
#################################### External Image Storage ##############
[external_image_storage]
# You can choose between (s3, webdav, gcs, azure_blob)
# You can choose between (s3, webdav, gcs, azure_blob, local)
provider =
[external_image_storage.s3]
@ -499,3 +499,6 @@ path =
account_name =
account_key =
container_name =
[external_image_storage.local]
# does not require any configuration

View File

@ -417,7 +417,7 @@ log_queries =
#################################### External image storage ##########################
[external_image_storage]
# Used for uploading images to public servers so they can be included in slack/email messages.
# you can choose between (s3, webdav, gcs, azure_blob)
# you can choose between (s3, webdav, gcs, azure_blob, local)
;provider =
[external_image_storage.s3]
@ -442,3 +442,6 @@ log_queries =
;account_name =
;account_key =
;container_name =
[external_image_storage.local]
# does not require any configuration

View File

@ -12,3 +12,10 @@
- /etc/timezone:/etc/timezone:ro
command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --innodb_monitor_enable=all]
fake-mysql-data:
image: grafana/fake-data-gen
network_mode: bridge
environment:
FD_DATASOURCE: mysql
FD_PORT: 3306

View File

@ -7,4 +7,7 @@
MYSQL_PASSWORD: password
ports:
- "3306:3306"
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
tmpfs: /var/lib/mysql:rw

View File

@ -7,3 +7,10 @@
ports:
- "5432:5432"
command: postgres -c log_connections=on -c logging_collector=on -c log_destination=stderr -c log_directory=/var/log/postgresql
fake-postgres-data:
image: grafana/fake-data-gen
network_mode: bridge
environment:
FD_DATASOURCE: postgres
FD_PORT: 5432

View File

@ -149,8 +149,10 @@ Prometheus Alertmanager | `prometheus-alertmanager` | no
# Enable images in notifications {#external-image-store}
Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessible (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports
Amazon S3, Webdav, and Azure Blob Storage for this. So to set that up you need to configure the [external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini config file.
Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessable (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports
Amazon S3, Webdav, Google Cloud Storage and Azure Blob Storage. So to set that up you need to configure the [external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini config file.
Be aware that some notifiers requires public access to the image to be able to include it in the notification. So make sure to enable public access to the images. If your using local image uploader, your Grafana instance need to be accessible by the internet.
Currently only the Email Channels attaches images if no external image store is specified. To include images in alert notifications for other channels then you need to set up an external image store.

View File

@ -68,6 +68,23 @@ server {
}
}
```
#### HAProxy configuration with sub path
```bash
frontend http-in
bind *:80
use_backend grafana_backend if { path /grafana } or { path_beg /grafana/ }
backend grafana_backend
# Requires haproxy >= 1.6
http-request set-path %[path,regsub(^/grafana/?,/)]
# Works for haproxy < 1.6
# reqrep ^([^\ ]*\ /)grafana[/]?(.*) \1\2
server grafana localhost:3000
```
### IIS URL Rewrite Rule (Windows) with Subpath
IIS requires that the URL Rewrite module is installed.

View File

@ -540,6 +540,70 @@ allowed_organizations =
allowed_organizations =
```
### Set up oauth2 with Auth0
1. Create a new Client in Auth0
- Name: Grafana
- Type: Regular Web Application
2. Go to the Settings tab and set:
- Allowed Callback URLs: `https://<grafana domain>/login/generic_oauth`
3. Click Save Changes, then use the values at the top of the page to configure Grafana:
```bash
[auth.generic_oauth]
enabled = true
allow_sign_up = true
team_ids =
allowed_organizations =
name = Auth0
client_id = <client id>
client_secret = <client secret>
scopes = openid profile email
auth_url = https://<domain>/authorize
token_url = https://<domain>/oauth/token
api_url = https://<domain>/userinfo
```
### Set up oauth2 with Azure Active Directory
1. Log in to portal.azure.com and click "Azure Active Directory" in the side menu, then click the "Properties" sub-menu item.
2. Copy the "Directory ID", this is needed for setting URLs later
3. Click "App Registrations" and add a new application registration:
- Name: Grafana
- Application type: Web app / API
- Sign-on URL: `https://<grafana domain>/login/generic_oauth`
4. Click the name of the new application to open the application details page.
5. Note down the "Application ID", this will be the OAuth client id.
6. Click "Settings", then click "Keys" and add a new entry under Passwords
- Key Description: Grafana OAuth
- Duration: Never Expires
7. Click Save then copy the key value, this will be the OAuth client secret.
8. Configure Grafana as follows:
```bash
[auth.generic_oauth]
name = Azure AD
enabled = true
allow_sign_up = true
client_id = <application id>
client_secret = <key value>
scopes = openid email name
auth_url = https://login.microsoftonline.com/<directory id>/oauth2/authorize
token_url = https://login.microsoftonline.com/<directory id>/oauth2/token
api_url =
team_ids =
allowed_organizations =
```
<hr>
## [auth.basic]
@ -766,7 +830,7 @@ Time to live for snapshots.
These options control how images should be made public so they can be shared on services like slack.
### provider
You can choose between (s3, webdav, gcs, azure_blob). If left empty Grafana will ignore the upload action.
You can choose between (s3, webdav, gcs, azure_blob, local). If left empty Grafana will ignore the upload action.
## [external_image_storage.s3]

View File

@ -57,7 +57,7 @@ For this you need nodejs (v.6+).
```bash
npm install -g yarn
yarn install --pure-lockfile
npm run build
npm run watch
```
## Running Grafana Locally

View File

@ -91,8 +91,7 @@
"typescript": "^2.6.2",
"webpack": "^3.10.0",
"webpack-bundle-analyzer": "^2.9.0",
"webpack-cleanup-plugin": "^0.5.1",
"angular-mocks": "^1.6.6",
"webpack-cleanup-plugin": "^0.5.1",
"webpack-merge": "^4.1.0",
"zone.js": "^0.7.2"
},
@ -135,7 +134,7 @@
"clipboard": "^1.7.1",
"d3": "^4.11.0",
"d3-scale-chromatic": "^1.1.1",
"eventemitter3": "^2.0.2",
"eventemitter3": "^2.0.3",
"file-saver": "^1.3.3",
"jquery": "^3.2.1",
"lodash": "^4.17.4",
@ -148,12 +147,13 @@
"prop-types": "^15.6.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-grid-layout": "^0.16.1",
"react-grid-layout": "^0.16.2",
"react-popper": "^0.7.5",
"react-highlight-words": "^0.10.0",
"react-select": "^1.1.0",
"react-sizeme": "^2.3.6",
"remarkable": "^1.7.1",
"rst2html": "github:thoward/rst2html#990cb89",
"rxjs": "^5.4.3",
"tether": "^1.4.0",
"tether-drop": "https://github.com/torkelo/drop",

View File

@ -162,6 +162,10 @@ func (hs *HttpServer) newMacaron() *macaron.Macaron {
hs.mapStatic(m, setting.StaticRootPath, "", "public")
hs.mapStatic(m, setting.StaticRootPath, "robots.txt", "robots.txt")
if setting.ImageUploadProvider == "local" {
hs.mapStatic(m, setting.ImagesDir, "", "/public/img/attachments")
}
m.Use(macaron.Renderer(macaron.RenderOptions{
Directory: path.Join(setting.StaticRootPath, "views"),
IndentJSON: macaron.Env != macaron.PROD,

View File

@ -1,6 +1,7 @@
package api
import (
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
@ -11,7 +12,6 @@ import (
"net/http"
"net/url"
"golang.org/x/net/context"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/bus"
@ -29,7 +29,7 @@ var (
ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter")
ErrUsersQuotaReached = errors.New("Users quota reached")
ErrNoEmail = errors.New("Login provider didn't return an email address")
oauthLogger = log.New("oauth.login")
oauthLogger = log.New("oauth")
)
func GenStateString() string {
@ -96,7 +96,9 @@ func OAuthLogin(ctx *middleware.Context) {
if setting.OAuthService.OAuthInfos[name].TlsClientCert != "" || setting.OAuthService.OAuthInfos[name].TlsClientKey != "" {
cert, err := tls.LoadX509KeyPair(setting.OAuthService.OAuthInfos[name].TlsClientCert, setting.OAuthService.OAuthInfos[name].TlsClientKey)
if err != nil {
log.Fatal(1, "Failed to setup TlsClientCert", "oauth provider", name, "error", err)
ctx.Logger.Error("Failed to setup TlsClientCert", "oauth", name, "error", err)
ctx.Handle(500, "login.OAuthLogin(Failed to setup TlsClientCert)", nil)
return
}
tr.TLSClientConfig.Certificates = append(tr.TLSClientConfig.Certificates, cert)
@ -105,7 +107,9 @@ func OAuthLogin(ctx *middleware.Context) {
if setting.OAuthService.OAuthInfos[name].TlsClientCa != "" {
caCert, err := ioutil.ReadFile(setting.OAuthService.OAuthInfos[name].TlsClientCa)
if err != nil {
log.Fatal(1, "Failed to setup TlsClientCa", "oauth provider", name, "error", err)
ctx.Logger.Error("Failed to setup TlsClientCa", "oauth", name, "error", err)
ctx.Handle(500, "login.OAuthLogin(Failed to setup TlsClientCa)", nil)
return
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
@ -124,13 +128,13 @@ func OAuthLogin(ctx *middleware.Context) {
// token.TokenType was defaulting to "bearer", which is out of spec, so we explicitly set to "Bearer"
token.TokenType = "Bearer"
ctx.Logger.Debug("OAuthLogin Got token")
oauthLogger.Debug("OAuthLogin Got token", "token", token)
// set up oauth2 client
client := connect.Client(oauthCtx, token)
// get user info
userInfo, err := connect.UserInfo(client)
userInfo, err := connect.UserInfo(client, token)
if err != nil {
if sErr, ok := err.(*social.Error); ok {
redirectWithError(ctx, sErr)
@ -140,7 +144,7 @@ func OAuthLogin(ctx *middleware.Context) {
return
}
ctx.Logger.Debug("OAuthLogin got user info", "userInfo", userInfo)
oauthLogger.Debug("OAuthLogin got user info", "userInfo", userInfo)
// validate that we got at least an email address
if userInfo.Email == "" {
@ -205,8 +209,7 @@ func OAuthLogin(ctx *middleware.Context) {
}
func redirectWithError(ctx *middleware.Context, err error, v ...interface{}) {
ctx.Logger.Info(err.Error(), v...)
// TODO: we can use the flash storage here once it's implemented
ctx.Logger.Error(err.Error(), v...)
ctx.Session.Set("loginError", err.Error())
ctx.Redirect(setting.AppSubUrl + "/login")
}

View File

@ -88,6 +88,8 @@ func NewImageUploader() (ImageUploader, error) {
container_name := azureBlobSec.Key("container_name").MustString("")
return NewAzureBlobUploader(account_name, account_key, container_name), nil
case "local":
return NewLocalImageUploader()
}
if setting.ImageUploadProvider != "" {

View File

@ -143,5 +143,23 @@ func TestImageUploaderFactory(t *testing.T) {
So(original.container_name, ShouldEqual, "container_name")
})
})
Convey("Local uploader", func() {
var err error
setting.NewConfigContext(&setting.CommandLineArgs{
HomePath: "../../../",
})
setting.ImageUploadProvider = "local"
uploader, err := NewImageUploader()
So(err, ShouldBeNil)
original, ok := uploader.(*LocalUploader)
So(ok, ShouldBeTrue)
So(original, ShouldNotBeNil)
})
})
}

View File

@ -0,0 +1,22 @@
package imguploader
import (
"context"
"path"
"path/filepath"
"github.com/grafana/grafana/pkg/setting"
)
type LocalUploader struct {
}
func (u *LocalUploader) Upload(ctx context.Context, imageOnDiskPath string) (string, error) {
filename := filepath.Base(imageOnDiskPath)
image_url := setting.ToAbsUrl(path.Join("public/img/attachments", filename))
return image_url, nil
}
func NewLocalImageUploader() (*LocalUploader, error) {
return &LocalUploader{}, nil
}

View File

@ -0,0 +1,18 @@
package imguploader
import (
"context"
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestUploadToLocal(t *testing.T) {
Convey("[Integration test] for external_image_store.local", t, func() {
localUploader, _ := NewLocalImageUploader()
path, err := localUploader.Upload(context.Background(), "../../../public/img/logo_transparent_400x.png")
So(err, ShouldBeNil)
So(path, ShouldContainSubstring, "/public/img/attachments")
})
}

View File

@ -26,9 +26,10 @@ import (
"strings"
"time"
"context"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
"golang.org/x/net/context"
dto "github.com/prometheus/client_model/go"

View File

@ -379,6 +379,7 @@ func sendUsageStats() {
metrics["stats.alerts.count"] = statsQuery.Result.Alerts
metrics["stats.active_users.count"] = statsQuery.Result.ActiveUsers
metrics["stats.datasources.count"] = statsQuery.Result.Datasources
metrics["stats.stars.count"] = statsQuery.Result.Stars
dsStats := models.GetDataSourceStatsQuery{}
if err := bus.Dispatch(&dsStats); err != nil {

View File

@ -8,6 +8,7 @@ type SystemStats struct {
Orgs int64
Playlists int64
Alerts int64
Stars int64
}
type DataSourceStats struct {

View File

@ -1,22 +0,0 @@
package tsdb
import (
proto "github.com/grafana/grafana/pkg/tsdb/models"
"golang.org/x/net/context"
)
type GRPCClient struct {
proto.TsdbPluginClient
}
func (m *GRPCClient) Query(ctx context.Context, req *proto.TsdbQuery) (*proto.Response, error) {
return m.TsdbPluginClient.Query(ctx, req)
}
type GRPCServer struct {
TsdbPlugin
}
func (m *GRPCServer) Query(ctx context.Context, req *proto.TsdbQuery) (*proto.Response, error) {
return m.TsdbPlugin.Query(ctx, req)
}

View File

@ -1,27 +0,0 @@
package tsdb
import (
"golang.org/x/net/context"
proto "github.com/grafana/grafana/pkg/tsdb/models"
plugin "github.com/hashicorp/go-plugin"
"google.golang.org/grpc"
)
type TsdbPlugin interface {
Query(ctx context.Context, req *proto.TsdbQuery) (*proto.Response, error)
}
type TsdbPluginImpl struct {
plugin.NetRPCUnsupportedPlugin
Plugin TsdbPlugin
}
func (p *TsdbPluginImpl) GRPCServer(s *grpc.Server) error {
proto.RegisterTsdbPluginServer(s, &GRPCServer{p.Plugin})
return nil
}
func (p *TsdbPluginImpl) GRPCClient(c *grpc.ClientConn) (interface{}, error) {
return &GRPCClient{proto.NewTsdbPluginClient(c)}, nil
}

View File

@ -1,22 +1,23 @@
package tsdb
package wrapper
import (
"context"
"errors"
"fmt"
"github.com/grafana/grafana/pkg/components/null"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/tsdb"
proto "github.com/grafana/grafana/pkg/tsdb/models"
"golang.org/x/net/context"
"github.com/grafana/grafana_plugin_model/go/datasource"
)
func NewDatasourcePluginWrapper(log log.Logger, plugin TsdbPlugin) *DatasourcePluginWrapper {
return &DatasourcePluginWrapper{TsdbPlugin: plugin, logger: log}
func NewDatasourcePluginWrapper(log log.Logger, plugin datasource.DatasourcePlugin) *DatasourcePluginWrapper {
return &DatasourcePluginWrapper{DatasourcePlugin: plugin, logger: log}
}
type DatasourcePluginWrapper struct {
TsdbPlugin
datasource.DatasourcePlugin
logger log.Logger
}
@ -26,28 +27,29 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou
return nil, err
}
pbQuery := &proto.TsdbQuery{
Datasource: &proto.DatasourceInfo{
JsonData: string(jsonData),
Name: ds.Name,
Type: ds.Type,
Url: ds.Url,
Id: ds.Id,
OrgId: ds.OrgId,
pbQuery := &datasource.DatasourceRequest{
Datasource: &datasource.DatasourceInfo{
Name: ds.Name,
Type: ds.Type,
Url: ds.Url,
Id: ds.Id,
OrgId: ds.OrgId,
JsonData: string(jsonData),
DecryptedSecureJsonData: ds.SecureJsonData.Decrypt(),
},
TimeRange: &proto.TimeRange{
TimeRange: &datasource.TimeRange{
FromRaw: query.TimeRange.From,
ToRaw: query.TimeRange.To,
ToEpochMs: query.TimeRange.GetToAsMsEpoch(),
FromEpochMs: query.TimeRange.GetFromAsMsEpoch(),
},
Queries: []*proto.Query{},
Queries: []*datasource.Query{},
}
for _, q := range query.Queries {
modelJson, _ := q.Model.MarshalJSON()
pbQuery.Queries = append(pbQuery.Queries, &proto.Query{
pbQuery.Queries = append(pbQuery.Queries, &datasource.Query{
ModelJson: string(modelJson),
IntervalMs: q.IntervalMs,
RefId: q.RefId,
@ -55,7 +57,7 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou
})
}
pbres, err := tw.TsdbPlugin.Query(ctx, pbQuery)
pbres, err := tw.DatasourcePlugin.Query(ctx, pbQuery)
if err != nil {
return nil, err
@ -66,9 +68,11 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou
}
for _, r := range pbres.Results {
res.Results[r.RefId] = &tsdb.QueryResult{
RefId: r.RefId,
Series: []*tsdb.TimeSeries{},
qr := &tsdb.QueryResult{
RefId: r.RefId,
Series: []*tsdb.TimeSeries{},
Error: errors.New(r.Error),
ErrorString: r.Error,
}
for _, s := range r.GetSeries() {
@ -79,7 +83,7 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou
points = append(points, po)
}
res.Results[r.RefId].Series = append(res.Results[r.RefId].Series, &tsdb.TimeSeries{
qr.Series = append(qr.Series, &tsdb.TimeSeries{
Name: s.Name,
Tags: s.Tags,
Points: points,
@ -90,12 +94,14 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou
if err != nil {
return nil, err
}
res.Results[r.RefId].Tables = mappedTables
qr.Tables = mappedTables
res.Results[r.RefId] = qr
}
return res, nil
}
func (tw *DatasourcePluginWrapper) mapTables(r *proto.QueryResult) ([]*tsdb.Table, error) {
func (tw *DatasourcePluginWrapper) mapTables(r *datasource.QueryResult) ([]*tsdb.Table, error) {
var tables []*tsdb.Table
for _, t := range r.GetTables() {
mappedTable, err := tw.mapTable(t)
@ -107,7 +113,7 @@ func (tw *DatasourcePluginWrapper) mapTables(r *proto.QueryResult) ([]*tsdb.Tabl
return tables, nil
}
func (tw *DatasourcePluginWrapper) mapTable(t *proto.Table) (*tsdb.Table, error) {
func (tw *DatasourcePluginWrapper) mapTable(t *datasource.Table) (*tsdb.Table, error) {
table := &tsdb.Table{}
for _, c := range t.GetColumns() {
table.Columns = append(table.Columns, tsdb.TableColumn{
@ -130,19 +136,19 @@ func (tw *DatasourcePluginWrapper) mapTable(t *proto.Table) (*tsdb.Table, error)
return table, nil
}
func (tw *DatasourcePluginWrapper) mapRowValue(rv *proto.RowValue) (interface{}, error) {
func (tw *DatasourcePluginWrapper) mapRowValue(rv *datasource.RowValue) (interface{}, error) {
switch rv.Kind {
case proto.RowValue_TYPE_NULL:
case datasource.RowValue_TYPE_NULL:
return nil, nil
case proto.RowValue_TYPE_INT64:
case datasource.RowValue_TYPE_INT64:
return rv.Int64Value, nil
case proto.RowValue_TYPE_BOOL:
case datasource.RowValue_TYPE_BOOL:
return rv.BoolValue, nil
case proto.RowValue_TYPE_STRING:
case datasource.RowValue_TYPE_STRING:
return rv.StringValue, nil
case proto.RowValue_TYPE_DOUBLE:
case datasource.RowValue_TYPE_DOUBLE:
return rv.DoubleValue, nil
case proto.RowValue_TYPE_BYTES:
case datasource.RowValue_TYPE_BYTES:
return rv.BytesValue, nil
default:
return nil, fmt.Errorf("Unsupported row value %v from plugin", rv.Kind)

View File

@ -1,17 +1,18 @@
package tsdb
package wrapper
import (
"testing"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/tsdb"
"github.com/grafana/grafana/pkg/tsdb/models"
"testing"
"github.com/grafana/grafana_plugin_model/go/datasource"
)
func TestMapTables(t *testing.T) {
dpw := NewDatasourcePluginWrapper(log.New("test-logger"), nil)
var qr = &proto.QueryResult{}
qr.Tables = append(qr.Tables, &proto.Table{
Columns: []*proto.TableColumn{},
var qr = &datasource.QueryResult{}
qr.Tables = append(qr.Tables, &datasource.Table{
Columns: []*datasource.TableColumn{},
Rows: nil,
})
want := []*tsdb.Table{{}}
@ -28,16 +29,16 @@ func TestMapTables(t *testing.T) {
func TestMapTable(t *testing.T) {
dpw := NewDatasourcePluginWrapper(log.New("test-logger"), nil)
source := &proto.Table{
Columns: []*proto.TableColumn{{Name: "column1"}, {Name: "column2"}},
Rows: []*proto.TableRow{{
Values: []*proto.RowValue{
source := &datasource.Table{
Columns: []*datasource.TableColumn{{Name: "column1"}, {Name: "column2"}},
Rows: []*datasource.TableRow{{
Values: []*datasource.RowValue{
{
Kind: proto.RowValue_TYPE_BOOL,
Kind: datasource.RowValue_TYPE_BOOL,
BoolValue: true,
},
{
Kind: proto.RowValue_TYPE_INT64,
Kind: datasource.RowValue_TYPE_INT64,
Int64Value: 42,
},
},
@ -71,37 +72,37 @@ func TestMapTable(t *testing.T) {
func TestMappingRowValue(t *testing.T) {
dpw := NewDatasourcePluginWrapper(log.New("test-logger"), nil)
boolRowValue, _ := dpw.mapRowValue(&proto.RowValue{Kind: proto.RowValue_TYPE_BOOL, BoolValue: true})
boolRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_BOOL, BoolValue: true})
haveBool, ok := boolRowValue.(bool)
if !ok || haveBool != true {
t.Fatalf("Expected true, was %s", haveBool)
}
intRowValue, _ := dpw.mapRowValue(&proto.RowValue{Kind: proto.RowValue_TYPE_INT64, Int64Value: 42})
intRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_INT64, Int64Value: 42})
haveInt, ok := intRowValue.(int64)
if !ok || haveInt != 42 {
t.Fatalf("Expected %d, was %d", 42, haveInt)
}
stringRowValue, _ := dpw.mapRowValue(&proto.RowValue{Kind: proto.RowValue_TYPE_STRING, StringValue: "grafana"})
stringRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_STRING, StringValue: "grafana"})
haveString, ok := stringRowValue.(string)
if !ok || haveString != "grafana" {
t.Fatalf("Expected %s, was %s", "grafana", haveString)
}
doubleRowValue, _ := dpw.mapRowValue(&proto.RowValue{Kind: proto.RowValue_TYPE_DOUBLE, DoubleValue: 1.5})
doubleRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_DOUBLE, DoubleValue: 1.5})
haveDouble, ok := doubleRowValue.(float64)
if !ok || haveDouble != 1.5 {
t.Fatalf("Expected %v, was %v", 1.5, haveDouble)
}
bytesRowValue, _ := dpw.mapRowValue(&proto.RowValue{Kind: proto.RowValue_TYPE_BYTES, BytesValue: []byte{66}})
bytesRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_BYTES, BytesValue: []byte{66}})
haveBytes, ok := bytesRowValue.([]byte)
if !ok || len(haveBytes) != 1 || haveBytes[0] != 66 {
t.Fatalf("Expected %v, was %v", []byte{66}, haveBytes)
}
haveNil, _ := dpw.mapRowValue(&proto.RowValue{Kind: proto.RowValue_TYPE_NULL})
haveNil, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_NULL})
if haveNil != nil {
t.Fatalf("Expected %v, was %v", nil, haveNil)
}

View File

@ -14,8 +14,9 @@ import (
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
shared "github.com/grafana/grafana/pkg/plugins/datasource/tsdb"
"github.com/grafana/grafana/pkg/plugins/datasource/wrapper"
"github.com/grafana/grafana/pkg/tsdb"
"github.com/grafana/grafana_plugin_model/go/datasource"
plugin "github.com/hashicorp/go-plugin"
)
@ -92,7 +93,7 @@ func (p *DataSourcePlugin) spawnSubProcess() error {
p.client = plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: handshakeConfig,
Plugins: map[string]plugin.Plugin{p.Id: &shared.TsdbPluginImpl{}},
Plugins: map[string]plugin.Plugin{p.Id: &datasource.DatasourcePluginImpl{}},
Cmd: exec.Command(fullpath),
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
Logger: LogWrapper{Logger: p.log},
@ -108,10 +109,10 @@ func (p *DataSourcePlugin) spawnSubProcess() error {
return err
}
plugin := raw.(shared.TsdbPlugin)
plugin := raw.(datasource.DatasourcePlugin)
tsdb.RegisterTsdbQueryEndpoint(p.Id, func(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
return shared.NewDatasourcePluginWrapper(p.log, plugin), nil
return wrapper.NewDatasourcePluginWrapper(p.log, plugin), nil
})
return nil

View File

@ -20,7 +20,7 @@ func init() {
OptionsTemplate: `
<h3 class="page-heading">Email addresses</h3>
<div class="gf-form">
<textarea rows="7" class="gf-form-input width-25" required ng-model="ctrl.model.settings.addresses"></textarea>
<textarea rows="7" class="gf-form-input width-27" required ng-model="ctrl.model.settings.addresses"></textarea>
</div>
<div class="gf-form">
<span>You can enter multiple email addresses using a ";" separator</span>

View File

@ -23,6 +23,10 @@ func init() {
<span class="gf-form-label width-14">API Key</span>
<input type="text" required class="gf-form-input max-width-22" ng-model="ctrl.model.settings.apiKey" placeholder="OpsGenie API Key"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-14">Alert API Url</span>
<input type="text" required class="gf-form-input max-width-22" ng-model="ctrl.model.settings.apiUrl" placeholder="https://api.opsgenie.com/v2/alerts"></input>
</div>
<div class="gf-form">
<gf-form-switch
class="gf-form"
@ -43,13 +47,18 @@ var (
func NewOpsGenieNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
autoClose := model.Settings.Get("autoClose").MustBool(true)
apiKey := model.Settings.Get("apiKey").MustString()
apiUrl := model.Settings.Get("apiUrl").MustString()
if apiKey == "" {
return nil, alerting.ValidationError{Reason: "Could not find api key property in settings"}
}
if apiUrl == "" {
apiUrl = opsgenieAlertURL
}
return &OpsGenieNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
ApiKey: apiKey,
ApiUrl: apiUrl,
AutoClose: autoClose,
log: log.New("alerting.notifier.opsgenie"),
}, nil
@ -58,6 +67,7 @@ func NewOpsGenieNotifier(model *m.AlertNotification) (alerting.Notifier, error)
type OpsGenieNotifier struct {
NotifierBase
ApiKey string
ApiUrl string
AutoClose bool
log log.Logger
}
@ -105,7 +115,7 @@ func (this *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) err
body, _ := bodyJSON.MarshalJSON()
cmd := &m.SendWebhookSync{
Url: opsgenieAlertURL,
Url: this.ApiUrl,
Body: string(body),
HttpMethod: "POST",
HttpHeader: map[string]string{
@ -129,7 +139,7 @@ func (this *OpsGenieNotifier) closeAlert(evalContext *alerting.EvalContext) erro
body, _ := bodyJSON.MarshalJSON()
cmd := &m.SendWebhookSync{
Url: fmt.Sprintf("%s/alertId-%d/close?identifierType=alias", opsgenieAlertURL, evalContext.Rule.Id),
Url: fmt.Sprintf("%s/alertId-%d/close?identifierType=alias", this.ApiUrl, evalContext.Rule.Id),
Body: string(body),
HttpMethod: "POST",
HttpHeader: map[string]string{

View File

@ -5,20 +5,25 @@ import (
"path/filepath"
"strings"
"github.com/grafana/grafana/pkg/log"
yaml "gopkg.in/yaml.v2"
)
type configReader struct {
path string
log log.Logger
}
func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) {
var dashboards []*DashboardsAsConfig
files, err := ioutil.ReadDir(cr.path)
if err != nil {
return nil, err
cr.log.Error("cant read dashboard provisioning files from directory", "path", cr.path)
return dashboards, nil
}
var dashboards []*DashboardsAsConfig
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml") {
continue
@ -30,13 +35,13 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) {
return nil, err
}
var datasource []*DashboardsAsConfig
err = yaml.Unmarshal(yamlFile, &datasource)
var dashCfg []*DashboardsAsConfig
err = yaml.Unmarshal(yamlFile, &dashCfg)
if err != nil {
return nil, err
}
dashboards = append(dashboards, datasource...)
dashboards = append(dashboards, dashCfg...)
}
for i := range dashboards {

View File

@ -3,6 +3,7 @@ package dashboards
import (
"testing"
"github.com/grafana/grafana/pkg/log"
. "github.com/smartystreets/goconvey/convey"
)
@ -16,8 +17,8 @@ func TestDashboardsAsConfig(t *testing.T) {
Convey("Can read config file", func() {
cfgProvifer := configReader{path: simpleDashboardConfig}
cfg, err := cfgProvifer.readConfig()
cfgProvider := configReader{path: simpleDashboardConfig, log: log.New("test-logger")}
cfg, err := cfgProvider.readConfig()
if err != nil {
t.Fatalf("readConfig return an error %v", err)
}
@ -33,7 +34,7 @@ func TestDashboardsAsConfig(t *testing.T) {
So(ds.Editable, ShouldBeTrue)
So(len(ds.Options), ShouldEqual, 1)
So(ds.Options["folder"], ShouldEqual, "/var/lib/grafana/dashboards")
So(ds.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards")
ds2 := cfg[1]
@ -44,19 +45,29 @@ func TestDashboardsAsConfig(t *testing.T) {
So(ds2.Editable, ShouldBeFalse)
So(len(ds2.Options), ShouldEqual, 1)
So(ds2.Options["folder"], ShouldEqual, "/var/lib/grafana/dashboards")
So(ds2.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards")
})
Convey("Should skip broken config files", func() {
Convey("Should skip invalid path", func() {
cfgProvifer := configReader{path: brokenConfigs}
cfg, err := cfgProvifer.readConfig()
cfgProvider := configReader{path: "/invalid-directory", log: log.New("test-logger")}
cfg, err := cfgProvider.readConfig()
if err != nil {
t.Fatalf("readConfig return an error %v", err)
}
So(len(cfg), ShouldEqual, 0)
})
Convey("Should skip broken config files", func() {
cfgProvider := configReader{path: brokenConfigs, log: log.New("test-logger")}
cfg, err := cfgProvider.readConfig()
if err != nil {
t.Fatalf("readConfig return an error %v", err)
}
So(len(cfg), ShouldEqual, 0)
})
})
}

View File

@ -14,9 +14,10 @@ type DashboardProvisioner struct {
}
func Provision(ctx context.Context, configDirectory string) (*DashboardProvisioner, error) {
log := log.New("provisioning.dashboard")
d := &DashboardProvisioner{
cfgReader: &configReader{path: configDirectory},
log: log.New("provisioning.dashboard"),
cfgReader: &configReader{path: configDirectory, log: log},
log: log,
ctx: ctx,
}

View File

@ -34,9 +34,15 @@ type fileReader struct {
}
func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReader, error) {
path, ok := cfg.Options["folder"].(string)
var path string
path, ok := cfg.Options["path"].(string)
if !ok {
return nil, fmt.Errorf("Failed to load dashboards. folder param is not a string")
path, ok = cfg.Options["folder"].(string)
if !ok {
return nil, fmt.Errorf("Failed to load dashboards. path param is not a string")
}
log.Warn("[Deprecated] The folder property is deprecated. Please use path instead.")
}
if _, err := os.Stat(path); os.IsNotExist(err) {
@ -145,6 +151,17 @@ func createWalkFn(fr *fileReader, folderId int64) filepath.WalkFunc {
return nil
}
checkFilepath, err := filepath.EvalSymlinks(path)
if path != checkFilepath {
path = checkFilepath
fi, err := os.Lstat(checkFilepath)
if err != nil {
return err
}
fileInfo = fi
}
cachedDashboard, exist := fr.cache.getCache(path)
if exist && cachedDashboard.UpdatedAt == fileInfo.ModTime() {
return nil

View File

@ -42,7 +42,7 @@ func TestDashboardFileReader(t *testing.T) {
}
Convey("Can read default dashboard", func() {
cfg.Options["folder"] = defaultDashboards
cfg.Options["path"] = defaultDashboards
cfg.Folder = "Team A"
reader, err := NewDashboardFileReader(cfg, logger)
@ -67,7 +67,7 @@ func TestDashboardFileReader(t *testing.T) {
})
Convey("Should not update dashboards when db is newer", func() {
cfg.Options["folder"] = oneDashboard
cfg.Options["path"] = oneDashboard
fakeRepo.getDashboard = append(fakeRepo.getDashboard, &models.Dashboard{
Updated: time.Now().Add(time.Hour),
@ -84,7 +84,7 @@ func TestDashboardFileReader(t *testing.T) {
})
Convey("Can read default dashboard and replace old version in database", func() {
cfg.Options["folder"] = oneDashboard
cfg.Options["path"] = oneDashboard
stat, _ := os.Stat(oneDashboard + "/dashboard1.json")
@ -115,7 +115,7 @@ func TestDashboardFileReader(t *testing.T) {
})
Convey("Broken dashboards should not cause error", func() {
cfg.Options["folder"] = brokenDashboards
cfg.Options["path"] = brokenDashboards
_, err := NewDashboardFileReader(cfg, logger)
So(err, ShouldBeNil)
@ -167,7 +167,7 @@ func TestDashboardFileReader(t *testing.T) {
OrgId: 1,
Folder: "",
Options: map[string]interface{}{
"folder": defaultDashboards,
"path": defaultDashboards,
},
}
@ -184,6 +184,30 @@ func TestDashboardFileReader(t *testing.T) {
So(shouldSkip, ShouldBeNil)
})
})
Convey("Can use bpth path and folder as dashboard path", func() {
cfg := &DashboardsAsConfig{
Name: "Default",
Type: "file",
OrgId: 1,
Folder: "",
Options: map[string]interface{}{},
}
Convey("using path parameter", func() {
cfg.Options["path"] = defaultDashboards
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
So(err, ShouldBeNil)
So(reader.Path, ShouldEqual, defaultDashboards)
})
Convey("using folder as options", func() {
cfg.Options["folder"] = defaultDashboards
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
So(err, ShouldBeNil)
So(reader.Path, ShouldEqual, defaultDashboards)
})
})
})
}

View File

@ -4,9 +4,9 @@
editable: true
type: file
options:
folder: /var/lib/grafana/dashboards
path: /var/lib/grafana/dashboards
- name: 'default'
type: file
options:
folder: /var/lib/grafana/dashboards
path: /var/lib/grafana/dashboards

View File

@ -25,13 +25,13 @@ func Provision(configDirectory string) error {
type DatasourceProvisioner struct {
log log.Logger
cfgProvider configReader
cfgProvider *configReader
}
func newDatasourceProvisioner(log log.Logger) DatasourceProvisioner {
return DatasourceProvisioner{
log: log,
cfgProvider: configReader{},
cfgProvider: &configReader{log: log},
}
}
@ -95,15 +95,19 @@ func (dc *DatasourceProvisioner) deleteDatasources(dsToDelete []*DeleteDatasourc
return nil
}
type configReader struct{}
type configReader struct {
log log.Logger
}
func (cr *configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) {
var datasources []*DatasourcesAsConfig
func (configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) {
files, err := ioutil.ReadDir(path)
if err != nil {
return nil, err
cr.log.Error("cant read datasource provisioning files from directory", "path", path)
return datasources, nil
}
var datasources []*DatasourcesAsConfig
for _, file := range files {
if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") {
filename, _ := filepath.Abs(filepath.Join(path, file.Name()))

View File

@ -11,7 +11,7 @@ import (
)
var (
logger log.Logger = log.New("fake.logger")
logger log.Logger = log.New("fake.log")
oneDatasourcesConfig string = ""
twoDatasourcesConfig string = "./test-configs/two-datasources"
twoDatasourcesConfigPurgeOthers string = "./test-configs/insert-two-delete-two"
@ -115,12 +115,23 @@ func TestDatasourceAsConfig(t *testing.T) {
})
Convey("broken yaml should return error", func() {
_, err := configReader{}.readConfig(brokenYaml)
reader := &configReader{}
_, err := reader.readConfig(brokenYaml)
So(err, ShouldNotBeNil)
})
Convey("skip invalid directory", func() {
cfgProvifer := &configReader{log: log.New("test logger")}
cfg, err := cfgProvifer.readConfig("./invalid-directory")
if err != nil {
t.Fatalf("readConfig return an error %v", err)
}
So(len(cfg), ShouldEqual, 0)
})
Convey("can read all properties", func() {
cfgProvifer := configReader{}
cfgProvifer := &configReader{log: log.New("test logger")}
cfg, err := cfgProvifer.readConfig(allProperties)
if err != nil {
t.Fatalf("readConfig return an error %v", err)

View File

@ -24,7 +24,7 @@ func init() {
func GetAlertById(query *m.GetAlertByIdQuery) error {
alert := m.Alert{}
has, err := x.Id(query.Id).Get(&alert)
has, err := x.ID(query.Id).Get(&alert)
if !has {
return fmt.Errorf("could not find alert")
}
@ -113,7 +113,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
}
alerts := make([]*m.Alert, 0)
if err := x.Sql(sql.String(), params...).Find(&alerts); err != nil {
if err := x.SQL(sql.String(), params...).Find(&alerts); err != nil {
return err
}
@ -260,7 +260,7 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error {
alert.ExecutionError = cmd.Error
}
sess.Id(alert.Id).Update(&alert)
sess.ID(alert.Id).Update(&alert)
return nil
})
}
@ -324,7 +324,7 @@ func GetAlertStatesForDashboard(query *m.GetAlertStatesForDashboardQuery) error
WHERE org_id = ? AND dashboard_id = ?`
query.Result = make([]*m.AlertStateInfoDTO, 0)
err := x.Sql(rawSql, query.OrgId, query.DashboardId).Find(&query.Result)
err := x.SQL(rawSql, query.OrgId, query.DashboardId).Find(&query.Result)
return err
}

View File

@ -76,7 +76,7 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro
sql.WriteString(`)`)
results := make([]*m.AlertNotification, 0)
if err := x.Sql(sql.String(), params...).Find(&results); err != nil {
if err := x.SQL(sql.String(), params...).Find(&results); err != nil {
return err
}
@ -165,7 +165,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
return inTransaction(func(sess *DBSession) (err error) {
current := m.AlertNotification{}
if _, err = sess.Id(cmd.Id).Get(&current); err != nil {
if _, err = sess.ID(cmd.Id).Get(&current); err != nil {
return err
}
@ -187,7 +187,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
sess.UseBool("is_default")
if affected, err := sess.Id(cmd.Id).Update(current); err != nil {
if affected, err := sess.ID(cmd.Id).Update(current); err != nil {
return err
} else if affected == 0 {
return fmt.Errorf("Could not find alert notification")

View File

@ -176,6 +176,7 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
folder.has_acl = ` + dialect.BooleanStr(false) + `
) AND
da.dashboard_id = -1
ORDER BY 1 ASC
`
query.Result = make([]*m.DashboardAclInfoDTO, 0)

View File

@ -17,15 +17,13 @@ func init() {
}
func CreatePlaylist(cmd *m.CreatePlaylistCommand) error {
var err error
playlist := m.Playlist{
Name: cmd.Name,
Interval: cmd.Interval,
OrgId: cmd.OrgId,
}
_, err = x.Insert(&playlist)
_, err := x.Insert(&playlist)
fmt.Printf("%v", playlist.Id)
@ -47,7 +45,6 @@ func CreatePlaylist(cmd *m.CreatePlaylistCommand) error {
}
func UpdatePlaylist(cmd *m.UpdatePlaylistCommand) error {
var err error
playlist := m.Playlist{
Id: cmd.Id,
OrgId: cmd.OrgId,
@ -68,7 +65,7 @@ func UpdatePlaylist(cmd *m.UpdatePlaylistCommand) error {
Interval: playlist.Interval,
}
_, err = x.Id(cmd.Id).Cols("id", "name", "interval").Update(&playlist)
_, err := x.ID(cmd.Id).Cols("id", "name", "interval").Update(&playlist)
if err != nil {
return err
@ -104,7 +101,7 @@ func GetPlaylist(query *m.GetPlaylistByIdQuery) error {
}
playlist := m.Playlist{}
_, err := x.Id(query.Id).Get(&playlist)
_, err := x.ID(query.Id).Get(&playlist)
query.Result = &playlist

View File

@ -18,7 +18,7 @@ var activeUserTimeLimit time.Duration = time.Hour * 24 * 30
func GetDataSourceStats(query *m.GetDataSourceStatsQuery) error {
var rawSql = `SELECT COUNT(*) as count, type FROM data_source GROUP BY type`
query.Result = make([]*m.DataSourceStats, 0)
err := x.Sql(rawSql).Find(&query.Result)
err := x.SQL(rawSql).Find(&query.Result)
if err != nil {
return err
}
@ -30,36 +30,39 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error {
var rawSql = `SELECT
(
SELECT COUNT(*)
FROM ` + dialect.Quote("user") + `
) AS users,
FROM ` + dialect.Quote("user") + `
) AS users,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("org") + `
) AS orgs,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("dashboard") + `
) AS dashboards,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("data_source") + `
) AS datasources,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("playlist") + `
) AS playlists,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("alert") + `
) AS alerts,
FROM ` + dialect.Quote("org") + `
) AS orgs,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("dashboard") + `
) AS dashboards,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("data_source") + `
) AS datasources,
(
SELECT COUNT(*) FROM ` + dialect.Quote("star") + `
) AS stars,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("playlist") + `
) AS playlists,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("alert") + `
) AS alerts,
(
SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` where last_seen_at > ?
) as active_users
) as active_users
`
activeUserDeadlineDate := time.Now().Add(-activeUserTimeLimit)
var stats m.SystemStats
_, err := x.Sql(rawSql, activeUserDeadlineDate).Get(&stats)
_, err := x.SQL(rawSql, activeUserDeadlineDate).Get(&stats)
if err != nil {
return err
}
@ -70,51 +73,51 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error {
func GetAdminStats(query *m.GetAdminStatsQuery) error {
var rawSql = `SELECT
(
SELECT COUNT(*)
FROM ` + dialect.Quote("user") + `
) AS users,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("org") + `
) AS orgs,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("dashboard") + `
) AS dashboards,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("dashboard_snapshot") + `
) AS snapshots,
(
SELECT COUNT( DISTINCT ( ` + dialect.Quote("term") + ` ))
FROM ` + dialect.Quote("dashboard_tag") + `
) AS tags,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("data_source") + `
) AS datasources,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("playlist") + `
) AS playlists,
(
SELECT COUNT(*) FROM ` + dialect.Quote("star") + `
) AS stars,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("alert") + `
) AS alerts,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("user") + `
) AS users,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("org") + `
) AS orgs,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("dashboard") + `
) AS dashboards,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("dashboard_snapshot") + `
) AS snapshots,
(
SELECT COUNT( DISTINCT ( ` + dialect.Quote("term") + ` ))
FROM ` + dialect.Quote("dashboard_tag") + `
) AS tags,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("data_source") + `
) AS datasources,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("playlist") + `
) AS playlists,
(
SELECT COUNT(*) FROM ` + dialect.Quote("star") + `
) AS stars,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("alert") + `
) AS alerts,
(
SELECT COUNT(*)
from ` + dialect.Quote("user") + ` where last_seen_at > ?
from ` + dialect.Quote("user") + ` where last_seen_at > ?
) as active_users
`
`
activeUserDeadlineDate := time.Now().Add(-activeUserTimeLimit)
var stats m.AdminStats
_, err := x.Sql(rawSql, activeUserDeadlineDate).Get(&stats)
_, err := x.SQL(rawSql, activeUserDeadlineDate).Get(&stats)
if err != nil {
return err
}

View File

@ -1,18 +1,21 @@
package social
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/mail"
"regexp"
"github.com/grafana/grafana/pkg/models"
"golang.org/x/oauth2"
)
type GenericOAuth struct {
*oauth2.Config
type SocialGenericOAuth struct {
*SocialBase
allowedDomains []string
allowedOrganizations []string
apiUrl string
@ -20,19 +23,19 @@ type GenericOAuth struct {
teamIds []int
}
func (s *GenericOAuth) Type() int {
func (s *SocialGenericOAuth) Type() int {
return int(models.GENERIC)
}
func (s *GenericOAuth) IsEmailAllowed(email string) bool {
func (s *SocialGenericOAuth) IsEmailAllowed(email string) bool {
return isEmailAllowed(email, s.allowedDomains)
}
func (s *GenericOAuth) IsSignupAllowed() bool {
func (s *SocialGenericOAuth) IsSignupAllowed() bool {
return s.allowSignup
}
func (s *GenericOAuth) IsTeamMember(client *http.Client) bool {
func (s *SocialGenericOAuth) IsTeamMember(client *http.Client) bool {
if len(s.teamIds) == 0 {
return true
}
@ -53,7 +56,7 @@ func (s *GenericOAuth) IsTeamMember(client *http.Client) bool {
return false
}
func (s *GenericOAuth) IsOrganizationMember(client *http.Client) bool {
func (s *SocialGenericOAuth) IsOrganizationMember(client *http.Client) bool {
if len(s.allowedOrganizations) == 0 {
return true
}
@ -74,7 +77,7 @@ func (s *GenericOAuth) IsOrganizationMember(client *http.Client) bool {
return false
}
func (s *GenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) {
func (s *SocialGenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) {
type Record struct {
Email string `json:"email"`
Primary bool `json:"primary"`
@ -115,7 +118,7 @@ func (s *GenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) {
return email, nil
}
func (s *GenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, error) {
func (s *SocialGenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, error) {
type Record struct {
Id int `json:"id"`
}
@ -140,7 +143,7 @@ func (s *GenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, error)
return ids, nil
}
func (s *GenericOAuth) FetchOrganizations(client *http.Client) ([]string, error) {
func (s *SocialGenericOAuth) FetchOrganizations(client *http.Client) ([]string, error) {
type Record struct {
Login string `json:"login"`
}
@ -165,53 +168,50 @@ func (s *GenericOAuth) FetchOrganizations(client *http.Client) ([]string, error)
return logins, nil
}
func (s *GenericOAuth) UserInfo(client *http.Client) (*BasicUserInfo, error) {
var data struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Login string `json:"login"`
Username string `json:"username"`
Email string `json:"email"`
Attributes map[string][]string `json:"attributes"`
}
type UserInfoJson struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Login string `json:"login"`
Username string `json:"username"`
Email string `json:"email"`
Upn string `json:"upn"`
Attributes map[string][]string `json:"attributes"`
}
response, err := HttpGet(client, s.apiUrl)
if err != nil {
return nil, fmt.Errorf("Error getting user info: %s", err)
}
func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
var data UserInfoJson
err = json.Unmarshal(response.Body, &data)
if err != nil {
return nil, fmt.Errorf("Error getting user info: %s", err)
}
userInfo := &BasicUserInfo{
Name: data.Name,
Login: data.Login,
Email: data.Email,
}
if userInfo.Email == "" && data.Attributes["email:primary"] != nil {
userInfo.Email = data.Attributes["email:primary"][0]
}
if userInfo.Email == "" {
userInfo.Email, err = s.FetchPrivateEmail(client)
if s.extractToken(&data, token) != true {
response, err := HttpGet(client, s.apiUrl)
if err != nil {
return nil, err
return nil, fmt.Errorf("Error getting user info: %s", err)
}
err = json.Unmarshal(response.Body, &data)
if err != nil {
return nil, fmt.Errorf("Error decoding user info JSON: %s", err)
}
}
if userInfo.Name == "" && data.DisplayName != "" {
userInfo.Name = data.DisplayName
name, err := s.extractName(data)
if err != nil {
return nil, err
}
if userInfo.Login == "" && data.Username != "" {
userInfo.Login = data.Username
email, err := s.extractEmail(data, client)
if err != nil {
return nil, err
}
if userInfo.Login == "" {
userInfo.Login = data.Email
login, err := s.extractLogin(data, email)
if err != nil {
return nil, err
}
userInfo := &BasicUserInfo{
Name: name,
Login: login,
Email: email,
}
if !s.IsTeamMember(client) {
@ -224,3 +224,76 @@ func (s *GenericOAuth) UserInfo(client *http.Client) (*BasicUserInfo, error) {
return userInfo, nil
}
func (s *SocialGenericOAuth) extractToken(data *UserInfoJson, token *oauth2.Token) bool {
idToken := token.Extra("id_token")
if idToken == nil {
s.log.Debug("No id_token found", "token", token)
return false
}
jwtRegexp := regexp.MustCompile("^([-_a-zA-Z0-9]+)[.]([-_a-zA-Z0-9]+)[.]([-_a-zA-Z0-9]+)$")
matched := jwtRegexp.FindStringSubmatch(idToken.(string))
if matched == nil {
s.log.Debug("id_token is not in JWT format", "id_token", idToken.(string))
return false
}
payload, err := base64.RawURLEncoding.DecodeString(matched[2])
if err != nil {
s.log.Error("Error base64 decoding id_token", "raw_payload", matched[2], "err", err)
return false
}
err = json.Unmarshal(payload, data)
if err != nil {
s.log.Error("Error decoding id_token JSON", "payload", string(payload), "err", err)
return false
}
s.log.Debug("Received id_token", "json", string(payload), "data", data)
return true
}
func (s *SocialGenericOAuth) extractEmail(data UserInfoJson, client *http.Client) (string, error) {
if data.Email != "" {
return data.Email, nil
}
if data.Attributes["email:primary"] != nil {
return data.Attributes["email:primary"][0], nil
}
if data.Upn != "" {
emailAddr, emailErr := mail.ParseAddress(data.Upn)
if emailErr == nil {
return emailAddr.Address, nil
}
}
return s.FetchPrivateEmail(client)
}
func (s *SocialGenericOAuth) extractLogin(data UserInfoJson, email string) (string, error) {
if data.Login != "" {
return data.Login, nil
}
if data.Username != "" {
return data.Username, nil
}
return email, nil
}
func (s *SocialGenericOAuth) extractName(data UserInfoJson) (string, error) {
if data.Name != "" {
return data.Name, nil
}
if data.DisplayName != "" {
return data.DisplayName, nil
}
return "", nil
}

View File

@ -12,7 +12,7 @@ import (
)
type SocialGithub struct {
*oauth2.Config
*SocialBase
allowedDomains []string
allowedOrganizations []string
apiUrl string
@ -192,7 +192,7 @@ func (s *SocialGithub) FetchOrganizations(client *http.Client, organizationsUrl
return logins, nil
}
func (s *SocialGithub) UserInfo(client *http.Client) (*BasicUserInfo, error) {
func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
var data struct {
Id int `json:"id"`

View File

@ -11,7 +11,7 @@ import (
)
type SocialGoogle struct {
*oauth2.Config
*SocialBase
allowedDomains []string
hostedDomain string
apiUrl string
@ -30,7 +30,7 @@ func (s *SocialGoogle) IsSignupAllowed() bool {
return s.allowSignup
}
func (s *SocialGoogle) UserInfo(client *http.Client) (*BasicUserInfo, error) {
func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
var data struct {
Name string `json:"name"`
Email string `json:"email"`

View File

@ -11,7 +11,7 @@ import (
)
type SocialGrafanaCom struct {
*oauth2.Config
*SocialBase
url string
allowedOrganizations []string
allowSignup bool
@ -49,7 +49,7 @@ func (s *SocialGrafanaCom) IsOrganizationMember(organizations []OrgRecord) bool
return false
}
func (s *SocialGrafanaCom) UserInfo(client *http.Client) (*BasicUserInfo, error) {
func (s *SocialGrafanaCom) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
var data struct {
Name string `json:"name"`
Login string `json:"username"`

View File

@ -4,9 +4,11 @@ import (
"net/http"
"strings"
"golang.org/x/net/context"
"context"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@ -21,7 +23,7 @@ type BasicUserInfo struct {
type SocialConnector interface {
Type() int
UserInfo(client *http.Client) (*BasicUserInfo, error)
UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error)
IsEmailAllowed(email string) bool
IsSignupAllowed() bool
@ -30,6 +32,11 @@ type SocialConnector interface {
Client(ctx context.Context, t *oauth2.Token) *http.Client
}
type SocialBase struct {
*oauth2.Config
log log.Logger
}
type Error struct {
s string
}
@ -90,10 +97,15 @@ func NewOAuthService() {
Scopes: info.Scopes,
}
logger := log.New("oauth." + name)
// GitHub.
if name == "github" {
SocialMap["github"] = &SocialGithub{
Config: &config,
SocialBase: &SocialBase{
Config: &config,
log: logger,
},
allowedDomains: info.AllowedDomains,
apiUrl: info.ApiUrl,
allowSignup: info.AllowSignup,
@ -105,7 +117,10 @@ func NewOAuthService() {
// Google.
if name == "google" {
SocialMap["google"] = &SocialGoogle{
Config: &config,
SocialBase: &SocialBase{
Config: &config,
log: logger,
},
allowedDomains: info.AllowedDomains,
hostedDomain: info.HostedDomain,
apiUrl: info.ApiUrl,
@ -115,8 +130,11 @@ func NewOAuthService() {
// Generic - Uses the same scheme as Github.
if name == "generic_oauth" {
SocialMap["generic_oauth"] = &GenericOAuth{
Config: &config,
SocialMap["generic_oauth"] = &SocialGenericOAuth{
SocialBase: &SocialBase{
Config: &config,
log: logger,
},
allowedDomains: info.AllowedDomains,
apiUrl: info.ApiUrl,
allowSignup: info.AllowSignup,
@ -138,7 +156,10 @@ func NewOAuthService() {
}
SocialMap["grafana_com"] = &SocialGrafanaCom{
Config: &config,
SocialBase: &SocialBase{
Config: &config,
log: logger,
},
url: setting.GrafanaComUrl,
allowSignup: info.AllowSignup,
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),

View File

@ -3,6 +3,7 @@ package cloudwatch
import (
"context"
"errors"
"fmt"
"reflect"
"sort"
"strings"
@ -187,18 +188,6 @@ func (e *CloudWatchExecutor) executeMetricFindQuery(ctx context.Context, queryCo
data, err = e.handleGetEbsVolumeIds(ctx, parameters, queryContext)
break
case "ec2_instance_attribute":
region := parameters.Get("region").MustString()
dsInfo := e.getDsInfo(region)
cfg, err := e.getAwsConfig(dsInfo)
if err != nil {
return nil, errors.New("Failed to call ec2:DescribeInstances")
}
sess, err := session.NewSession(cfg)
if err != nil {
return nil, errors.New("Failed to call ec2:DescribeInstances")
}
e.ec2Svc = ec2.New(sess, cfg)
data, err = e.handleGetEc2InstanceAttribute(ctx, parameters, queryContext)
break
}
@ -226,6 +215,21 @@ func transformToTable(data []suggestData, result *tsdb.QueryResult) {
result.Meta.Set("rowCount", len(data))
}
func parseMultiSelectValue(input string) []string {
trimmedInput := strings.TrimSpace(input)
if strings.HasPrefix(trimmedInput, "{") {
values := strings.Split(strings.TrimRight(strings.TrimLeft(trimmedInput, "{"), "}"), ",")
trimValues := make([]string, len(values))
for i, v := range values {
trimValues[i] = strings.TrimSpace(v)
}
return trimValues
} else {
return []string{trimmedInput}
}
}
// Whenever this list is updated, frontend list should also be updated.
// Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html
func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
@ -364,19 +368,44 @@ func (e *CloudWatchExecutor) handleGetDimensionValues(ctx context.Context, param
return result, nil
}
func (e *CloudWatchExecutor) ensureClientSession(region string) error {
if e.ec2Svc == nil {
dsInfo := e.getDsInfo(region)
cfg, err := e.getAwsConfig(dsInfo)
if err != nil {
return fmt.Errorf("Failed to call ec2:getAwsConfig, %v", err)
}
sess, err := session.NewSession(cfg)
if err != nil {
return fmt.Errorf("Failed to call ec2:NewSession, %v", err)
}
e.ec2Svc = ec2.New(sess, cfg)
}
return nil
}
func (e *CloudWatchExecutor) handleGetEbsVolumeIds(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
region := parameters.Get("region").MustString()
instanceId := parameters.Get("instanceId").MustString()
instanceIds := []*string{aws.String(instanceId)}
err := e.ensureClientSession(region)
if err != nil {
return nil, err
}
instanceIds := aws.StringSlice(parseMultiSelectValue(instanceId))
instances, err := e.ec2DescribeInstances(region, nil, instanceIds)
if err != nil {
return nil, err
}
result := make([]suggestData, 0)
for _, mapping := range instances.Reservations[0].Instances[0].BlockDeviceMappings {
result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId})
for _, reservation := range instances.Reservations {
for _, instance := range reservation.Instances {
for _, mapping := range instance.BlockDeviceMappings {
result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId})
}
}
}
return result, nil
@ -403,6 +432,11 @@ func (e *CloudWatchExecutor) handleGetEc2InstanceAttribute(ctx context.Context,
}
}
err := e.ensureClientSession(region)
if err != nil {
return nil, err
}
instances, err := e.ec2DescribeInstances(region, filters, nil)
if err != nil {
return nil, err
@ -478,7 +512,7 @@ func (e *CloudWatchExecutor) cloudwatchListMetrics(region string, namespace stri
return !lastPage
})
if err != nil {
return nil, errors.New("Failed to call cloudwatch:ListMetrics")
return nil, fmt.Errorf("Failed to call cloudwatch:ListMetrics, %v", err)
}
return &resp, nil

View File

@ -8,6 +8,7 @@ import (
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
"github.com/bmizerany/assert"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/tsdb"
. "github.com/smartystreets/goconvey/convey"
@ -114,4 +115,85 @@ func TestCloudWatchMetrics(t *testing.T) {
So(result[0].Text, ShouldEqual, "i-12345678")
})
})
Convey("When calling handleGetEbsVolumeIds", t, func() {
executor := &CloudWatchExecutor{
ec2Svc: mockedEc2{Resp: ec2.DescribeInstancesOutput{
Reservations: []*ec2.Reservation{
{
Instances: []*ec2.Instance{
{
InstanceId: aws.String("i-1"),
BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{
{Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-1-1")}},
{Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-1-2")}},
},
},
{
InstanceId: aws.String("i-2"),
BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{
{Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-2-1")}},
{Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-2-2")}},
},
},
},
},
{
Instances: []*ec2.Instance{
{
InstanceId: aws.String("i-3"),
BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{
{Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-3-1")}},
{Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-3-2")}},
},
},
{
InstanceId: aws.String("i-4"),
BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{
{Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-4-1")}},
{Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-4-2")}},
},
},
},
},
},
}},
}
json := simplejson.New()
json.Set("region", "us-east-1")
json.Set("instanceId", "{i-1, i-2, i-3, i-4}")
result, _ := executor.handleGetEbsVolumeIds(context.Background(), json, &tsdb.TsdbQuery{})
Convey("Should return all 8 VolumeIds", func() {
So(len(result), ShouldEqual, 8)
So(result[0].Text, ShouldEqual, "vol-1-1")
So(result[1].Text, ShouldEqual, "vol-1-2")
So(result[2].Text, ShouldEqual, "vol-2-1")
So(result[3].Text, ShouldEqual, "vol-2-2")
So(result[4].Text, ShouldEqual, "vol-3-1")
So(result[5].Text, ShouldEqual, "vol-3-2")
So(result[6].Text, ShouldEqual, "vol-4-1")
So(result[7].Text, ShouldEqual, "vol-4-2")
})
})
}
func TestParseMultiSelectValue(t *testing.T) {
var values []string
values = parseMultiSelectValue(" i-someInstance ")
assert.Equal(t, []string{"i-someInstance"}, values)
values = parseMultiSelectValue("{i-05}")
assert.Equal(t, []string{"i-05"}, values)
values = parseMultiSelectValue(" {i-01, i-03, i-04} ")
assert.Equal(t, []string{"i-01", "i-03", "i-04"}, values)
values = parseMultiSelectValue("i-{01}")
assert.Equal(t, []string{"i-{01}"}, values)
}

View File

@ -1,98 +0,0 @@
syntax = "proto3";
option go_package = "proto";
package plugins;
message TsdbQuery {
TimeRange timeRange = 1;
DatasourceInfo datasource = 2;
repeated Query queries = 3;
}
message Query {
string refId = 1;
int64 maxDataPoints = 2;
int64 intervalMs = 3;
string modelJson = 4;
}
message TimeRange {
string fromRaw = 1;
string toRaw = 2;
int64 fromEpochMs = 3;
int64 toEpochMs = 4;
}
message Response {
repeated QueryResult results = 1;
}
message QueryResult {
string error = 1;
string refId = 2;
string metaJson = 3;
repeated TimeSeries series = 4;
repeated Table tables = 5;
}
message Table {
repeated TableColumn columns = 1;
repeated TableRow rows = 2;
}
message TableColumn {
string name = 1;
}
message TableRow {
repeated RowValue values = 1;
}
message RowValue {
enum Kind {
// Field type null.
TYPE_NULL = 0;
// Field type double.
TYPE_DOUBLE = 1;
// Field type int64.
TYPE_INT64 = 2;
// Field type bool.
TYPE_BOOL = 3;
// Field type string.
TYPE_STRING = 4;
// Field type bytes.
TYPE_BYTES = 5;
};
Kind kind = 1;
double doubleValue = 2;
int64 int64Value = 3;
bool boolValue = 4;
string stringValue = 5;
bytes bytesValue = 6;
}
message DatasourceInfo {
int64 id = 1;
int64 orgId = 2;
string name = 3;
string type = 4;
string url = 5;
string jsonData = 6;
string secureJsonData = 7;
}
message TimeSeries {
string name = 1;
map<string, string> tags = 2;
repeated Point points = 3;
}
message Point {
int64 timestamp = 1;
double value = 2;
}
service TsdbPlugin {
rpc Query(TsdbQuery) returns (Response);
}

View File

@ -5,8 +5,8 @@ import (
"context"
"database/sql"
"fmt"
"reflect"
"strconv"
"time"
"github.com/go-sql-driver/mysql"
@ -73,24 +73,36 @@ func (e MysqlQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows,
table.Columns[i].Text = name
}
columnTypes, err := rows.ColumnTypes()
if err != nil {
return err
}
rowLimit := 1000000
rowCount := 0
timeIndex := -1
// check if there is a column named time
for i, col := range columnNames {
switch col {
case "time_sec":
timeIndex = i
}
}
for ; rows.Next(); rowCount++ {
if rowCount > rowLimit {
return fmt.Errorf("MySQL query row limit exceeded, limit %d", rowLimit)
}
values, err := e.getTypedRowData(columnTypes, rows)
values, err := e.getTypedRowData(rows)
if err != nil {
return err
}
// for annotations, convert to epoch
if timeIndex != -1 {
switch value := values[timeIndex].(type) {
case time.Time:
values[timeIndex] = float64(value.UnixNano() / 1e9)
}
}
table.Rows = append(table.Rows, values)
}
@ -99,60 +111,20 @@ func (e MysqlQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows,
return nil
}
func (e MysqlQueryEndpoint) getTypedRowData(types []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) {
func (e MysqlQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues, error) {
types, err := rows.ColumnTypes()
if err != nil {
return nil, err
}
values := make([]interface{}, len(types))
for i, stype := range types {
e.log.Debug("type", "type", stype)
switch stype.DatabaseTypeName() {
case mysql.FieldTypeNameTiny:
values[i] = new(int8)
case mysql.FieldTypeNameInt24:
values[i] = new(int32)
case mysql.FieldTypeNameShort:
values[i] = new(int16)
case mysql.FieldTypeNameVarString:
values[i] = new(string)
case mysql.FieldTypeNameVarChar:
values[i] = new(string)
case mysql.FieldTypeNameLong:
values[i] = new(int)
case mysql.FieldTypeNameLongLong:
values[i] = new(int64)
case mysql.FieldTypeNameDouble:
values[i] = new(float64)
case mysql.FieldTypeNameDecimal:
values[i] = new(float32)
case mysql.FieldTypeNameNewDecimal:
values[i] = new(float64)
case mysql.FieldTypeNameFloat:
values[i] = new(float64)
case mysql.FieldTypeNameTimestamp:
values[i] = new(time.Time)
case mysql.FieldTypeNameDateTime:
values[i] = new(time.Time)
case mysql.FieldTypeNameTime:
values[i] = new(string)
case mysql.FieldTypeNameYear:
values[i] = new(int16)
case mysql.FieldTypeNameNULL:
values[i] = nil
case mysql.FieldTypeNameBit:
for i := range values {
scanType := types[i].ScanType()
values[i] = reflect.New(scanType).Interface()
if types[i].DatabaseTypeName() == "BIT" {
values[i] = new([]byte)
case mysql.FieldTypeNameBLOB:
values[i] = new(string)
case mysql.FieldTypeNameTinyBLOB:
values[i] = new(string)
case mysql.FieldTypeNameMediumBLOB:
values[i] = new(string)
case mysql.FieldTypeNameLongBLOB:
values[i] = new(string)
case mysql.FieldTypeNameString:
values[i] = new(string)
case mysql.FieldTypeNameDate:
values[i] = new(string)
default:
return nil, fmt.Errorf("Database type %s not supported", stype.DatabaseTypeName())
}
}
@ -160,14 +132,54 @@ func (e MysqlQueryEndpoint) getTypedRowData(types []*sql.ColumnType, rows *core.
return nil, err
}
for i := 0; i < len(types); i++ {
typeName := reflect.ValueOf(values[i]).Type().String()
switch typeName {
case "*sql.RawBytes":
values[i] = string(*values[i].(*sql.RawBytes))
case "*mysql.NullTime":
sqlTime := (*values[i].(*mysql.NullTime))
if sqlTime.Valid {
values[i] = sqlTime.Time
} else {
values[i] = nil
}
case "*sql.NullInt64":
nullInt64 := (*values[i].(*sql.NullInt64))
if nullInt64.Valid {
values[i] = nullInt64.Int64
} else {
values[i] = nil
}
case "*sql.NullFloat64":
nullFloat64 := (*values[i].(*sql.NullFloat64))
if nullFloat64.Valid {
values[i] = nullFloat64.Float64
} else {
values[i] = nil
}
}
if types[i].DatabaseTypeName() == "DECIMAL" {
f, err := strconv.ParseFloat(values[i].(string), 64)
if err == nil {
values[i] = f
} else {
values[i] = nil
}
}
}
return values, nil
}
func (e MysqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error {
pointsBySeries := make(map[string]*tsdb.TimeSeries)
seriesByQueryOrder := list.New()
columnNames, err := rows.Columns()
columnNames, err := rows.Columns()
if err != nil {
return err
}

View File

@ -30,19 +30,19 @@ func TestMySQL(t *testing.T) {
defer sess.Close()
sql := "CREATE TABLE `mysql_types` ("
sql += "`atinyint` tinyint(1),"
sql += "`avarchar` varchar(3),"
sql += "`atinyint` tinyint(1) NOT NULL,"
sql += "`avarchar` varchar(3) NOT NULL,"
sql += "`achar` char(3),"
sql += "`amediumint` mediumint,"
sql += "`asmallint` smallint,"
sql += "`abigint` bigint,"
sql += "`aint` int(11),"
sql += "`amediumint` mediumint NOT NULL,"
sql += "`asmallint` smallint NOT NULL,"
sql += "`abigint` bigint NOT NULL,"
sql += "`aint` int(11) NOT NULL,"
sql += "`adouble` double(10,2),"
sql += "`anewdecimal` decimal(10,2),"
sql += "`afloat` float(10,2),"
sql += "`afloat` float(10,2) NOT NULL,"
sql += "`atimestamp` timestamp NOT NULL,"
sql += "`adatetime` datetime,"
sql += "`atime` time,"
sql += "`adatetime` datetime NOT NULL,"
sql += "`atime` time NOT NULL,"
// sql += "`ayear` year," // Crashes xorm when running cleandb
sql += "`abit` bit(1),"
sql += "`atinytext` tinytext,"
@ -55,7 +55,12 @@ func TestMySQL(t *testing.T) {
sql += "`alongblob` longblob,"
sql += "`aenum` enum('val1', 'val2'),"
sql += "`aset` set('a', 'b', 'c', 'd'),"
sql += "`adate` date"
sql += "`adate` date,"
sql += "`time_sec` datetime(6),"
sql += "`aintnull` int(11),"
sql += "`afloatnull` float(10,2),"
sql += "`avarcharnull` varchar(3),"
sql += "`adecimalnull` decimal(10,2)"
sql += ") ENGINE=InnoDB DEFAULT CHARSET=latin1;"
_, err := sess.Exec(sql)
So(err, ShouldBeNil)
@ -64,11 +69,11 @@ func TestMySQL(t *testing.T) {
sql += "(`atinyint`, `avarchar`, `achar`, `amediumint`, `asmallint`, `abigint`, `aint`, `adouble`, "
sql += "`anewdecimal`, `afloat`, `adatetime`, `atimestamp`, `atime`, `abit`, `atinytext`, "
sql += "`atinyblob`, `atext`, `ablob`, `amediumtext`, `amediumblob`, `alongtext`, `alongblob`, "
sql += "`aenum`, `aset`, `adate`) "
sql += "`aenum`, `aset`, `adate`, `time_sec`) "
sql += "VALUES(1, 'abc', 'def', 1, 10, 100, 1420070400, 1.11, "
sql += "2.22, 3.33, now(), current_timestamp(), '11:11:11', 1, 'tinytext', "
sql += "'tinyblob', 'text', 'blob', 'mediumtext', 'mediumblob', 'longtext', 'longblob', "
sql += "'val2', 'a,b', curdate());"
sql += "'val2', 'a,b', curdate(), '2018-01-01 00:01:01.123456');"
_, err = sess.Exec(sql)
So(err, ShouldBeNil)
@ -90,32 +95,38 @@ func TestMySQL(t *testing.T) {
So(err, ShouldBeNil)
column := queryResult.Tables[0].Rows[0]
So(*column[0].(*int8), ShouldEqual, 1)
So(*column[1].(*string), ShouldEqual, "abc")
So(*column[2].(*string), ShouldEqual, "def")
So(column[1].(string), ShouldEqual, "abc")
So(column[2].(string), ShouldEqual, "def")
So(*column[3].(*int32), ShouldEqual, 1)
So(*column[4].(*int16), ShouldEqual, 10)
So(*column[5].(*int64), ShouldEqual, 100)
So(*column[6].(*int), ShouldEqual, 1420070400)
So(*column[7].(*float64), ShouldEqual, 1.11)
So(*column[8].(*float64), ShouldEqual, 2.22)
So(*column[9].(*float64), ShouldEqual, 3.33)
So(*column[6].(*int32), ShouldEqual, 1420070400)
So(column[7].(float64), ShouldEqual, 1.11)
So(column[8].(float64), ShouldEqual, 2.22)
So(*column[9].(*float32), ShouldEqual, 3.33)
_, offset := time.Now().Zone()
So((*column[10].(*time.Time)), ShouldHappenWithin, time.Duration(10*time.Second), time.Now().Add(time.Duration(offset)*time.Second))
So(*column[11].(*time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now().Add(time.Duration(offset)*time.Second))
So(*column[12].(*string), ShouldEqual, "11:11:11")
So(column[10].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now().Add(time.Duration(offset)*time.Second))
So(column[11].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now().Add(time.Duration(offset)*time.Second))
So(column[12].(string), ShouldEqual, "11:11:11")
So(*column[13].(*[]byte), ShouldHaveSameTypeAs, []byte{1})
So(*column[14].(*string), ShouldEqual, "tinytext")
So(*column[15].(*string), ShouldEqual, "tinyblob")
So(*column[16].(*string), ShouldEqual, "text")
So(*column[17].(*string), ShouldEqual, "blob")
So(*column[18].(*string), ShouldEqual, "mediumtext")
So(*column[19].(*string), ShouldEqual, "mediumblob")
So(*column[20].(*string), ShouldEqual, "longtext")
So(*column[21].(*string), ShouldEqual, "longblob")
So(*column[22].(*string), ShouldEqual, "val2")
So(*column[23].(*string), ShouldEqual, "a,b")
So(*column[24].(*string), ShouldEqual, time.Now().Format("2006-01-02T00:00:00Z"))
So(column[14].(string), ShouldEqual, "tinytext")
So(column[15].(string), ShouldEqual, "tinyblob")
So(column[16].(string), ShouldEqual, "text")
So(column[17].(string), ShouldEqual, "blob")
So(column[18].(string), ShouldEqual, "mediumtext")
So(column[19].(string), ShouldEqual, "mediumblob")
So(column[20].(string), ShouldEqual, "longtext")
So(column[21].(string), ShouldEqual, "longblob")
So(column[22].(string), ShouldEqual, "val2")
So(column[23].(string), ShouldEqual, "a,b")
So(column[24].(time.Time).Format("2006-01-02T00:00:00Z"), ShouldEqual, time.Now().Format("2006-01-02T00:00:00Z"))
So(column[25].(float64), ShouldEqual, 1514764861)
So(column[26], ShouldEqual, nil)
So(column[27], ShouldEqual, nil)
So(column[28], ShouldEqual, "")
So(column[29], ShouldEqual, nil)
})
})
}

View File

@ -5,6 +5,7 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import LoginBackground from './components/Login/LoginBackground';
import { SearchResult } from './components/search/SearchResult';
import UserPicker from './components/UserPicker/UserPicker';
import { TagFilter } from './components/TagFilter/TagFilter';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
@ -13,4 +14,9 @@ export function registerAngularDirectives() {
react2AngularDirective('loginBackground', LoginBackground, []);
react2AngularDirective('searchResult', SearchResult, []);
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'teamId', 'refreshList']);
react2AngularDirective('tagFilter', TagFilter, [
'tags',
['onSelect', { watchDepth: 'reference' }],
['tagOptions', { watchDepth: 'reference' }],
]);
}

View File

@ -0,0 +1,37 @@
import React from 'react';
import tags from 'app/core/utils/tags';
export interface IProps {
label: string;
removeIcon: boolean;
count: number;
onClick: any;
}
export class TagBadge extends React.Component<IProps, any> {
constructor(props) {
super(props);
this.onClick = this.onClick.bind(this);
}
onClick(event) {
this.props.onClick(event);
}
render() {
const { label, removeIcon, count } = this.props;
const { color, borderColor } = tags.getTagColorsFromName(label);
const tagStyle = {
backgroundColor: color,
borderColor: borderColor,
};
const countLabel = count !== 0 && <span className="tag-count-label">{`(${count})`}</span>;
return (
<span className={`label label-tag`} onClick={this.onClick} style={tagStyle}>
{removeIcon && <i className="fa fa-remove" />}
{label} {countLabel}
</span>
);
}
}

View File

@ -0,0 +1,69 @@
import _ from 'lodash';
import React from 'react';
import { Async } from 'react-select';
import { TagValue } from './TagValue';
import { TagOption } from './TagOption';
export interface IProps {
tags: string[];
tagOptions: () => any;
onSelect: (tag: string) => void;
}
export class TagFilter extends React.Component<IProps, any> {
inlineTags: boolean;
constructor(props) {
super(props);
this.searchTags = this.searchTags.bind(this);
this.onChange = this.onChange.bind(this);
this.onTagRemove = this.onTagRemove.bind(this);
}
searchTags(query) {
return this.props.tagOptions().then(options => {
const tags = _.map(options, tagOption => {
return { value: tagOption.term, label: tagOption.term, count: tagOption.count };
});
return { options: tags };
});
}
onChange(newTags) {
this.props.onSelect(newTags);
}
onTagRemove(tag) {
let newTags = _.without(this.props.tags, tag.label);
newTags = _.map(newTags, tag => {
return { value: tag };
});
this.props.onSelect(newTags);
}
render() {
let selectOptions = {
loadOptions: this.searchTags,
onChange: this.onChange,
value: this.props.tags,
multi: true,
className: 'gf-form-input gf-form-input--form-dropdown',
placeholder: 'Tags',
loadingPlaceholder: 'Loading...',
noResultsText: 'No tags found',
optionComponent: TagOption,
};
selectOptions['valueComponent'] = TagValue;
return (
<div className="gf-form gf-form--has-input-icon gf-form--grow">
<div className="tag-filter">
<Async {...selectOptions} />
</div>
<i className="gf-form-input-icon fa fa-tag" />
</div>
);
}
}

View File

@ -0,0 +1,52 @@
import React from 'react';
import { TagBadge } from './TagBadge';
export interface IProps {
onSelect: any;
onFocus: any;
option: any;
isFocused: any;
className: any;
}
export class TagOption extends React.Component<IProps, any> {
constructor(props) {
super(props);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
}
handleMouseDown(event) {
event.preventDefault();
event.stopPropagation();
this.props.onSelect(this.props.option, event);
}
handleMouseEnter(event) {
this.props.onFocus(this.props.option, event);
}
handleMouseMove(event) {
if (this.props.isFocused) {
return;
}
this.props.onFocus(this.props.option, event);
}
render() {
const { option, className } = this.props;
return (
<button
onMouseDown={this.handleMouseDown}
onMouseEnter={this.handleMouseEnter}
onMouseMove={this.handleMouseMove}
title={option.title}
className={`tag-filter-option btn btn-link ${className || ''}`}
>
<TagBadge label={option.label} removeIcon={false} count={option.count} onClick={this.handleMouseDown} />
</button>
);
}
}

View File

@ -0,0 +1,26 @@
import React from 'react';
import { TagBadge } from './TagBadge';
export interface IProps {
value: any;
className: any;
onClick: any;
onRemove: any;
}
export class TagValue extends React.Component<IProps, any> {
constructor(props) {
super(props);
this.onClick = this.onClick.bind(this);
}
onClick(event) {
this.props.onRemove(this.props.value, event);
}
render() {
const { value } = this.props;
return <TagBadge label={value.label} removeIcon={true} count={0} onClick={this.onClick} />;
}
}

View File

@ -1,9 +1,11 @@
import _ from 'lodash';
import $ from 'jquery';
import coreModule from '../../core_module';
function typeaheadMatcher(item) {
var str = this.query;
if (str === '') {
return true;
}
if (str[0] === '/') {
str = str.substring(1);
}
@ -30,6 +32,8 @@ export class FormDropdownCtrl {
getOptions: any;
optionCache: any;
lookupText: boolean;
placeholder: any;
startOpen: any;
/** @ngInject **/
constructor(private $scope, $element, private $sce, private templateSrv, private $q) {
@ -47,6 +51,10 @@ export class FormDropdownCtrl {
this.cssClasses = 'gf-form-input gf-form-input--dropdown ' + this.cssClass;
}
if (this.placeholder) {
this.inputElement.attr('placeholder', this.placeholder);
}
this.inputElement.attr('data-provide', 'typeahead');
this.inputElement.typeahead({
source: this.typeaheadSource.bind(this),
@ -61,8 +69,7 @@ export class FormDropdownCtrl {
var typeahead = this.inputElement.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;
this.source(this.query, this.process.bind(this));
};
this.linkElement.keydown(evt => {
@ -81,6 +88,10 @@ export class FormDropdownCtrl {
});
this.inputElement.blur(this.inputBlur.bind(this));
if (this.startOpen) {
setTimeout(this.open.bind(this), 0);
}
}
getOptionsInternal(query) {
@ -121,9 +132,9 @@ export class FormDropdownCtrl {
});
// add custom values
if (this.allowCustom) {
if (this.allowCustom && this.text !== '') {
if (_.indexOf(optionTexts, this.text) === -1) {
options.unshift(this.text);
optionTexts.unshift(this.text);
}
}
@ -228,10 +239,10 @@ const template = `
style="display:none">
</input>
<a ng-class="ctrl.cssClasses"
tabindex="1"
ng-click="ctrl.open()"
give-focus="ctrl.focus"
ng-bind-html="ctrl.display">
tabindex="1"
ng-click="ctrl.open()"
give-focus="ctrl.focus"
ng-bind-html="ctrl.display || '&nbsp;'">
</a>
`;
@ -250,6 +261,8 @@ export function formDropdownDirective() {
allowCustom: '@',
labelMode: '@',
lookupText: '@',
placeholder: '@',
startOpen: '@',
},
};
}

View File

@ -12,8 +12,7 @@
ng-model-options="{ debounce: 500 }"
spellcheck='false'
ng-change="ctrl.search()"
ng-blur="ctrl.searchInputBlur()"
/>
/>
<div class="search-field-spacer"></div>
</div>
@ -31,37 +30,28 @@
</div>
<div class="search-dropdown__col_2">
<!-- <div class="search&#45;filter&#45;box"> -->
<!-- <div class="search&#45;filter&#45;box__header"> -->
<!-- <i class="fa fa&#45;filter"></i> -->
<!-- Filter by: -->
<!-- <a class="pointer pull&#45;right small"> -->
<!-- <i class="fa fa&#45;remove"></i> Clear -->
<!-- </a> -->
<!-- </div> -->
<!-- -->
<!-- <div class="gf&#45;form"> -->
<!-- <folder&#45;picker initial&#45;title="ctrl.initialFolderFilterTitle" -->
<!-- on&#45;change="ctrl.onFolderChange($folder)" -->
<!-- label&#45;class="width&#45;4"> -->
<!-- </folder&#45;picker> -->
<!-- </div> -->
<!-- -->
<!-- <div class="gf&#45;form"> -->
<!-- <label class="gf&#45;form&#45;label width&#45;4">Tags</label> -->
<!-- <bootstrap&#45;tagsinput ng&#45;model="ctrl.dashboard.tags" tagclass="label label&#45;tag" placeholder="add tags"> -->
<!-- </bootstrap&#45;tagsinput> -->
<!-- </div> -->
<!-- </div> -->
<div class="search-filter-box" ng-click="ctrl.onFilterboxClick()">
<div class="search-filter-box__header">
<i class="fa fa-filter"></i>
Filter by:
<a class="pointer pull-right small" ng-click="ctrl.clearSearchFilter()">
<i class="fa fa-remove"></i> Clear
</a>
</div>
<tag-filter tags="ctrl.query.tag" tagOptions="ctrl.getTags" onSelect="ctrl.onTagSelect">
</tag-filter>
</div>
<div class="search-filter-box">
<a href="dashboard/new" class="search-filter-box-link">
<i class="gicon gicon-dashboard-new"></i>
New dashboard
<i class="gicon gicon-dashboard-new"></i> New dashboard
</a>
<a href="dashboards/folder/new" class="search-filter-box-link">
<i class="gicon gicon-folder-new"></i>
New folder
<i class="gicon gicon-folder-new"></i> New folder
</a>
<a href="dashboard/import" class="search-filter-box-link">
<i class="gicon gicon-dashboard-import"></i> Import dashboard
</a>
<a class="search-filter-box-link" target="_blank" href="https://grafana.com/dashboards?utm_source=grafana_search">
<img src="public/img/icn-dashboard-tiny.svg" width="20" /> Find dashboards on Grafana.com

View File

@ -22,6 +22,8 @@ export class SearchCtrl {
appEvents.on('hide-dash-search', this.closeSearch.bind(this), $scope);
this.initialFolderFilterTitle = 'All';
this.getTags = this.getTags.bind(this);
this.onTagSelect = this.onTagSelect.bind(this);
}
closeSearch() {
@ -88,6 +90,19 @@ export class SearchCtrl {
}
}
onFilterboxClick() {
this.giveSearchFocus = 0;
this.preventClose();
}
preventClose() {
this.ignoreClose = true;
this.$timeout(() => {
this.ignoreClose = false;
}, 100);
}
moveSelection(direction) {
if (this.results.length === 0) {
return;
@ -160,7 +175,6 @@ export class SearchCtrl {
if (_.indexOf(this.query.tag, tag) === -1) {
this.query.tag.push(tag);
this.search();
this.giveSearchFocus = this.giveSearchFocus + 1;
}
}
@ -173,10 +187,17 @@ export class SearchCtrl {
}
getTags() {
return this.searchSrv.getDashboardTags().then(results => {
this.results = results;
this.giveSearchFocus = this.giveSearchFocus + 1;
});
return this.searchSrv.getDashboardTags();
}
onTagSelect(newTags) {
this.query.tag = _.map(newTags, tag => tag.value);
this.search();
}
clearSearchFilter() {
this.query.tag = [];
this.search();
}
showStarred() {

View File

@ -12,7 +12,7 @@ function (_, $, coreModule) {
' class="gf-form-input input-medium tight-form-input"' +
' spellcheck="false" style="display:none"></input>';
var buttonTemplate = '<a class="gf-form-label tight-form-func dropdown-toggle"' +
var buttonTemplate = '<a class="gf-form-label tight-form-func dropdown-toggle"' +
' tabindex="1" gf-dropdown="menuItems" data-toggle="dropdown"' +
' data-placement="top"><i class="fa fa-plus"></i></a>';

View File

@ -1,82 +1,11 @@
import angular from 'angular';
import $ from 'jquery';
import coreModule from '../core_module';
import tags from 'app/core/utils/tags';
import 'vendor/tagsinput/bootstrap-tagsinput.js';
function djb2(str) {
var hash = 5381;
for (var i = 0; i < str.length; i++) {
hash = (hash << 5) + hash + str.charCodeAt(i); /* hash * 33 + c */
}
return hash;
}
function setColor(name, element) {
var hash = djb2(name.toLowerCase());
var colors = [
'#E24D42',
'#1F78C1',
'#BA43A9',
'#705DA0',
'#466803',
'#508642',
'#447EBC',
'#C15C17',
'#890F02',
'#757575',
'#0A437C',
'#6D1F62',
'#584477',
'#629E51',
'#2F4F4F',
'#BF1B00',
'#806EB7',
'#8a2eb8',
'#699e00',
'#000000',
'#3F6833',
'#2F575E',
'#99440A',
'#E0752D',
'#0E4AB4',
'#58140C',
'#052B51',
'#511749',
'#3F2B5B',
];
var borderColors = [
'#FF7368',
'#459EE7',
'#E069CF',
'#9683C6',
'#6C8E29',
'#76AC68',
'#6AA4E2',
'#E7823D',
'#AF3528',
'#9B9B9B',
'#3069A2',
'#934588',
'#7E6A9D',
'#88C477',
'#557575',
'#E54126',
'#A694DD',
'#B054DE',
'#8FC426',
'#262626',
'#658E59',
'#557D84',
'#BF6A30',
'#FF9B53',
'#3470DA',
'#7E3A32',
'#2B5177',
'#773D6F',
'#655181',
];
var color = colors[Math.abs(hash % colors.length)];
var borderColor = borderColors[Math.abs(hash % borderColors.length)];
const { color, borderColor } = tags.getTagColorsFromName(name);
element.css('background-color', color);
element.css('border-color', borderColor);
}

View File

@ -106,10 +106,6 @@ function (angular, _, coreModule) {
return new MetricSegment({fake: true, html: '<i class="fa fa-plus "></i>', type: 'plus-button', cssClass: 'query-part' });
};
this.newSelectTagValue = function() {
return new MetricSegment({value: 'select tag value', fake: true});
};
});
});

View File

@ -493,6 +493,7 @@ kbn.valueFormats.kvolt = kbn.formatBuilders.decimalSIPrefix('V', 1);
kbn.valueFormats.mvolt = kbn.formatBuilders.decimalSIPrefix('V', -1);
kbn.valueFormats.dBm = kbn.formatBuilders.decimalSIPrefix('dBm');
kbn.valueFormats.ohm = kbn.formatBuilders.decimalSIPrefix('Ω');
kbn.valueFormats.lumens = kbn.formatBuilders.decimalSIPrefix('Lm');
// Temperature
kbn.valueFormats.celsius = kbn.formatBuilders.fixedUnit('°C');
@ -958,6 +959,7 @@ kbn.getUnitFormats = function() {
{ text: 'Millivolt (mV)', value: 'mvolt' },
{ text: 'Decibel-milliwatt (dBm)', value: 'dBm' },
{ text: 'Ohm (Ω)', value: 'ohm' },
{ text: 'Lumens (Lm)', value: 'lumens' },
],
},
{

View File

@ -0,0 +1,86 @@
const TAG_COLORS = [
'#E24D42',
'#1F78C1',
'#BA43A9',
'#705DA0',
'#466803',
'#508642',
'#447EBC',
'#C15C17',
'#890F02',
'#757575',
'#0A437C',
'#6D1F62',
'#584477',
'#629E51',
'#2F4F4F',
'#BF1B00',
'#806EB7',
'#8a2eb8',
'#699e00',
'#000000',
'#3F6833',
'#2F575E',
'#99440A',
'#E0752D',
'#0E4AB4',
'#58140C',
'#052B51',
'#511749',
'#3F2B5B',
];
const TAG_BORDER_COLORS = [
'#FF7368',
'#459EE7',
'#E069CF',
'#9683C6',
'#6C8E29',
'#76AC68',
'#6AA4E2',
'#E7823D',
'#AF3528',
'#9B9B9B',
'#3069A2',
'#934588',
'#7E6A9D',
'#88C477',
'#557575',
'#E54126',
'#A694DD',
'#B054DE',
'#8FC426',
'#262626',
'#658E59',
'#557D84',
'#BF6A30',
'#FF9B53',
'#3470DA',
'#7E3A32',
'#2B5177',
'#773D6F',
'#655181',
];
/**
* Returns tag badge background and border colors based on hashed tag name.
* @param name tag name
*/
export function getTagColorsFromName(name: string): { color: string; borderColor: string } {
let hash = djb2(name.toLowerCase());
let color = TAG_COLORS[Math.abs(hash % TAG_COLORS.length)];
let borderColor = TAG_BORDER_COLORS[Math.abs(hash % TAG_BORDER_COLORS.length)];
return { color, borderColor };
}
function djb2(str) {
let hash = 5381;
for (var i = 0; i < str.length; i++) {
hash = (hash << 5) + hash + str.charCodeAt(i); /* hash * 33 + c */
}
return hash;
}
export default {
getTagColorsFromName,
};

View File

@ -0,0 +1,52 @@
/**
* @preserve jquery-param (c) 2015 KNOWLEDGECODE | MIT
*/
export function toUrlParams(a) {
let s = [];
let rbracket = /\[\]$/;
let isArray = function(obj) {
return Object.prototype.toString.call(obj) === '[object Array]';
};
let add = function(k, v) {
v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
};
let buildParams = function(prefix, obj) {
var i, len, key;
if (prefix) {
if (isArray(obj)) {
for (i = 0, len = obj.length; i < len; i++) {
if (rbracket.test(prefix)) {
add(prefix, obj[i]);
} else {
buildParams(prefix, obj[i]);
}
}
} else if (obj && String(obj) === '[object Object]') {
for (key in obj) {
buildParams(prefix + '[' + key + ']', obj[key]);
}
} else {
add(prefix, obj);
}
} else if (isArray(obj)) {
for (i = 0, len = obj.length; i < len; i++) {
add(obj[i].name, obj[i].value);
}
} else {
for (key in obj) {
buildParams(key, obj[key]);
}
}
return s;
};
return buildParams('', a)
.join('&')
.replace(/%20/g, '+');
}

View File

@ -86,7 +86,7 @@ export class DashboardSrv {
save(clone, options) {
options = options || {};
options.folderId = this.dash.meta.folderId || clone.folderId;
options.folderId = options.folderId || this.dash.meta.folderId || clone.folderId;
return this.backendSrv
.saveDashboard(clone, options)

View File

@ -35,8 +35,7 @@ export class DashNavCtrl {
let search = this.$location.search();
if (search.editview) {
delete search.editview;
}
if (search.fullscreen) {
} else if (search.fullscreen) {
delete search.fullscreen;
delete search.edit;
}

View File

@ -9,29 +9,21 @@
</div>
<input type="text"
class="gf-form-input max-width-10"
ng-show="ctrl.createNewFolder"
ng-if="ctrl.createNewFolder"
give-focus="ctrl.createNewFolder"
ng-model="ctrl.newFolderName"
ng-model-options="{ debounce: 400 }"
ng-class="{'validation-error': !ctrl.isNewFolderNameValid()}"
ng-change="ctrl.newFolderNameChanged()" />
</div>
<div class="gf-form" ng-show="ctrl.createNewFolder">
<label class="gf-form-label text-success"
ng-show="ctrl.newFolderNameTouched && !ctrl.hasValidationError">
<i class="fa fa-check"></i>
</label>
</div>
<div class="gf-form" ng-show="ctrl.createNewFolder">
<button class="gf-form-label"
<div class="gf-form" ng-if="ctrl.createNewFolder">
<button class="btn btn-inverse"
ng-click="ctrl.createFolder($event)"
ng-disabled="!ctrl.newFolderNameTouched || ctrl.hasValidationError">
<i class="fa fa-fw fa-save"></i>&nbsp;Create
</button>
</div>
<div class="gf-form" ng-show="ctrl.createNewFolder">
<button class="gf-form-label"
ng-click="ctrl.cancelCreateFolder($event)">
<div class="gf-form" ng-if="ctrl.createNewFolder">
<button class="btn btn-inverse" ng-click="ctrl.cancelCreateFolder($event)">
Cancel
</button>
</div>

View File

@ -13,7 +13,7 @@ const template = `
</a>
</div>
<form name="ctrl.saveForm" ng-submit="ctrl.save()" class="modal-content" novalidate>
<form name="ctrl.saveForm" class="modal-content" novalidate>
<div class="p-t-2">
<div class="gf-form">
<label class="gf-form-label width-7">New name</label>
@ -22,8 +22,6 @@ const template = `
<div class="gf-form">
<folder-picker initial-folder-id="ctrl.folderId"
on-change="ctrl.onFolderChange($folder)"
enter-folder-creation="ctrl.onEnterFolderCreation()"
exit-folder-creation="ctrl.onExitFolderCreation()"
enable-create-new="true"
label-class="width-7">
</folder-picker>
@ -31,7 +29,7 @@ const template = `
</div>
<div class="gf-form-button-row text-center">
<button type="submit" class="btn btn-success" ng-disabled="ctrl.saveForm.$invalid || !ctrl.isValidFolderSelection">Save</button>
<button type="submit" class="btn btn-success" ng-click="ctrl.save()">Save</button>
<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
</div>
</form>
@ -41,7 +39,6 @@ const template = `
export class SaveDashboardAsModalCtrl {
clone: any;
folderId: any;
isValidFolderSelection = true;
dismiss: () => void;
/** @ngInject */
@ -69,25 +66,17 @@ export class SaveDashboardAsModalCtrl {
}
save() {
return this.dashboardSrv.save(this.clone).then(this.dismiss);
}
onEnterFolderCreation() {
this.isValidFolderSelection = false;
}
onExitFolderCreation() {
this.isValidFolderSelection = true;
return this.dashboardSrv.save(this.clone, { folderId: this.folderId }).then(this.dismiss);
}
keyDown(evt) {
if (this.isValidFolderSelection && evt.keyCode === 13) {
if (evt.keyCode === 13) {
this.save();
}
}
onFolderChange(folder) {
this.clone.folderId = folder.id;
this.folderId = folder.id;
}
}

View File

@ -10,11 +10,13 @@
</a>
<div class="dashboard-settings__aside-actions">
<button class="btn btn-success" ng-click="ctrl.saveDashboard()" ng-show="ctrl.canSave">
<i class="fa fa-save"></i> Save
</button>
<button class="btn btn-inverse" ng-click="ctrl.openSaveAsModal()" ng-show="ctrl.canSaveAs">
<i class="fa fa-copy"></i>
Save As...
</button>
<button class="btn btn-danger" ng-click="ctrl.deleteDashboard()" ng-show="ctrl.canDelete">
<i class="fa fa-trash"></i>
Delete

View File

@ -11,6 +11,7 @@ export class SettingsCtrl {
json: string;
alertCount: number;
canSaveAs: boolean;
canSave: boolean;
canDelete: boolean;
sections: any[];
@ -26,6 +27,7 @@ export class SettingsCtrl {
});
this.canSaveAs = contextSrv.isEditor;
this.canSave = this.dashboard.meta.canSave;
this.canDelete = this.dashboard.meta.canSave;
this.buildSectionList();
@ -125,6 +127,10 @@ export class SettingsCtrl {
this.dashboardSrv.showSaveAsModal();
}
saveDashboard() {
this.dashboardSrv.saveDashboard();
}
hideSettings() {
var urlParams = this.$location.search();
delete urlParams.editview;

View File

@ -1,6 +1,7 @@
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import { variableTypes } from './variable';
import appEvents from 'app/core/app_events';
export class VariableEditorCtrl {
/** @ngInject **/
@ -56,16 +57,13 @@ export class VariableEditorCtrl {
}
if (!$scope.current.name.match(/^\w+$/)) {
$scope.appEvent('alert-warning', [
'Validation',
'Only word and digit characters are allowed in variable names',
]);
appEvents.emit('alert-warning', ['Validation', 'Only word and digit characters are allowed in variable names']);
return false;
}
var sameName = _.find($scope.variables, { name: $scope.current.name });
if (sameName && sameName !== $scope.current) {
$scope.appEvent('alert-warning', ['Validation', 'Variable with the same name already exists']);
appEvents.emit('alert-warning', ['Validation', 'Variable with the same name already exists']);
return false;
}
@ -73,7 +71,7 @@ export class VariableEditorCtrl {
$scope.current.type === 'query' &&
$scope.current.query.match(new RegExp('\\$' + $scope.current.name + '(/| |$)'))
) {
$scope.appEvent('alert-warning', [
appEvents.emit('alert-warning', [
'Validation',
'Query cannot contain a reference to itself. Variable: $' + $scope.current.name,
]);
@ -96,11 +94,11 @@ export class VariableEditorCtrl {
};
$scope.runQuery = function() {
return variableSrv.updateOptions($scope.current).then(null, function(err) {
return variableSrv.updateOptions($scope.current).catch(err => {
if (err.data && err.data.message) {
err.message = err.data.message;
}
$scope.appEvent('alert-error', ['Templating', 'Template variables could not be initialized: ' + err.message]);
appEvents.emit('alert-error', ['Templating', 'Template variables could not be initialized: ' + err.message]);
});
};

View File

@ -16,12 +16,12 @@
Add variable
</a>
<div class="grafana-info-box">
<h5>What does variables do?</h5>
<p>Variables enables more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor names
<h5>What do variables do?</h5>
<p>Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor names
in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of
the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard.
Checkout the
Check out the
<a class="external-link" href="http://docs.grafana.org/reference/templating/" target="_blank">
Templating documentation
</a> for more information.
@ -93,7 +93,7 @@
</div>
<div class="gf-form" ng-show="ctrl.form.name.$error.pattern">
<span class="gf-form-label gf-form-label--error">Template names cannot begin with '__' that's reserved for Grafanas global variables</span>
<span class="gf-form-label gf-form-label--error">Template names cannot begin with '__', that's reserved for Grafana's global variables</span>
</div>
<div class="gf-form-inline">

View File

@ -0,0 +1,40 @@
import { VariableEditorCtrl } from '../editor_ctrl';
let mockEmit;
jest.mock('app/core/app_events', () => {
mockEmit = jest.fn();
return {
emit: mockEmit,
};
});
describe('VariableEditorCtrl', () => {
let scope = {
runQuery: () => {
return Promise.resolve({});
},
};
describe('When running a variable query and the data source returns an error', () => {
beforeEach(() => {
const variableSrv = {
updateOptions: () => {
return Promise.reject({
data: { message: 'error' },
});
},
};
return new VariableEditorCtrl(scope, {}, variableSrv, {});
});
it('should emit an error', () => {
return scope.runQuery().then(res => {
expect(mockEmit).toBeCalled();
expect(mockEmit.mock.calls[0][0]).toBe('alert-error');
expect(mockEmit.mock.calls[0][1][0]).toBe('Templating');
expect(mockEmit.mock.calls[0][1][1]).toBe('Template variables could not be initialized: error');
});
});
});
});

View File

@ -1,46 +1,37 @@
define([
'angular',
'lodash',
'jquery',
'./gfunc',
],
function (angular, _, $, gfunc) {
define(['angular', 'lodash', 'jquery', 'rst2html', 'tether-drop'], function(angular, _, $, rst2html, Drop) {
'use strict';
gfunc = gfunc.default;
angular.module('grafana.directives').directive('graphiteAddFunc', function($compile) {
var inputTemplate =
'<input type="text"' + ' class="gf-form-input"' + ' spellcheck="false" style="display:none"></input>';
angular
.module('grafana.directives')
.directive('graphiteAddFunc', function($compile) {
var inputTemplate = '<input type="text"'+
' class="gf-form-input"' +
' spellcheck="false" style="display:none"></input>';
var buttonTemplate =
'<a class="gf-form-label query-part dropdown-toggle"' +
' tabindex="1" gf-dropdown="functionMenu" data-toggle="dropdown">' +
'<i class="fa fa-plus"></i></a>';
var buttonTemplate = '<a class="gf-form-label query-part dropdown-toggle"' +
' tabindex="1" gf-dropdown="functionMenu" data-toggle="dropdown">' +
'<i class="fa fa-plus"></i></a>';
return {
link: function($scope, elem) {
var ctrl = $scope.ctrl;
return {
link: function($scope, elem) {
var ctrl = $scope.ctrl;
var graphiteVersion = ctrl.datasource.graphiteVersion;
var categories = gfunc.getCategories(graphiteVersion);
var allFunctions = getAllFunctionNames(categories);
var $input = $(inputTemplate);
var $button = $(buttonTemplate);
$scope.functionMenu = createFunctionDropDownMenu(categories);
$input.appendTo(elem);
$button.appendTo(elem);
var $input = $(inputTemplate);
var $button = $(buttonTemplate);
$input.appendTo(elem);
$button.appendTo(elem);
ctrl.datasource.getFuncDefs().then(function(funcDefs) {
var allFunctions = _.map(funcDefs, 'name').sort();
$scope.functionMenu = createFunctionDropDownMenu(funcDefs);
$input.attr('data-provide', 'typeahead');
$input.typeahead({
source: allFunctions,
minLength: 1,
items: 10,
updater: function (value) {
var funcDef = gfunc.getFuncDef(value);
updater: function(value) {
var funcDef = ctrl.datasource.getFuncDef(value);
if (!funcDef) {
// try find close match
value = value.toLowerCase();
@ -48,7 +39,9 @@ function (angular, _, $, gfunc) {
return funcName.toLowerCase().indexOf(value) === 0;
});
if (!funcDef) { return; }
if (!funcDef) {
return;
}
}
$scope.$apply(function() {
@ -57,7 +50,7 @@ function (angular, _, $, gfunc) {
$input.trigger('blur');
return '';
}
},
});
$button.click(function() {
@ -82,32 +75,81 @@ function (angular, _, $, gfunc) {
});
$compile(elem.contents())($scope);
}
};
});
});
function getAllFunctionNames(categories) {
return _.reduce(categories, function(list, category) {
_.each(category, function(func) {
list.push(func.name);
});
return list;
}, []);
}
function createFunctionDropDownMenu(categories) {
return _.map(categories, function(list, category) {
var submenu = _.map(list, function(value) {
return {
text: value.name,
click: "ctrl.addFunction('" + value.name + "')",
var drop;
var cleanUpDrop = function() {
if (drop) {
drop.destroy();
drop = null;
}
};
});
return {
text: category,
submenu: submenu
};
$(elem)
.on('mouseenter', 'ul.dropdown-menu li', function() {
cleanUpDrop();
var funcDef;
try {
funcDef = ctrl.datasource.getFuncDef($('a', this).text());
} catch (e) {
// ignore
}
if (funcDef && funcDef.description) {
var shortDesc = funcDef.description;
if (shortDesc.length > 500) {
shortDesc = shortDesc.substring(0, 497) + '...';
}
var contentElement = document.createElement('div');
contentElement.innerHTML = '<h4>' + funcDef.name + '</h4>' + rst2html(shortDesc);
drop = new Drop({
target: this,
content: contentElement,
classes: 'drop-popover',
openOn: 'always',
tetherOptions: {
attachment: 'bottom left',
targetAttachment: 'bottom right',
},
});
}
})
.on('mouseout', 'ul.dropdown-menu li', function() {
cleanUpDrop();
});
$scope.$on('$destroy', cleanUpDrop);
},
};
});
function createFunctionDropDownMenu(funcDefs) {
var categories = {};
_.forEach(funcDefs, function(funcDef) {
if (!funcDef.category) {
return;
}
if (!categories[funcDef.category]) {
categories[funcDef.category] = [];
}
categories[funcDef.category].push({
text: funcDef.name,
click: "ctrl.addFunction('" + funcDef.name + "')",
});
});
return _.sortBy(
_.map(categories, function(submenu, category) {
return {
text: category,
submenu: _.sortBy(submenu, 'text'),
};
}),
'text'
);
}
});

View File

@ -8,7 +8,6 @@ export class GraphiteConfigCtrl {
this.datasourceSrv = datasourceSrv;
this.current.jsonData = this.current.jsonData || {};
this.current.jsonData.graphiteVersion = this.current.jsonData.graphiteVersion || '0.9';
this.autoDetectGraphiteVersion();
}

View File

@ -1,6 +1,7 @@
import _ from 'lodash';
import * as dateMath from 'app/core/utils/datemath';
import { isVersionGtOrEq, SemVersion } from 'app/core/utils/version';
import gfunc from './gfunc';
/** @ngInject */
export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv) {
@ -12,6 +13,8 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
this.cacheTimeout = instanceSettings.cacheTimeout;
this.withCredentials = instanceSettings.withCredentials;
this.render_method = instanceSettings.render_method || 'POST';
this.funcDefs = null;
this.funcDefsPromise = null;
this.getQueryOptionsInfo = function() {
return {
@ -200,6 +203,35 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
let options = optionalOptions || {};
let interpolatedQuery = templateSrv.replace(query);
// special handling for tag_values(<tag>[,<expression>]*), this is used for template variables
let matches = interpolatedQuery.match(/^tag_values\(([^,]+)((, *[^,]+)*)\)$/);
if (matches) {
const expressions = [];
const exprRegex = /, *([^,]+)/g;
let match;
while ((match = exprRegex.exec(matches[2])) !== null) {
expressions.push(match[1]);
}
options.limit = 10000;
return this.getTagValuesAutoComplete(expressions, matches[1], undefined, options);
}
// special handling for tags(<expression>[,<expression>]*), this is used for template variables
matches = interpolatedQuery.match(/^tags\(([^,]*)((, *[^,]+)*)\)$/);
if (matches) {
const expressions = [];
if (matches[1]) {
expressions.push(matches[1]);
const exprRegex = /, *([^,]+)/g;
let match;
while ((match = exprRegex.exec(matches[2])) !== null) {
expressions.push(match[1]);
}
}
options.limit = 10000;
return this.getTagsAutoComplete(expressions, undefined, options);
}
let httpOptions: any = {
method: 'GET',
url: '/metrics/find',
@ -210,7 +242,7 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
requestId: options.requestId,
};
if (options && options.range) {
if (options.range) {
httpOptions.params.from = this.translateTime(options.range.from, false);
httpOptions.params.until = this.translateTime(options.range.to, true);
}
@ -235,7 +267,7 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
requestId: options.requestId,
};
if (options && options.range) {
if (options.range) {
httpOptions.params.from = this.translateTime(options.range.from, false);
httpOptions.params.until = this.translateTime(options.range.to, true);
}
@ -255,12 +287,12 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
let httpOptions: any = {
method: 'GET',
url: '/tags/' + tag,
url: '/tags/' + templateSrv.replace(tag),
// for cancellations
requestId: options.requestId,
};
if (options && options.range) {
if (options.range) {
httpOptions.params.from = this.translateTime(options.range.from, false);
httpOptions.params.until = this.translateTime(options.range.to, true);
}
@ -279,18 +311,29 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
});
};
this.getTagsAutoComplete = (expression, tagPrefix) => {
this.getTagsAutoComplete = (expressions, tagPrefix, optionalOptions) => {
let options = optionalOptions || {};
let httpOptions: any = {
method: 'GET',
url: '/tags/autoComplete/tags',
params: {
expr: expression,
expr: _.map(expressions, expression => templateSrv.replace(expression)),
},
// for cancellations
requestId: options.requestId,
};
if (tagPrefix) {
httpOptions.params.tagPrefix = tagPrefix;
}
if (options.limit) {
httpOptions.params.limit = options.limit;
}
if (options.range) {
httpOptions.params.from = this.translateTime(options.range.from, false);
httpOptions.params.until = this.translateTime(options.range.to, true);
}
return this.doGraphiteRequest(httpOptions).then(results => {
if (results.data) {
@ -303,19 +346,30 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
});
};
this.getTagValuesAutoComplete = (expression, tag, valuePrefix) => {
this.getTagValuesAutoComplete = (expressions, tag, valuePrefix, optionalOptions) => {
let options = optionalOptions || {};
let httpOptions: any = {
method: 'GET',
url: '/tags/autoComplete/values',
params: {
expr: expression,
tag: tag,
expr: _.map(expressions, expression => templateSrv.replace(expression)),
tag: templateSrv.replace(tag),
},
// for cancellations
requestId: options.requestId,
};
if (valuePrefix) {
httpOptions.params.valuePrefix = valuePrefix;
}
if (options.limit) {
httpOptions.params.limit = options.limit;
}
if (options.range) {
httpOptions.params.from = this.translateTime(options.range.from, false);
httpOptions.params.until = this.translateTime(options.range.to, true);
}
return this.doGraphiteRequest(httpOptions).then(results => {
if (results.data) {
@ -328,10 +382,13 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
});
};
this.getVersion = function() {
this.getVersion = function(optionalOptions) {
let options = optionalOptions || {};
let httpOptions = {
method: 'GET',
url: '/version/_', // Prevent last / trimming
url: '/version',
requestId: options.requestId,
};
return this.doGraphiteRequest(httpOptions)
@ -347,6 +404,52 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
});
};
this.createFuncInstance = function(funcDef, options?) {
return gfunc.createFuncInstance(funcDef, options, this.funcDefs);
};
this.getFuncDef = function(name) {
return gfunc.getFuncDef(name, this.funcDefs);
};
this.waitForFuncDefsLoaded = function() {
return this.getFuncDefs();
};
this.getFuncDefs = function() {
if (this.funcDefsPromise !== null) {
return this.funcDefsPromise;
}
if (!supportsFunctionIndex(this.graphiteVersion)) {
this.funcDefs = gfunc.getFuncDefs(this.graphiteVersion);
this.funcDefsPromise = Promise.resolve(this.funcDefs);
return this.funcDefsPromise;
}
let httpOptions = {
method: 'GET',
url: '/functions',
};
this.funcDefsPromise = this.doGraphiteRequest(httpOptions)
.then(results => {
if (results.status !== 200 || typeof results.data !== 'object') {
this.funcDefs = gfunc.getFuncDefs(this.graphiteVersion);
} else {
this.funcDefs = gfunc.parseFuncDefs(results.data);
}
return this.funcDefs;
})
.catch(err => {
console.log('Fetching graphite functions error', err);
this.funcDefs = gfunc.getFuncDefs(this.graphiteVersion);
return this.funcDefs;
});
return this.funcDefsPromise;
};
this.testDatasource = function() {
return this.metricFindQuery('*').then(function() {
return { status: 'success', message: 'Data source is working' };
@ -440,3 +543,7 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
function supportsTags(version: string): boolean {
return isVersionGtOrEq(version, '1.1');
}
function supportsFunctionIndex(version: string): boolean {
return isVersionGtOrEq(version, '1.1');
}

View File

@ -2,17 +2,18 @@ define([
'angular',
'lodash',
'jquery',
'rst2html',
],
function (angular, _, $) {
function (angular, _, $, rst2html) {
'use strict';
angular
.module('grafana.directives')
.directive('graphiteFuncEditor', function($compile, templateSrv) {
.directive('graphiteFuncEditor', function($compile, templateSrv, popoverSrv) {
var funcSpanTemplate = '<a ng-click="">{{func.def.name}}</a><span>(</span>';
var paramTemplate = '<input type="text" style="display:none"' +
' class="input-mini tight-form-func-param"></input>';
' class="input-small tight-form-func-param"></input>';
var funcControlsTemplate =
'<div class="tight-form-func-controls">' +
@ -29,19 +30,20 @@ function (angular, _, $) {
var $funcControls = $(funcControlsTemplate);
var ctrl = $scope.ctrl;
var func = $scope.func;
var funcDef = func.def;
var scheduledRelink = false;
var paramCountAtLink = 0;
var cancelBlur = null;
function clickFuncParam(paramIndex) {
/*jshint validthis:true */
var $link = $(this);
var $comma = $link.prev('.comma');
var $input = $link.next();
$input.val(func.params[paramIndex]);
$input.css('width', ($link.width() + 16) + 'px');
$comma.removeClass('query-part__last');
$link.hide();
$input.show();
$input.focus();
@ -68,31 +70,64 @@ function (angular, _, $) {
}
}
function inputBlur(paramIndex) {
function paramDef(index) {
if (index < func.def.params.length) {
return func.def.params[index];
}
if (_.last(func.def.params).multiple) {
return _.assign({}, _.last(func.def.params), {optional: true});
}
return {};
}
function switchToLink(inputElem, paramIndex) {
/*jshint validthis:true */
var $input = $(this);
var $input = $(inputElem);
clearTimeout(cancelBlur);
cancelBlur = null;
var $link = $input.prev();
var $comma = $link.prev('.comma');
var newValue = $input.val();
if (newValue !== '' || func.def.params[paramIndex].optional) {
$link.html(templateSrv.highlightVariablesAsHtml(newValue));
func.updateParam($input.val(), paramIndex);
scheduledRelinkIfNeeded();
$scope.$apply(function() {
ctrl.targetChanged();
});
$input.hide();
$link.show();
// remove optional empty params
if (newValue !== '' || paramDef(paramIndex).optional) {
func.updateParam(newValue, paramIndex);
$link.html(newValue ? templateSrv.highlightVariablesAsHtml(newValue) : '&nbsp;');
}
scheduledRelinkIfNeeded();
$scope.$apply(function() {
ctrl.targetChanged();
});
if ($link.hasClass('query-part__last') && newValue === '') {
$comma.addClass('query-part__last');
} else {
$link.removeClass('query-part__last');
}
$input.hide();
$link.show();
}
// this = input element
function inputBlur(paramIndex) {
/*jshint validthis:true */
var inputElem = this;
// happens long before the click event on the typeahead options
// need to have long delay because the blur
cancelBlur = setTimeout(function() {
switchToLink(inputElem, paramIndex);
}, 200);
}
function inputKeyPress(paramIndex, e) {
/*jshint validthis:true */
if(e.which === 13) {
inputBlur.call(this, paramIndex);
$(this).blur();
}
}
@ -104,8 +139,8 @@ function (angular, _, $) {
function addTypeahead($input, paramIndex) {
$input.attr('data-provide', 'typeahead');
var options = funcDef.params[paramIndex].options;
if (funcDef.params[paramIndex].type === 'int') {
var options = paramDef(paramIndex).options;
if (paramDef(paramIndex).type === 'int') {
options = _.map(options, function(val) { return val.toString(); });
}
@ -114,9 +149,8 @@ function (angular, _, $) {
minLength: 0,
items: 20,
updater: function (value) {
setTimeout(function() {
inputBlur.call($input[0], paramIndex);
}, 0);
$input.val(value);
switchToLink($input[0], paramIndex);
return value;
}
});
@ -148,18 +182,34 @@ function (angular, _, $) {
$funcControls.appendTo(elem);
$funcLink.appendTo(elem);
_.each(funcDef.params, function(param, index) {
if (param.optional && func.params.length <= index) {
return;
}
var defParams = _.clone(func.def.params);
var lastParam = _.last(func.def.params);
if (index > 0) {
$('<span>, </span>').appendTo(elem);
while (func.params.length >= defParams.length && lastParam && lastParam.multiple) {
defParams.push(_.assign({}, lastParam, {optional: true}));
}
_.each(defParams, function(param, index) {
if (param.optional && func.params.length < index) {
return false;
}
var paramValue = templateSrv.highlightVariablesAsHtml(func.params[index]);
var $paramLink = $('<a ng-click="" class="graphite-func-param-link">' + paramValue + '</a>');
var last = (index >= func.params.length - 1) && param.optional && !paramValue;
if (last && param.multiple) {
paramValue = '+';
}
if (index > 0) {
$('<span class="comma' + (last ? ' query-part__last' : '') + '">, </span>').appendTo(elem);
}
var $paramLink = $(
'<a ng-click="" class="graphite-func-param-link' + (last ? ' query-part__last' : '') + '">'
+ (paramValue || '&nbsp;') + '</a>');
var $input = $(paramTemplate);
$input.attr('placeholder', param.name);
paramCountAtLink++;
@ -171,10 +221,9 @@ function (angular, _, $) {
$input.keypress(_.partial(inputKeyPress, index));
$paramLink.click(_.partial(clickFuncParam, index));
if (funcDef.params[index].options) {
if (param.options) {
addTypeahead($input, index);
}
});
$('<span>)</span>').appendTo(elem);
@ -182,7 +231,7 @@ function (angular, _, $) {
$compile(elem.contents())($scope);
}
function ifJustAddedFocusFistParam() {
function ifJustAddedFocusFirstParam() {
if ($scope.func.added) {
$scope.func.added = false;
setTimeout(function() {
@ -223,7 +272,20 @@ function (angular, _, $) {
}
if ($target.hasClass('fa-question-circle')) {
window.open("http://graphite.readthedocs.org/en/latest/functions.html#graphite.render.functions." + funcDef.name,'_blank');
var funcDef = ctrl.datasource.getFuncDef(func.def.name);
if (funcDef && funcDef.description) {
popoverSrv.show({
element: e.target,
position: 'bottom left',
classNames: 'drop-popover drop-function-def',
template: '<div style="overflow:auto;max-height:30rem;">'
+ '<h4>' + funcDef.name + '</h4>' + rst2html(funcDef.description) + '</div>',
openOn: 'click',
});
} else {
window.open(
"http://graphite.readthedocs.org/en/latest/functions.html#graphite.render.functions." + func.def.name,'_blank');
}
return;
}
});
@ -233,7 +295,7 @@ function (angular, _, $) {
elem.children().remove();
addElementsAndCompile();
ifJustAddedFocusFistParam();
ifJustAddedFocusFirstParam();
registerFuncControlsToggle();
registerFuncControlsActions();
}

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
import _ from 'lodash';
import gfunc from './gfunc';
import { Parser } from './parser';
export default class GraphiteQuery {
datasource: any;
target: any;
functions: any[];
segments: any[];
@ -15,7 +15,8 @@ export default class GraphiteQuery {
scopedVars: any;
/** @ngInject */
constructor(target, templateSrv?, scopedVars?) {
constructor(datasource, target, templateSrv?, scopedVars?) {
this.datasource = datasource;
this.target = target;
this.parseTarget();
@ -86,7 +87,7 @@ export default class GraphiteQuery {
switch (astNode.type) {
case 'function':
var innerFunc = gfunc.createFuncInstance(astNode.name, {
var innerFunc = this.datasource.createFuncInstance(astNode.name, {
withDefaultParams: false,
});
_.each(astNode.params, param => {
@ -133,7 +134,7 @@ export default class GraphiteQuery {
moveAliasFuncLast() {
var aliasFunc = _.find(this.functions, function(func) {
return func.def.name === 'alias' || func.def.name === 'aliasByNode' || func.def.name === 'aliasByMetric';
return func.def.name.startsWith('alias');
});
if (aliasFunc) {
@ -143,7 +144,7 @@ export default class GraphiteQuery {
}
addFunctionParameter(func, value) {
if (func.params.length >= func.def.params.length) {
if (func.params.length >= func.def.params.length && !_.get(_.last(func.def.params), 'multiple', false)) {
throw { message: 'too many parameters for function ' + func.def.name };
}
func.params.push(value);
@ -208,7 +209,7 @@ export default class GraphiteQuery {
}
splitSeriesByTagParams(func) {
const tagPattern = /([^\!=~]+)([\!=~]+)([^\!=~]+)/;
const tagPattern = /([^\!=~]+)(\!?=~?)(.*)/;
return _.flatten(
_.map(func.params, (param: string) => {
let matches = tagPattern.exec(param);

View File

@ -10,30 +10,50 @@
<label class="gf-form-label width-6 query-keyword">Series</label>
</div>
<div ng-repeat="tag in ctrl.queryModel.tags" class="gf-form">
<gf-form-dropdown model="tag.key" lookup-text="false" allow-custom="false" label-mode="true" css-class="query-segment-key"
<div ng-if="ctrl.queryModel.seriesByTagUsed" ng-repeat="tag in ctrl.queryModel.tags" class="gf-form">
<gf-form-dropdown
model="tag.key"
lookup-text="false"
allow-custom="true"
label-mode="true"
placeholder="Tag key"
css-class="query-segment-key"
get-options="ctrl.getTags($index, $query)"
on-change="ctrl.tagChanged(tag, $index)">
</gf-form-dropdown>
<gf-form-dropdown model="tag.operator" lookup-text="false" allow-custom="false" label-mode="true" css-class="query-segment-operator"
on-change="ctrl.tagChanged(tag, $index)"
/>
<gf-form-dropdown
model="tag.operator"
lookup-text="false"
allow-custom="false"
label-mode="true"
css-class="query-segment-operator"
get-options="ctrl.getTagOperators()"
on-change="ctrl.tagChanged(tag, $index)"
min-input-width="30">
</gf-form-dropdown>
<gf-form-dropdown model="tag.value" lookup-text="false" allow-custom="false" label-mode="true" css-class="query-segment-value"
min-input-width="30"
/>
<gf-form-dropdown
model="tag.value"
lookup-text="false"
allow-custom="true"
label-mode="true"
css-class="query-segment-value"
placeholder="Tag value"
get-options="ctrl.getTagValues(tag, $index, $query)"
on-change="ctrl.tagChanged(tag, $index)">
</gf-form-dropdown>
on-change="ctrl.tagChanged(tag, $index)"
/>
<label class="gf-form-label query-keyword" ng-if="ctrl.showDelimiter($index)">AND</label>
</div>
<div ng-repeat="segment in ctrl.segments" role="menuitem" class="gf-form">
<metric-segment segment="segment" get-options="ctrl.getAltSegments($index)" on-change="ctrl.segmentValueChanged(segment, $index)"></metric-segment>
<div ng-if="ctrl.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.addTagSegments" role="menuitem" class="gf-form">
<metric-segment segment="segment" get-options="ctrl.getTagsAsSegments($query)" on-change="ctrl.addNewTag(segment)" />
</div>
<div ng-if="ctrl.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.addTagSegments" role="menuitem" class="gf-form">
<metric-segment segment="segment" get-options="ctrl.getTagsAsSegments()" on-change="ctrl.addNewTag(segment)">
</metric-segment>
<div ng-if="!ctrl.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.segments" role="menuitem" class="gf-form">
<metric-segment segment="segment" get-options="ctrl.getAltSegments($index, $query)" on-change="ctrl.segmentValueChanged(segment, $index)" />
</div>
<div ng-if="ctrl.paused" class="gf-form">
<a ng-click="ctrl.unpause()" class="gf-form-label query-part"><i class="fa fa-play"></i></a>
</div>
<div class="gf-form gf-form--grow">

View File

@ -2,7 +2,6 @@ import './add_graphite_func';
import './func_editor';
import _ from 'lodash';
import gfunc from './gfunc';
import GraphiteQuery from './graphite_query';
import { QueryCtrl } from 'app/plugins/sdk';
import appEvents from 'app/core/app_events';
@ -18,17 +17,19 @@ export class GraphiteQueryCtrl extends QueryCtrl {
addTagSegments: any[];
removeTagValue: string;
supportsTags: boolean;
paused: boolean;
/** @ngInject **/
constructor($scope, $injector, private uiSegmentSrv, private templateSrv) {
constructor($scope, $injector, private uiSegmentSrv, private templateSrv, $timeout) {
super($scope, $injector);
this.supportsTags = this.datasource.supportsTags;
this.paused = false;
this.target.target = this.target.target || '';
if (this.target) {
this.target.target = this.target.target || '';
this.queryModel = new GraphiteQuery(this.target, templateSrv);
this.datasource.waitForFuncDefsLoaded().then(() => {
this.queryModel = new GraphiteQuery(this.datasource, this.target, templateSrv);
this.buildSegments();
}
});
this.removeTagValue = '-- remove tag --';
}
@ -104,8 +105,11 @@ export class GraphiteQueryCtrl extends QueryCtrl {
});
}
getAltSegments(index) {
var query = index === 0 ? '*' : this.queryModel.getSegmentPathUpTo(index) + '.*';
getAltSegments(index, prefix) {
var query = prefix && prefix.length > 0 ? '*' + prefix + '*' : '*';
if (index > 0) {
query = this.queryModel.getSegmentPathUpTo(index) + '.' + query;
}
var options = {
range: this.panelCtrl.range,
requestId: 'get-alt-segments',
@ -121,7 +125,7 @@ export class GraphiteQueryCtrl extends QueryCtrl {
});
});
if (altSegments.length === 0) {
if (index > 0 && altSegments.length === 0) {
return altSegments;
}
@ -158,7 +162,7 @@ export class GraphiteQueryCtrl extends QueryCtrl {
if (this.supportsTags && index === 0) {
this.removeTaggedEntry(altSegments);
return this.addAltTagSegments(index, altSegments);
return this.addAltTagSegments(prefix, altSegments);
} else {
return altSegments;
}
@ -168,8 +172,8 @@ export class GraphiteQueryCtrl extends QueryCtrl {
});
}
addAltTagSegments(index, altSegments) {
return this.getTagsAsSegments().then(tagSegments => {
addAltTagSegments(prefix, altSegments) {
return this.getTagsAsSegments(prefix).then(tagSegments => {
tagSegments = _.map(tagSegments, segment => {
segment.value = TAG_PREFIX + segment.value;
return segment;
@ -192,6 +196,7 @@ export class GraphiteQueryCtrl extends QueryCtrl {
if (segment.type === 'tag') {
let tag = removeTagPrefix(segment.value);
this.pause();
this.addSeriesByTagFunc(tag);
return;
}
@ -236,13 +241,13 @@ export class GraphiteQueryCtrl extends QueryCtrl {
var oldTarget = this.queryModel.target.target;
this.updateModelTarget();
if (this.queryModel.target !== oldTarget) {
if (this.queryModel.target !== oldTarget && !this.paused) {
this.panelCtrl.refresh();
}
}
addFunction(funcDef) {
var newFunc = gfunc.createFuncInstance(funcDef, {
var newFunc = this.datasource.createFuncInstance(funcDef, {
withDefaultParams: true,
});
newFunc.added = true;
@ -268,11 +273,10 @@ export class GraphiteQueryCtrl extends QueryCtrl {
}
addSeriesByTagFunc(tag) {
let funcDef = gfunc.getFuncDef('seriesByTag');
let newFunc = gfunc.createFuncInstance(funcDef, {
let newFunc = this.datasource.createFuncInstance('seriesByTag', {
withDefaultParams: false,
});
let tagParam = `${tag}=select tag value`;
let tagParam = `${tag}=`;
newFunc.params = [tagParam];
this.queryModel.addFunction(newFunc);
newFunc.added = true;
@ -314,9 +318,9 @@ export class GraphiteQueryCtrl extends QueryCtrl {
});
}
getTagsAsSegments() {
getTagsAsSegments(tagPrefix) {
let tagExpressions = this.queryModel.renderTagExpressions();
return this.datasource.getTagsAutoComplete(tagExpressions).then(values => {
return this.datasource.getTagsAutoComplete(tagExpressions, tagPrefix).then(values => {
return _.map(values, val => {
return this.uiSegmentSrv.newSegment({
value: val.text,
@ -355,7 +359,7 @@ export class GraphiteQueryCtrl extends QueryCtrl {
addNewTag(segment) {
let newTagKey = segment.value;
let newTag = { key: newTagKey, operator: '=', value: 'select tag value' };
let newTag = { key: newTagKey, operator: '=', value: '' };
this.queryModel.addTag(newTag);
this.targetChanged();
this.fixTagSegments();
@ -374,6 +378,15 @@ export class GraphiteQueryCtrl extends QueryCtrl {
showDelimiter(index) {
return index !== this.queryModel.tags.length - 1;
}
pause() {
this.paused = true;
}
unpause() {
this.paused = false;
this.panelCtrl.refresh();
}
}
function mapToDropdownOptions(results) {

View File

@ -5,7 +5,8 @@ describe('when creating func instance from func names', function() {
var func = gfunc.createFuncInstance('sumSeries');
expect(func).toBeTruthy();
expect(func.def.name).toEqual('sumSeries');
expect(func.def.params.length).toEqual(5);
expect(func.def.params.length).toEqual(1);
expect(func.def.params[0].multiple).toEqual(true);
expect(func.def.defaultParams.length).toEqual(1);
});
@ -74,10 +75,10 @@ describe('when rendering func instance', function() {
});
});
describe('when requesting function categories', function() {
it('should return function categories', function() {
var catIndex = gfunc.getCategories('1.0');
expect(catIndex.Special.length).toBeGreaterThan(8);
describe('when requesting function definitions', function() {
it('should return function definitions', function() {
var funcIndex = gfunc.getFuncDefs('1.0');
expect(Object.keys(funcIndex).length).toBeGreaterThan(8);
});
});

View File

@ -24,6 +24,10 @@ describe('GraphiteQueryCtrl', function() {
ctx.scope = $rootScope.$new();
ctx.target = { target: 'aliasByNode(scaleToSeconds(test.prod.*,1),2)' };
ctx.datasource.metricFindQuery = sinon.stub().returns(ctx.$q.when([]));
ctx.datasource.getFuncDefs = sinon.stub().returns(ctx.$q.when(gfunc.getFuncDefs('1.0')));
ctx.datasource.getFuncDef = gfunc.getFuncDef;
ctx.datasource.waitForFuncDefsLoaded = sinon.stub().returns(ctx.$q.when(null));
ctx.datasource.createFuncInstance = gfunc.createFuncInstance;
ctx.panelCtrl = { panel: {} };
ctx.panelCtrl = {
panel: {
@ -180,7 +184,21 @@ describe('GraphiteQueryCtrl', function() {
ctx.ctrl.target.target = 'scaleToSeconds(#A, 60)';
ctx.ctrl.datasource.metricFindQuery = sinon.stub().returns(ctx.$q.when([{ expandable: false }]));
ctx.ctrl.parseTarget();
});
it('should add function params', function() {
expect(ctx.ctrl.queryModel.segments.length).to.be(1);
expect(ctx.ctrl.queryModel.segments[0].value).to.be('#A');
expect(ctx.ctrl.queryModel.functions[0].params.length).to.be(1);
expect(ctx.ctrl.queryModel.functions[0].params[0]).to.be(60);
});
it('target should remain the same', function() {
expect(ctx.ctrl.target.target).to.be('scaleToSeconds(#A, 60)');
});
it('targetFull should include nested queries', function() {
ctx.ctrl.panelCtrl.panel.targets = [
{
target: 'nested.query.count',
@ -189,13 +207,9 @@ describe('GraphiteQueryCtrl', function() {
];
ctx.ctrl.updateModelTarget();
});
it('target should remain the same', function() {
expect(ctx.ctrl.target.target).to.be('scaleToSeconds(#A, 60)');
});
it('targetFull should include nexted queries', function() {
expect(ctx.ctrl.target.targetFull).to.be('scaleToSeconds(nested.query.count, 60)');
});
});
@ -271,12 +285,12 @@ describe('GraphiteQueryCtrl', function() {
});
it('should update tags with default value', function() {
const expected = [{ key: 'tag1', operator: '=', value: 'select tag value' }];
const expected = [{ key: 'tag1', operator: '=', value: '' }];
expect(ctx.ctrl.queryModel.tags).to.eql(expected);
});
it('should update target', function() {
const expected = "seriesByTag('tag1=select tag value')";
const expected = "seriesByTag('tag1=')";
expect(ctx.ctrl.target.target).to.eql(expected);
});
});

View File

@ -1,7 +1,7 @@
<div class="gf-form-group">
<div class="gf-form">
<input type="text" class="gf-form-input" ng-model='ctrl.annotation.query' placeholder="select text from events where $timeFilter"></input>
<input type="text" class="gf-form-input" ng-model='ctrl.annotation.query' placeholder="select text from events where $timeFilter limit 1000"></input>
</div>
</div>

View File

@ -138,7 +138,7 @@ export default class ResponseParser {
list.push({
annotation: options.annotation,
time: Math.floor(row[timeColumnIndex]) * 1000,
text: row[textColumnIndex],
text: row[textColumnIndex] ? row[textColumnIndex].toString() : '',
tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : [],
});
}

View File

@ -17,66 +17,22 @@ export class PromCompleter {
getCompletions(editor, session, pos, prefix, callback) {
let token = session.getTokenAt(pos.row, pos.column);
var metricName;
switch (token.type) {
case 'entity.name.tag':
metricName = this.findMetricName(session, pos.row, pos.column);
if (!metricName) {
callback(null, this.transformToCompletions(['__name__', 'instance', 'job'], 'label name'));
return;
}
if (this.labelNameCache[metricName]) {
callback(null, this.labelNameCache[metricName]);
return;
}
return this.getLabelNameAndValueForMetric(metricName).then(result => {
var labelNames = this.transformToCompletions(
_.uniq(
_.flatten(
result.map(r => {
return Object.keys(r.metric);
})
)
),
'label name'
);
this.labelNameCache[metricName] = labelNames;
callback(null, labelNames);
case 'entity.name.tag.label-matcher':
this.getCompletionsForLabelMatcherName(session, pos).then(completions => {
callback(null, completions);
});
case 'string.quoted':
metricName = this.findMetricName(session, pos.row, pos.column);
if (!metricName) {
callback(null, []);
return;
}
var labelNameToken = this.findToken(session, pos.row, pos.column, 'entity.name.tag', null, 'paren.lparen');
if (!labelNameToken) {
callback(null, []);
return;
}
var labelName = labelNameToken.value;
if (this.labelValueCache[metricName] && this.labelValueCache[metricName][labelName]) {
callback(null, this.labelValueCache[metricName][labelName]);
return;
}
return this.getLabelNameAndValueForMetric(metricName).then(result => {
var labelValues = this.transformToCompletions(
_.uniq(
result.map(r => {
return r.metric[labelName];
})
),
'label value'
);
this.labelValueCache[metricName] = this.labelValueCache[metricName] || {};
this.labelValueCache[metricName][labelName] = labelValues;
callback(null, labelValues);
return;
case 'string.quoted.label-matcher':
this.getCompletionsForLabelMatcherValue(session, pos).then(completions => {
callback(null, completions);
});
return;
case 'entity.name.tag.label-list-matcher':
this.getCompletionsForBinaryOperator(session, pos).then(completions => {
callback(null, completions);
});
return;
}
if (token.type === 'paren.lparen' && token.value === '[') {
@ -128,17 +84,186 @@ export class PromCompleter {
});
}
getLabelNameAndValueForMetric(metricName) {
if (this.labelQueryCache[metricName]) {
return Promise.resolve(this.labelQueryCache[metricName]);
getCompletionsForLabelMatcherName(session, pos) {
let metricName = this.findMetricName(session, pos.row, pos.column);
if (!metricName) {
return Promise.resolve(this.transformToCompletions(['__name__', 'instance', 'job'], 'label name'));
}
var op = '=~';
if (/[a-zA-Z_:][a-zA-Z0-9_:]*/.test(metricName)) {
op = '=';
if (this.labelNameCache[metricName]) {
return Promise.resolve(this.labelNameCache[metricName]);
}
var expr = '{__name__' + op + '"' + metricName + '"}';
return this.datasource.performInstantQuery({ expr: expr }, new Date().getTime() / 1000).then(response => {
this.labelQueryCache[metricName] = response.data.data.result;
return this.getLabelNameAndValueForExpression(metricName, 'metricName').then(result => {
var labelNames = this.transformToCompletions(
_.uniq(
_.flatten(
result.map(r => {
return Object.keys(r.metric);
})
)
),
'label name'
);
this.labelNameCache[metricName] = labelNames;
return Promise.resolve(labelNames);
});
}
getCompletionsForLabelMatcherValue(session, pos) {
let metricName = this.findMetricName(session, pos.row, pos.column);
if (!metricName) {
return Promise.resolve([]);
}
var labelNameToken = this.findToken(
session,
pos.row,
pos.column,
'entity.name.tag.label-matcher',
null,
'paren.lparen.label-matcher'
);
if (!labelNameToken) {
return Promise.resolve([]);
}
var labelName = labelNameToken.value;
if (this.labelValueCache[metricName] && this.labelValueCache[metricName][labelName]) {
return Promise.resolve(this.labelValueCache[metricName][labelName]);
}
return this.getLabelNameAndValueForExpression(metricName, 'metricName').then(result => {
var labelValues = this.transformToCompletions(
_.uniq(
result.map(r => {
return r.metric[labelName];
})
),
'label value'
);
this.labelValueCache[metricName] = this.labelValueCache[metricName] || {};
this.labelValueCache[metricName][labelName] = labelValues;
return Promise.resolve(labelValues);
});
}
getCompletionsForBinaryOperator(session, pos) {
let keywordOperatorToken = this.findToken(session, pos.row, pos.column, 'keyword.control', null, 'identifier');
if (!keywordOperatorToken) {
return Promise.resolve([]);
}
let rparenToken, expr;
switch (keywordOperatorToken.value) {
case 'by':
case 'without':
rparenToken = this.findToken(
session,
keywordOperatorToken.row,
keywordOperatorToken.column,
'paren.rparen',
null,
'identifier'
);
if (!rparenToken) {
return Promise.resolve([]);
}
expr = this.findExpressionMatchedParen(session, rparenToken.row, rparenToken.column);
if (expr === '') {
return Promise.resolve([]);
}
return this.getLabelNameAndValueForExpression(expr, 'expression').then(result => {
var labelNames = this.transformToCompletions(
_.uniq(
_.flatten(
result.map(r => {
return Object.keys(r.metric);
})
)
),
'label name'
);
this.labelNameCache[expr] = labelNames;
return labelNames;
});
case 'on':
case 'ignoring':
case 'group_left':
case 'group_right':
let binaryOperatorToken = this.findToken(
session,
keywordOperatorToken.row,
keywordOperatorToken.column,
'keyword.operator.binary',
null,
'identifier'
);
if (!binaryOperatorToken) {
return Promise.resolve([]);
}
rparenToken = this.findToken(
session,
binaryOperatorToken.row,
binaryOperatorToken.column,
'paren.rparen',
null,
'identifier'
);
if (rparenToken) {
expr = this.findExpressionMatchedParen(session, rparenToken.row, rparenToken.column);
if (expr === '') {
return Promise.resolve([]);
}
return this.getLabelNameAndValueForExpression(expr, 'expression').then(result => {
var labelNames = this.transformToCompletions(
_.uniq(
_.flatten(
result.map(r => {
return Object.keys(r.metric);
})
)
),
'label name'
);
this.labelNameCache[expr] = labelNames;
return labelNames;
});
} else {
let metricName = this.findMetricName(session, binaryOperatorToken.row, binaryOperatorToken.column);
return this.getLabelNameAndValueForExpression(metricName, 'metricName').then(result => {
var labelNames = this.transformToCompletions(
_.uniq(
_.flatten(
result.map(r => {
return Object.keys(r.metric);
})
)
),
'label name'
);
this.labelNameCache[metricName] = labelNames;
return Promise.resolve(labelNames);
});
}
}
return Promise.resolve([]);
}
getLabelNameAndValueForExpression(expr, type) {
if (this.labelQueryCache[expr]) {
return Promise.resolve(this.labelQueryCache[expr]);
}
let query = expr;
if (type === 'metricName') {
let op = '=~';
if (/[a-zA-Z_:][a-zA-Z0-9_:]*/.test(expr)) {
op = '=';
}
query = '{__name__' + op + '"' + expr + '"}';
}
return this.datasource.performInstantQuery({ expr: query }, new Date().getTime() / 1000).then(response => {
this.labelQueryCache[expr] = response.data.data.result;
return response.data.data.result;
});
}
@ -158,20 +283,25 @@ export class PromCompleter {
var metricName = '';
var tokens;
var nameLabelNameToken = this.findToken(session, row, column, 'entity.name.tag', '__name__', 'paren.lparen');
var nameLabelNameToken = this.findToken(
session,
row,
column,
'entity.name.tag.label-matcher',
'__name__',
'paren.lparen.label-matcher'
);
if (nameLabelNameToken) {
tokens = session.getTokens(nameLabelNameToken.row);
var nameLabelValueToken = tokens[nameLabelNameToken.index + 2];
if (nameLabelValueToken && nameLabelValueToken.type === 'string.quoted') {
if (nameLabelValueToken && nameLabelValueToken.type === 'string.quoted.label-matcher') {
metricName = nameLabelValueToken.value.slice(1, -1); // cut begin/end quotation
}
} else {
var metricNameToken = this.findToken(session, row, column, 'identifier', null, null);
if (metricNameToken) {
tokens = session.getTokens(metricNameToken.row);
if (tokens[metricNameToken.index + 1].type === 'paren.lparen') {
metricName = metricNameToken.value;
}
metricName = metricNameToken.value;
}
}
@ -180,11 +310,57 @@ export class PromCompleter {
findToken(session, row, column, target, value, guard) {
var tokens, idx;
// find index and get column of previous token
for (var r = row; r >= 0; r--) {
let c;
tokens = session.getTokens(r);
if (r === row) {
// current row
var c = 0;
c = 0;
for (idx = 0; idx < tokens.length; idx++) {
let nc = c + tokens[idx].value.length;
if (nc >= column) {
break;
}
c = nc;
}
} else {
idx = tokens.length - 1;
c =
_.sum(
tokens.map(t => {
return t.value.length;
})
) - tokens[tokens.length - 1].value.length;
}
for (; idx >= 0; idx--) {
if (tokens[idx].type === guard) {
return null;
}
if (tokens[idx].type === target && (!value || tokens[idx].value === value)) {
tokens[idx].row = r;
tokens[idx].column = c;
tokens[idx].index = idx;
return tokens[idx];
}
c -= tokens[idx].value.length;
}
}
return null;
}
findExpressionMatchedParen(session, row, column) {
let tokens, idx;
let deep = 1;
let expression = ')';
for (let r = row; r >= 0; r--) {
tokens = session.getTokens(r);
if (r === row) {
// current row
let c = 0;
for (idx = 0; idx < tokens.length; idx++) {
c += tokens[idx].value.length;
if (c >= column) {
@ -196,18 +372,18 @@ export class PromCompleter {
}
for (; idx >= 0; idx--) {
if (tokens[idx].type === guard) {
return null;
}
if (tokens[idx].type === target && (!value || tokens[idx].value === value)) {
tokens[idx].row = r;
tokens[idx].index = idx;
return tokens[idx];
expression = tokens[idx].value + expression;
if (tokens[idx].type === 'paren.rparen') {
deep++;
} else if (tokens[idx].type === 'paren.lparen') {
deep--;
if (deep === 0) {
return expression;
}
}
}
}
return null;
return expression;
}
}

View File

@ -8,7 +8,6 @@ var TextHighlightRules = require("./text_highlight_rules").TextHighlightRules;
var PrometheusHighlightRules = function() {
var keywords = (
"by|without|keep_common|offset|bool|and|or|unless|ignoring|on|group_left|group_right|" +
"count|count_values|min|max|avg|sum|stddev|stdvar|bottomk|topk|quantile"
);
@ -41,45 +40,66 @@ var PrometheusHighlightRules = function() {
}, {
token : "constant.language", // time
regex : "\\d+[smhdwy]"
}, {
token : "keyword.operator.binary",
regex : "\\+|\\-|\\*|\\/|%|\\^|==|!=|<=|>=|<|>|and|or|unless"
}, {
token : "keyword.other",
regex : "keep_common|offset|bool"
}, {
token : "keyword.control",
regex : "by|without|on|ignoring|group_left|group_right",
next : "start-label-list-matcher"
}, {
token : keywordMapper,
regex : "[a-zA-Z_:][a-zA-Z0-9_:]*"
}, {
token : "keyword.operator",
regex : "\\+|\\-|\\*|\\/|%|\\^|==|!=|<=|>=|<|>"
}, {
token : "paren.lparen",
regex : "[[(]"
}, {
token : "paren.lparen",
token : "paren.lparen.label-matcher",
regex : "{",
next : "start-label-matcher"
}, {
token : "paren.rparen",
regex : "[\\])]"
}, {
token : "paren.rparen",
token : "paren.rparen.label-matcher",
regex : "}"
}, {
token : "text",
regex : "\\s+"
} ],
"start-label-matcher" : [ {
token : "entity.name.tag",
token : "entity.name.tag.label-matcher",
regex : '[a-zA-Z_][a-zA-Z0-9_]*'
}, {
token : "keyword.operator",
token : "keyword.operator.label-matcher",
regex : '=~|=|!~|!='
}, {
token : "string.quoted",
token : "string.quoted.label-matcher",
regex : '"[^"]*"|\'[^\']*\''
}, {
token : "punctuation.operator",
token : "punctuation.operator.label-matcher",
regex : ","
}, {
token : "paren.rparen",
token : "paren.rparen.label-matcher",
regex : "}",
next : "start"
} ],
"start-label-list-matcher" : [ {
token : "paren.lparen.label-list-matcher",
regex : "[(]"
}, {
token : "entity.name.tag.label-list-matcher",
regex : '[a-zA-Z_][a-zA-Z0-9_]*'
}, {
token : "punctuation.operator.label-list-matcher",
regex : ","
}, {
token : "paren.rparen.label-list-matcher",
regex : "[)]",
next : "start"
} ]
};
@ -395,7 +415,9 @@ var PrometheusCompletions = function() {};
(function() {
this.getCompletions = function(state, session, pos, prefix, callback) {
var token = session.getTokenAt(pos.row, pos.column);
if (token.type === 'entity.name.tag' || token.type === 'string.quoted') {
if (token.type === 'entity.name.tag.label-matcher'
|| token.type === 'string.quoted.label-matcher'
|| token.type === 'entity.name.tag.label-list-matcher') {
return callback(null, []);
}

View File

@ -61,16 +61,21 @@ describe('Prometheus editor completer', function() {
it('Should return label name list', () => {
const session = getSessionStub({
currentToken: {
type: 'entity.name.tag',
type: 'entity.name.tag.label-matcher',
value: 'j',
index: 2,
start: 9,
},
tokens: [
{ type: 'identifier', value: 'node_cpu' },
{ type: 'paren.lparen', value: '{' },
{ type: 'entity.name.tag', value: 'j', index: 2, start: 9 },
{ type: 'paren.rparen', value: '}' },
{ type: 'paren.lparen.label-matcher', value: '{' },
{
type: 'entity.name.tag.label-matcher',
value: 'j',
index: 2,
start: 9,
},
{ type: 'paren.rparen.label-matcher', value: '}' },
],
line: 'node_cpu{j}',
});
@ -85,19 +90,24 @@ describe('Prometheus editor completer', function() {
it('Should return label name list', () => {
const session = getSessionStub({
currentToken: {
type: 'entity.name.tag',
type: 'entity.name.tag.label-matcher',
value: 'j',
index: 5,
start: 22,
},
tokens: [
{ type: 'paren.lparen', value: '{' },
{ type: 'entity.name.tag', value: '__name__' },
{ type: 'keyword.operator', value: '=~' },
{ type: 'string.quoted', value: '"node_cpu"' },
{ type: 'punctuation.operator', value: ',' },
{ type: 'entity.name.tag', value: 'j', index: 5, start: 22 },
{ type: 'paren.rparen', value: '}' },
{ type: 'paren.lparen.label-matcher', value: '{' },
{ type: 'entity.name.tag.label-matcher', value: '__name__' },
{ type: 'keyword.operator.label-matcher', value: '=~' },
{ type: 'string.quoted.label-matcher', value: '"node_cpu"' },
{ type: 'punctuation.operator.label-matcher', value: ',' },
{
type: 'entity.name.tag.label-matcher',
value: 'j',
index: 5,
start: 22,
},
{ type: 'paren.rparen.label-matcher', value: '}' },
],
line: '{__name__=~"node_cpu",j}',
});
@ -112,18 +122,23 @@ describe('Prometheus editor completer', function() {
it('Should return label value list', () => {
const session = getSessionStub({
currentToken: {
type: 'string.quoted',
type: 'string.quoted.label-matcher',
value: '"n"',
index: 4,
start: 13,
},
tokens: [
{ type: 'identifier', value: 'node_cpu' },
{ type: 'paren.lparen', value: '{' },
{ type: 'entity.name.tag', value: 'job' },
{ type: 'keyword.operator', value: '=' },
{ type: 'string.quoted', value: '"n"', index: 4, start: 13 },
{ type: 'paren.rparen', value: '}' },
{ type: 'paren.lparen.label-matcher', value: '{' },
{ type: 'entity.name.tag.label-matcher', value: 'job' },
{ type: 'keyword.operator.label-matcher', value: '=' },
{
type: 'string.quoted.label-matcher',
value: '"n"',
index: 4,
start: 13,
},
{ type: 'paren.rparen.label-matcher', value: '}' },
],
line: 'node_cpu{job="n"}',
});
@ -133,4 +148,40 @@ describe('Prometheus editor completer', function() {
});
});
});
describe('When inside by', () => {
it('Should return label name list', () => {
const session = getSessionStub({
currentToken: {
type: 'entity.name.tag.label-list-matcher',
value: 'm',
index: 9,
start: 22,
},
tokens: [
{ type: 'paren.lparen', value: '(' },
{ type: 'keyword', value: 'count' },
{ type: 'paren.lparen', value: '(' },
{ type: 'identifier', value: 'node_cpu' },
{ type: 'paren.rparen', value: '))' },
{ type: 'text', value: ' ' },
{ type: 'keyword.control', value: 'by' },
{ type: 'text', value: ' ' },
{ type: 'paren.lparen.label-list-matcher', value: '(' },
{
type: 'entity.name.tag.label-list-matcher',
value: 'm',
index: 9,
start: 22,
},
{ type: 'paren.rparen.label-list-matcher', value: ')' },
],
line: '(count(node_cpu)) by (m)',
});
return completer.getCompletions(editor, session, { row: 0, column: 23 }, 'm', (s, res) => {
expect(res[0].meta).to.eql('label name');
});
});
});
});

View File

@ -26,24 +26,24 @@
</div>
<div class="section gf-form-group">
<h5 class="section-heading">Mode Options</h5>
<div class="gf-form" ng-show="ctrl.panel.lines">
<div class="gf-form">
<label class="gf-form-label width-8">Fill</label>
<div class="gf-form-select-wrapper max-width-5">
<select class="gf-form-input" ng-model="ctrl.panel.fill" ng-options="f for f in [0,1,2,3,4,5,6,7,8,9,10]" ng-change="ctrl.render()"></select>
<select class="gf-form-input" ng-model="ctrl.panel.fill" ng-options="f for f in [0,1,2,3,4,5,6,7,8,9,10]" ng-change="ctrl.render()" ng-disabled="!ctrl.panel.lines"></select>
</div>
</div>
<div class="gf-form" ng-show="(ctrl.panel.lines)">
<div class="gf-form">
<label class="gf-form-label width-8">Line Width</label>
<div class="gf-form-select-wrapper max-width-5">
<select class="gf-form-input" ng-model="ctrl.panel.linewidth" ng-options="f for f in [0,1,2,3,4,5,6,7,8,9,10]" ng-change="ctrl.render()"></select>
<select class="gf-form-input" ng-model="ctrl.panel.linewidth" ng-options="f for f in [0,1,2,3,4,5,6,7,8,9,10]" ng-change="ctrl.render()" ng-disabled="!ctrl.panel.lines"></select>
</div>
</div>
<gf-form-switch ng-show="ctrl.panel.lines" class="gf-form" label="Staircase" label-class="width-8" checked="ctrl.panel.steppedLine" on-change="ctrl.render()">
<gf-form-switch ng-disabled="!ctrl.panel.lines" class="gf-form" label="Staircase" label-class="width-8" checked="ctrl.panel.steppedLine" on-change="ctrl.render()">
</gf-form-switch>
<div class="gf-form" ng-show="ctrl.panel.points">
<div class="gf-form">
<label class="gf-form-label width-8">Point Radius</label>
<div class="gf-form-select-wrapper max-width-5">
<select class="gf-form-input" ng-model="ctrl.panel.pointradius" ng-options="f for f in [1,2,3,4,5,6,7,8,9,10]" ng-change="ctrl.render()"></select>
<select class="gf-form-input" ng-model="ctrl.panel.pointradius" ng-options="f for f in [1,2,3,4,5,6,7,8,9,10]" ng-change="ctrl.render()" ng-disabled="!ctrl.panel.points"></select>
</div>
</div>
</div>

View File

@ -29,7 +29,7 @@
<input type="text" class="gf-form-input width-12" ng-model="ctrl.panel.prefix" ng-change="ctrl.render()" ng-model-onblur>
<label class="gf-form-label width-6">Font size</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.panel.prefixFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()"></select>
<select class="gf-form-input" ng-model="ctrl.panel.prefixFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()" ng-disabled="ctrl.canChangeFontSize()"></select>
</div>
</div>
</div>
@ -39,7 +39,7 @@
<input type="text" class="gf-form-input width-12" ng-model="ctrl.panel.postfix" ng-change="ctrl.render()" ng-model-onblur>
<label class="gf-form-label width-6">Font size</label>
<div class="gf-form-select-wrapper">
<select class="input-small gf-form-input" ng-model="ctrl.panel.postfixFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()"></select>
<select class="input-small gf-form-input" ng-model="ctrl.panel.postfixFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()" ng-disabled="ctrl.canChangeFontSize()"></select>
</div>
</div>
<div class="gf-form">

View File

@ -198,6 +198,10 @@ class SingleStatCtrl extends MetricsPanelCtrl {
this.setValueMapping(data);
}
canChangeFontSize() {
return this.panel.gauge.show;
}
setColoring(options) {
if (options.background) {
this.panel.colorValue = false;

View File

@ -0,0 +1,26 @@
import { ViewStore } from './ViewStore';
import { toJS } from 'mobx';
describe('ViewStore', () => {
let store;
beforeAll(() => {
store = ViewStore.create({
path: '',
query: {},
});
});
it('Can update path and query', () => {
store.updatePathAndQuery('/hello', { key: 1, otherParam: 'asd' });
expect(store.path).toBe('/hello');
expect(store.query.get('key')).toBe(1);
expect(store.currentUrl).toBe('/hello?key=1&otherParam=asd');
});
it('Query can contain arrays', () => {
store.updatePathAndQuery('/hello', { values: ['A', 'B'] });
expect(toJS(store.query.get('values'))).toMatchObject(['A', 'B']);
expect(store.currentUrl).toBe('/hello?values=A&values=B');
});
});

View File

@ -1,15 +1,9 @@
import { types } from 'mobx-state-tree';
import { toJS } from 'mobx';
import { toUrlParams } from 'app/core/utils/url';
const QueryValueType = types.union(types.string, types.boolean, types.number);
const urlParameterize = queryObj => {
const keys = Object.keys(queryObj);
const newQuery = keys.reduce((acc: string, key: string, idx: number) => {
const preChar = idx === 0 ? '?' : '&';
return acc + preChar + key + '=' + queryObj[key];
}, '');
return newQuery;
};
const QueryInnerValueType = types.union(types.string, types.boolean, types.number);
const QueryValueType = types.union(QueryInnerValueType, types.array(QueryInnerValueType));
export const ViewStore = types
.model({
@ -22,7 +16,7 @@ export const ViewStore = types
let path = self.path;
if (self.query.size) {
path += urlParameterize(self.query.toJS());
path += '?' + toUrlParams(toJS(self.query));
}
return path;
},

View File

@ -1,11 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!-- Generator: Adobe Fireworks CS6, Export SVG Extension by Aaron Beall (http://fireworks.abeall.com) . Version: 0.6.1 -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg id="Untitled-Page%201" viewBox="0 0 6 6" style="background-color:#ffffff00" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"
x="0px" y="0px" width="6px" height="6px"
>
<g opacity="0.302">
<path d="M 6 6 L 0 6 L 0 4.2 L 4 4.2 L 4.2 4.2 L 4.2 0 L 6 0 L 6 6 L 6 6 Z" fill="#FFFFFF"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 629 B

View File

@ -103,13 +103,7 @@ $component-active-bg: $brand-primary !default;
$panel-bg: #212124;
$panel-border-color: $dark-1;
$panel-border: solid 1px $panel-border-color;
$panel-drop-zone-bg: repeating-linear-gradient(
-128deg,
#111,
#111 10px,
#191919 10px,
#222 20px
);
$panel-drop-zone-bg: repeating-linear-gradient(-128deg, #111, #111 10px, #191919 10px, #222 20px);
$panel-header-hover-bg: $dark-4;
$panel-header-menu-hover-bg: $dark-5;
$panel-edit-shadow: 0 -30px 30px -30px $black;
@ -134,8 +128,7 @@ $code-tag-border: lighten($code-tag-bg, 2%);
// cards
$card-background: linear-gradient(135deg, #2f2f32, #262628);
$card-background-hover: linear-gradient(135deg, #343436, #262628);
$card-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1),
1px 1px 0 0 rgba(0, 0, 0, 0.3);
$card-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.3);
// Lists
$list-item-bg: $card-background;
@ -157,6 +150,7 @@ $table-bg-hover: $dark-4; // for hover
$table-border: $dark-3; // table and cell border
$table-bg-odd: $dark-2;
$table-bg-hover: $dark-3;
// Buttons
// -------------------------
@ -188,7 +182,7 @@ $iconContainerBackground: $black;
$btn-divider-left: $dark-4;
$btn-divider-right: $dark-2;
$btn-drag-image: "../img/grab_dark.svg";
$btn-drag-image: '../img/grab_dark.svg';
// Forms
// -------------------------
@ -259,7 +253,7 @@ $navbarDropdownShadow: inset 0px 4px 10px -4px $body-bg;
$navbarButtonBackground: $navbarBackground;
$navbarButtonBackgroundHighlight: $body-bg;
$navbar-button-border: #151515;
$navbar-button-border: #2f2f32;
// Sidemenu
// -------------------------
@ -325,14 +319,10 @@ $tooltipLinkColor: $link-color;
$graph-tooltip-bg: $dark-1;
// images
$checkboxImageUrl: "../img/checkbox.png";
$checkboxImageUrl: '../img/checkbox.png';
// info box
$info-box-background: linear-gradient(
100deg,
$blue-dark,
darken($blue-dark, 5%)
);
$info-box-background: linear-gradient(100deg, $blue-dark, darken($blue-dark, 5%));
$info-box-color: $gray-4;
// footer

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