diff --git a/.editorconfig b/.editorconfig index 84bbaf8a420..cbde126ca6c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index da4fe019959..b1c17d684b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 00000000000..4d5f6ca0a46 --- /dev/null +++ b/Gopkg.lock @@ -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 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 00000000000..144dcc1e4af --- /dev/null +++ b/Gopkg.toml @@ -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 diff --git a/README.md b/README.md index 069958d9031..80a80b694c1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.go b/build.go index 1c61c72d5dc..d55244246ff 100644 --- a/build.go +++ b/build.go @@ -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") } diff --git a/conf/defaults.ini b/conf/defaults.ini index 4e2929096a6..5439a373bbb 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -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 diff --git a/conf/sample.ini b/conf/sample.ini index d297d2db66a..59bd5845ffe 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -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 diff --git a/docker/blocks/mysql/docker-compose.yaml b/docker/blocks/mysql/docker-compose.yaml index 6eee158ac43..f7881e66539 100644 --- a/docker/blocks/mysql/docker-compose.yaml +++ b/docker/blocks/mysql/docker-compose.yaml @@ -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 + diff --git a/docker/blocks/mysql_tests/docker-compose.yaml b/docker/blocks/mysql_tests/docker-compose.yaml index c6c3097d463..3c59b66b5ac 100644 --- a/docker/blocks/mysql_tests/docker-compose.yaml +++ b/docker/blocks/mysql_tests/docker-compose.yaml @@ -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 diff --git a/docker/blocks/postgres/docker-compose.yaml b/docker/blocks/postgres/docker-compose.yaml index eced00aafeb..566df7b8877 100644 --- a/docker/blocks/postgres/docker-compose.yaml +++ b/docker/blocks/postgres/docker-compose.yaml @@ -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 \ No newline at end of file diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 9f94810ff4d..ae68e39c26d 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -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. diff --git a/docs/sources/installation/behind_proxy.md b/docs/sources/installation/behind_proxy.md index f151a66662e..f1a00a5b1cc 100644 --- a/docs/sources/installation/behind_proxy.md +++ b/docs/sources/installation/behind_proxy.md @@ -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. diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 01a26f38b9a..5f458a48aeb 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -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:///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_secret = + scopes = openid profile email + auth_url = https:///authorize + token_url = https:///oauth/token + api_url = https:///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:///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 = + client_secret = + scopes = openid email name + auth_url = https://login.microsoftonline.com//oauth2/authorize + token_url = https://login.microsoftonline.com//oauth2/token + api_url = + team_ids = + allowed_organizations = + ``` +
## [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] diff --git a/docs/sources/project/building_from_source.md b/docs/sources/project/building_from_source.md index dba04164d00..13d71e8dcf4 100644 --- a/docs/sources/project/building_from_source.md +++ b/docs/sources/project/building_from_source.md @@ -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 diff --git a/package.json b/package.json index 01241637d46..06a83ecac18 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 0366b9aedad..b911780913d 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -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, diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index b63e3bfd89f..22fcafc8b29 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -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") } diff --git a/pkg/components/imguploader/imguploader.go b/pkg/components/imguploader/imguploader.go index 383d2c6d311..52a31f9f606 100644 --- a/pkg/components/imguploader/imguploader.go +++ b/pkg/components/imguploader/imguploader.go @@ -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 != "" { diff --git a/pkg/components/imguploader/imguploader_test.go b/pkg/components/imguploader/imguploader_test.go index d5008c9ae9f..b0311dac975 100644 --- a/pkg/components/imguploader/imguploader_test.go +++ b/pkg/components/imguploader/imguploader_test.go @@ -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) + }) }) } diff --git a/pkg/components/imguploader/localuploader.go b/pkg/components/imguploader/localuploader.go new file mode 100644 index 00000000000..022d67122a7 --- /dev/null +++ b/pkg/components/imguploader/localuploader.go @@ -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 +} diff --git a/pkg/components/imguploader/localuploader_test.go b/pkg/components/imguploader/localuploader_test.go new file mode 100644 index 00000000000..0d8c0df0e57 --- /dev/null +++ b/pkg/components/imguploader/localuploader_test.go @@ -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") + }) +} diff --git a/pkg/metrics/graphitebridge/graphite.go b/pkg/metrics/graphitebridge/graphite.go index 8504a908d9a..68fb544fc7c 100644 --- a/pkg/metrics/graphitebridge/graphite.go +++ b/pkg/metrics/graphitebridge/graphite.go @@ -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" diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 4d7de98f2ea..4d4a11d0faa 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -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 { diff --git a/pkg/models/stats.go b/pkg/models/stats.go index 0d982c3f4bd..e132d88c030 100644 --- a/pkg/models/stats.go +++ b/pkg/models/stats.go @@ -8,6 +8,7 @@ type SystemStats struct { Orgs int64 Playlists int64 Alerts int64 + Stars int64 } type DataSourceStats struct { diff --git a/pkg/plugins/datasource/tsdb/grpc.go b/pkg/plugins/datasource/tsdb/grpc.go deleted file mode 100644 index c9d696f236e..00000000000 --- a/pkg/plugins/datasource/tsdb/grpc.go +++ /dev/null @@ -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) -} diff --git a/pkg/plugins/datasource/tsdb/interface.go b/pkg/plugins/datasource/tsdb/interface.go deleted file mode 100644 index 740718cc4a9..00000000000 --- a/pkg/plugins/datasource/tsdb/interface.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/datasource/tsdb/datasource_plugin_wrapper.go b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go similarity index 58% rename from pkg/plugins/datasource/tsdb/datasource_plugin_wrapper.go rename to pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go index e13bfab9cc4..91511f96685 100644 --- a/pkg/plugins/datasource/tsdb/datasource_plugin_wrapper.go +++ b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go @@ -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) diff --git a/pkg/plugins/datasource/tsdb/datasource_plugin_wrapper_test.go b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go similarity index 63% rename from pkg/plugins/datasource/tsdb/datasource_plugin_wrapper_test.go rename to pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go index 84a682854f5..c85cb6cea52 100644 --- a/pkg/plugins/datasource/tsdb/datasource_plugin_wrapper_test.go +++ b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go @@ -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) } diff --git a/pkg/plugins/datasource_plugin.go b/pkg/plugins/datasource_plugin.go index be39e65b2e4..37ce175efe4 100644 --- a/pkg/plugins/datasource_plugin.go +++ b/pkg/plugins/datasource_plugin.go @@ -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 diff --git a/pkg/services/alerting/notifiers/email.go b/pkg/services/alerting/notifiers/email.go index f84dc886d83..095f7c7156a 100644 --- a/pkg/services/alerting/notifiers/email.go +++ b/pkg/services/alerting/notifiers/email.go @@ -20,7 +20,7 @@ func init() { OptionsTemplate: `

Email addresses

- +
You can enter multiple email addresses using a ";" separator diff --git a/pkg/services/alerting/notifiers/opsgenie.go b/pkg/services/alerting/notifiers/opsgenie.go index 1a812f49ca3..82be9d1df4e 100644 --- a/pkg/services/alerting/notifiers/opsgenie.go +++ b/pkg/services/alerting/notifiers/opsgenie.go @@ -23,6 +23,10 @@ func init() { API Key
+
+ Alert API Url + +
? - ) 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 } diff --git a/pkg/social/generic_oauth.go b/pkg/social/generic_oauth.go index ec3c445ebb2..2411e0006a2 100644 --- a/pkg/social/generic_oauth.go +++ b/pkg/social/generic_oauth.go @@ -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 +} diff --git a/pkg/social/github_oauth.go b/pkg/social/github_oauth.go index 7e348e2363a..6f00cd2cd9e 100644 --- a/pkg/social/github_oauth.go +++ b/pkg/social/github_oauth.go @@ -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"` diff --git a/pkg/social/google_oauth.go b/pkg/social/google_oauth.go index c44720fbe21..e9ab08305f6 100644 --- a/pkg/social/google_oauth.go +++ b/pkg/social/google_oauth.go @@ -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"` diff --git a/pkg/social/grafana_com_oauth.go b/pkg/social/grafana_com_oauth.go index 9cc87d4b41c..d3614520d61 100644 --- a/pkg/social/grafana_com_oauth.go +++ b/pkg/social/grafana_com_oauth.go @@ -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"` diff --git a/pkg/social/social.go b/pkg/social/social.go index d40c0a0c965..b763e2d71b2 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -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()), diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index befaf8e1276..251527ab4e5 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -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 diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index 255b343a33a..bf87e7b7d41 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -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) + } diff --git a/pkg/tsdb/models/tsdb_plugin.proto b/pkg/tsdb/models/tsdb_plugin.proto deleted file mode 100644 index e870740ff68..00000000000 --- a/pkg/tsdb/models/tsdb_plugin.proto +++ /dev/null @@ -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 tags = 2; - repeated Point points = 3; -} - -message Point { - int64 timestamp = 1; - double value = 2; -} - -service TsdbPlugin { - rpc Query(TsdbQuery) returns (Response); -} diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index e5c6b92f245..cf965b17a08 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -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 } diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index 55def0b4129..fe2c82223d2 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -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) }) }) } diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 6e68e7c8d2f..c3311e39659 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -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' }], + ]); } diff --git a/public/app/core/components/TagFilter/TagBadge.tsx b/public/app/core/components/TagFilter/TagBadge.tsx new file mode 100644 index 00000000000..e5c2e357a58 --- /dev/null +++ b/public/app/core/components/TagFilter/TagBadge.tsx @@ -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 { + 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 && {`(${count})`}; + + return ( + + {removeIcon && } + {label} {countLabel} + + ); + } +} diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx new file mode 100644 index 00000000000..0b6058f3dd2 --- /dev/null +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -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 { + 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 ( +
+
+ +
+ +
+ ); + } +} diff --git a/public/app/core/components/TagFilter/TagOption.tsx b/public/app/core/components/TagFilter/TagOption.tsx new file mode 100644 index 00000000000..402544dd5f3 --- /dev/null +++ b/public/app/core/components/TagFilter/TagOption.tsx @@ -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 { + 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 ( + + ); + } +} diff --git a/public/app/core/components/TagFilter/TagValue.tsx b/public/app/core/components/TagFilter/TagValue.tsx new file mode 100644 index 00000000000..2e7819951f2 --- /dev/null +++ b/public/app/core/components/TagFilter/TagValue.tsx @@ -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 { + 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 ; + } +} diff --git a/public/app/core/components/form_dropdown/form_dropdown.ts b/public/app/core/components/form_dropdown/form_dropdown.ts index 1fa1dea4338..cf9c6b5efe0 100644 --- a/public/app/core/components/form_dropdown/form_dropdown.ts +++ b/public/app/core/components/form_dropdown/form_dropdown.ts @@ -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"> + tabindex="1" + ng-click="ctrl.open()" + give-focus="ctrl.focus" + ng-bind-html="ctrl.display || ' '"> `; @@ -250,6 +261,8 @@ export function formDropdownDirective() { allowCustom: '@', labelMode: '@', lookupText: '@', + placeholder: '@', + startOpen: '@', }, }; } diff --git a/public/app/core/components/search/search.html b/public/app/core/components/search/search.html index 3b83284757e..e0106740d3a 100644 --- a/public/app/core/components/search/search.html +++ b/public/app/core/components/search/search.html @@ -12,8 +12,7 @@ ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.search()" - ng-blur="ctrl.searchInputBlur()" - /> + />
@@ -31,37 +30,28 @@
- - - - - - - - - - - - - - - - - - - - - - +
+
+ + Filter by: + + Clear + +
+ + + +
- - New dashboard + New dashboard - - New folder + New folder + + + Import dashboard Find dashboards on Grafana.com diff --git a/public/app/core/components/search/search.ts b/public/app/core/components/search/search.ts index dcb88d7d32d..04b77e7b7fe 100644 --- a/public/app/core/components/search/search.ts +++ b/public/app/core/components/search/search.ts @@ -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() { diff --git a/public/app/core/directives/dropdown_typeahead.js b/public/app/core/directives/dropdown_typeahead.js index b44e953785e..25772b4638a 100644 --- a/public/app/core/directives/dropdown_typeahead.js +++ b/public/app/core/directives/dropdown_typeahead.js @@ -12,7 +12,7 @@ function (_, $, coreModule) { ' class="gf-form-input input-medium tight-form-input"' + ' spellcheck="false" style="display:none">'; - var buttonTemplate = ''; diff --git a/public/app/core/directives/tags.ts b/public/app/core/directives/tags.ts index b5020b71fc7..6b36ee7d503 100644 --- a/public/app/core/directives/tags.ts +++ b/public/app/core/directives/tags.ts @@ -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); } diff --git a/public/app/core/services/segment_srv.js b/public/app/core/services/segment_srv.js index d615525988a..6c126da1acd 100644 --- a/public/app/core/services/segment_srv.js +++ b/public/app/core/services/segment_srv.js @@ -106,10 +106,6 @@ function (angular, _, coreModule) { return new MetricSegment({fake: true, html: '', type: 'plus-button', cssClass: 'query-part' }); }; - this.newSelectTagValue = function() { - return new MetricSegment({value: 'select tag value', fake: true}); - }; - }); }); diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts index ec4837fe31d..e7ae645d4e4 100644 --- a/public/app/core/utils/kbn.ts +++ b/public/app/core/utils/kbn.ts @@ -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' }, ], }, { diff --git a/public/app/core/utils/tags.ts b/public/app/core/utils/tags.ts new file mode 100644 index 00000000000..678fd8c94be --- /dev/null +++ b/public/app/core/utils/tags.ts @@ -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, +}; diff --git a/public/app/core/utils/url.ts b/public/app/core/utils/url.ts new file mode 100644 index 00000000000..0b629768b92 --- /dev/null +++ b/public/app/core/utils/url.ts @@ -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, '+'); +} diff --git a/public/app/features/dashboard/dashboard_srv.ts b/public/app/features/dashboard/dashboard_srv.ts index b546c3f2bf4..ee7ba3d6aac 100644 --- a/public/app/features/dashboard/dashboard_srv.ts +++ b/public/app/features/dashboard/dashboard_srv.ts @@ -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) diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/dashnav/dashnav.ts index 2f1a2936419..17a5bc61696 100644 --- a/public/app/features/dashboard/dashnav/dashnav.ts +++ b/public/app/features/dashboard/dashnav/dashnav.ts @@ -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; } diff --git a/public/app/features/dashboard/folder_picker/folder_picker.html b/public/app/features/dashboard/folder_picker/folder_picker.html index 8722a19a5f8..17b7512d35d 100644 --- a/public/app/features/dashboard/folder_picker/folder_picker.html +++ b/public/app/features/dashboard/folder_picker/folder_picker.html @@ -9,29 +9,21 @@
-
- -
-
-
-
-
diff --git a/public/app/features/dashboard/save_as_modal.ts b/public/app/features/dashboard/save_as_modal.ts index 236b1f07b56..04b029cbb4c 100644 --- a/public/app/features/dashboard/save_as_modal.ts +++ b/public/app/features/dashboard/save_as_modal.ts @@ -13,7 +13,7 @@ const template = ` -