diff --git a/.circleci/config.yml b/.circleci/config.yml index 3dd8f800b94..dba6c5f8bd0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,7 +19,7 @@ version: 2 jobs: mysql-integration-test: docker: - - image: circleci/golang:1.11 + - image: circleci/golang:1.11.4 - image: circleci/mysql:5.6-ram environment: MYSQL_ROOT_PASSWORD: rootpass @@ -39,7 +39,7 @@ jobs: postgres-integration-test: docker: - - image: circleci/golang:1.11 + - image: circleci/golang:1.11.4 - image: circleci/postgres:9.3-ram environment: POSTGRES_USER: grafanatest @@ -74,7 +74,7 @@ jobs: gometalinter: docker: - - image: circleci/golang:1.11 + - image: circleci/golang:1.11.4 environment: # we need CGO because of go-sqlite3 CGO_ENABLED: 1 @@ -117,7 +117,7 @@ jobs: test-backend: docker: - - image: circleci/golang:1.11 + - image: circleci/golang:1.11.4 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -175,7 +175,7 @@ jobs: build: docker: - - image: grafana/build-container:1.2.1 + - image: grafana/build-container:1.2.2 working_directory: /go/src/github.com/grafana/grafana steps: - checkout diff --git a/.gitignore b/.gitignore index 05ae4907e89..d599f762840 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,4 @@ debug.test /devenv/bulk_alerting_dashboards/*.json /scripts/build/release_publisher/release_publisher +*.patch diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b78a30f68..5b420dcc374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### New Features * **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster) +* **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109) ### Minor @@ -11,6 +12,13 @@ * **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi) * **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh) * **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK) +* **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483) +* **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548) +* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard) +* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas) + +### Bug fixes +* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486) # 5.4.2 (2018-12-13) diff --git a/Dockerfile b/Dockerfile index 65260e1a6a8..c3af89b6092 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Golang build container -FROM golang:1.11 +FROM golang:1.11.4 WORKDIR $GOPATH/src/github.com/grafana/grafana diff --git a/Gopkg.lock b/Gopkg.lock index 1e06d4cbc5d..d7795cbd6ba 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -2,39 +2,30 @@ [[projects]] - digest = "1:f8ad8a53fa865a70efbe215b0ca34735523f50ea39e0efde319ab6fc80089b44" name = "cloud.google.com/go" packages = ["compute/metadata"] - pruneopts = "NUT" revision = "056a55f54a6cc77b440b31a56a5e7c3982d32811" version = "v0.22.0" [[projects]] - digest = "1:167b6f65a6656de568092189ae791253939f076df60231fdd64588ac703892a1" name = "github.com/BurntSushi/toml" packages = ["."] - pruneopts = "NUT" revision = "b26d9c308763d68093482582cea63d69be07a0f0" version = "v0.3.0" [[projects]] branch = "master" - digest = "1:7d23e6e1889b8bb4bbb37a564708fdab4497ce232c3a99d66406c975b642a6ff" name = "github.com/Unknwon/com" packages = ["."] - pruneopts = "NUT" revision = "7677a1d7c1137cd3dd5ba7a076d0c898a1ef4520" [[projects]] branch = "master" - digest = "1:1610787cd9726e29d8fecc2a80e43e4fced008a1f560fec6688fc4d946f17835" name = "github.com/VividCortex/mysqlerr" packages = ["."] - pruneopts = "NUT" revision = "6c6b55f8796f578c870b7e19bafb16103bc40095" [[projects]] - digest = "1:58294d68772aab5a8941b7d5d228eff7cccf63f895e914bc9bc38fda80471ea5" name = "github.com/aws/aws-sdk-go" packages = [ "aws", @@ -70,523 +61,399 @@ "service/ec2", "service/ec2/ec2iface", "service/s3", - "service/sts", + "service/sts" ] - pruneopts = "NUT" revision = "fde4ded7becdeae4d26bf1212916aabba79349b4" version = "v1.14.12" [[projects]] branch = "master" - digest = "1:79cad073c7be02632d3fa52f62486848b089f560db1e94536de83a408c0f4726" name = "github.com/benbjohnson/clock" packages = ["."] - pruneopts = "NUT" revision = "7dc76406b6d3c05b5f71a86293cbcf3c4ea03b19" [[projects]] branch = "master" - digest = "1:707ebe952a8b3d00b343c01536c79c73771d100f63ec6babeaed5c79e2b8a8dd" name = "github.com/beorn7/perks" packages = ["quantile"] - pruneopts = "NUT" revision = "3a771d992973f24aa725d07868b467d1ddfceafb" [[projects]] branch = "master" - digest = "1:433a2ff0ef4e2f8634614aab3174783c5ff80120b487712db96cc3712f409583" name = "github.com/bmizerany/assert" packages = ["."] - pruneopts = "NUT" revision = "b7ed37b82869576c289d7d97fb2bbd8b64a0cb28" [[projects]] branch = "master" - digest = "1:d8f9145c361920507a4f85ffb7f70b96beaedacba2ce8c00aa663adb08689d3e" name = "github.com/bradfitz/gomemcache" packages = ["memcache"] - pruneopts = "NUT" revision = "1952afaa557dc08e8e0d89eafab110fb501c1a2b" [[projects]] branch = "master" - digest = "1:8ecb89af7dfe3ac401bdb0c9390b134ef96a97e85f732d2b0604fb7b3977839f" name = "github.com/codahale/hdrhistogram" packages = ["."] - pruneopts = "NUT" revision = "3a0bb77429bd3a61596f5e8a3172445844342120" [[projects]] - digest = "1:5dba68a1600a235630e208cb7196b24e58fcbb77bb7a6bec08fcd23f081b0a58" name = "github.com/codegangsta/cli" packages = ["."] - pruneopts = "NUT" revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1" version = "v1.20.0" [[projects]] - digest = "1:a2c1d0e43bd3baaa071d1b9ed72c27d78169b2b269f71c105ac4ba34b1be4a39" name = "github.com/davecgh/go-spew" packages = ["spew"] - pruneopts = "NUT" revision = "346938d642f2ec3594ed81d874461961cd0faa76" version = "v1.1.0" [[projects]] - digest = "1:1b318d2dd6cea8a1a8d8ec70348852303bd3e491df74e8bca6e32eb5a4d06970" name = "github.com/denisenkom/go-mssqldb" packages = [ ".", - "internal/cp", + "internal/cp" ] - pruneopts = "NUT" revision = "270bc3860bb94dd3a3ffd047377d746c5e276726" [[projects]] branch = "master" - digest = "1:2da5f11ad66ff01a27a5c3dba4620b7eee2327be75b32c9ee9f87c9a8001ecbf" name = "github.com/facebookgo/inject" packages = ["."] - pruneopts = "NUT" revision = "cc1aa653e50f6a9893bcaef89e673e5b24e1e97b" [[projects]] branch = "master" - digest = "1:1108df7f658c90db041e0d6174d55be689aaeb0585913b9c3c7aab51a3a6b2b1" name = "github.com/facebookgo/structtag" packages = ["."] - pruneopts = "NUT" revision = "217e25fb96916cc60332e399c9aa63f5c422ceed" [[projects]] - digest = "1:ade392a843b2035effb4b4a2efa2c3bab3eb29b992e98bacf9c898b0ecb54e45" name = "github.com/fatih/color" packages = ["."] - pruneopts = "NUT" revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4" version = "v1.7.0" [[projects]] - digest = "1:e05711632e1515319b014e8fe4cbe1d30ab024c473403f60cf0fdeb4c586a474" name = "github.com/go-ini/ini" packages = ["."] - pruneopts = "NUT" revision = "6529cf7c58879c08d927016dde4477f18a0634cb" version = "v1.36.0" -[[projects]] - digest = "1:7e1c00b9959544fa1ccca7cf0407a5b29ac6d5201059c4fac6f599cb99bfd24d" - name = "github.com/go-ldap/ldap" - packages = ["."] - pruneopts = "NUT" - revision = "bb7a9ca6e4fbc2129e3db588a34bc970ffe811a9" - version = "v2.5.1" - [[projects]] branch = "master" - digest = "1:682a0aca743a1a4a36697f3d7f86c0ed403c4e3a780db9935f633242855eac9c" name = "github.com/go-macaron/binding" packages = ["."] - pruneopts = "NUT" revision = "ac54ee249c27dca7e76fad851a4a04b73bd1b183" [[projects]] branch = "master" - digest = "1:6326b27f8e0c8e135c8674ddbc619fae879664ac832e8e6fa6a23ce0d279ed4d" name = "github.com/go-macaron/gzip" packages = ["."] - pruneopts = "NUT" revision = "cad1c6580a07c56f5f6bc52d66002a05985c5854" [[projects]] branch = "master" - digest = "1:fb8711b648d1ff03104fc1d9593a13cb1d5120be7ba2b01641c14ccae286a9e3" name = "github.com/go-macaron/inject" packages = ["."] - pruneopts = "NUT" revision = "d8a0b8677191f4380287cfebd08e462217bac7ad" [[projects]] branch = "master" - digest = "1:21577aafe885f088e8086a3415f154c63c0b7ce956a6994df2ac5776bc01b7e3" name = "github.com/go-macaron/session" packages = [ ".", "memcache", "postgres", - "redis", + "redis" ] - pruneopts = "NUT" revision = "068d408f9c54c7fa7fcc5e2bdd3241ab21280c9e" [[projects]] - digest = "1:fddd4bada6100d6fc49a9f32f18ba5718db45a58e4b00aa6377e1cfbf06af34f" name = "github.com/go-sql-driver/mysql" packages = ["."] - pruneopts = "NUT" revision = "2cc627ac8defc45d65066ae98f898166f580f9a4" [[projects]] - digest = "1:a1efdbc2762667c8a41cbf02b19a0549c846bf2c1d08cad4f445e3344089f1f0" name = "github.com/go-stack/stack" packages = ["."] - pruneopts = "NUT" revision = "259ab82a6cad3992b4e21ff5cac294ccb06474bc" version = "v1.7.0" [[projects]] - digest = "1:b9d4f09cdaaa9e7dca5ed0b501ca5519eb2168dd01fc5d174c54edfe42a7d5ed" name = "github.com/go-xorm/builder" packages = ["."] - pruneopts = "NUT" revision = "bad0a612f0d6277b953910822ab5dfb30dd18237" version = "v0.2.0" [[projects]] - digest = "1:30fb106b0cd6d64ea6fccded579c8f7d788460092f885fcc8f3edd02fc2085a4" name = "github.com/go-xorm/core" packages = ["."] - pruneopts = "NUT" revision = "da1adaf7a28ca792961721a34e6e04945200c890" version = "v0.5.7" [[projects]] - digest = "1:007d1354e4f44e6a393337e7623bcf911dfe75d6ef30fb767a6a0b65d302f5ed" name = "github.com/go-xorm/xorm" packages = ["."] - pruneopts = "NUT" revision = "1933dd69e294c0a26c0266637067f24dbb25770c" version = "v0.6.4" [[projects]] branch = "master" - digest = "1:ffbb19fb66f140b5ea059428d1f84246a055d1bc3d9456c1e5c3d143611f03d0" name = "github.com/golang/protobuf" packages = [ "proto", "ptypes", "ptypes/any", "ptypes/duration", - "ptypes/timestamp", + "ptypes/timestamp" ] - pruneopts = "NUT" revision = "927b65914520a8b7d44f5c9057611cfec6b2e2d0" [[projects]] branch = "master" - digest = "1:f14d1b50e0075fb00177f12a96dd7addf93d1e2883c25befd17285b779549795" name = "github.com/gopherjs/gopherjs" packages = ["js"] - pruneopts = "NUT" revision = "8dffc02ea1cb8398bb73f30424697c60fcf8d4c5" [[projects]] - digest = "1:3b708ebf63bfa9ba3313bedb8526bc0bb284e51474e65e958481476a9d4a12aa" name = "github.com/gorilla/websocket" packages = ["."] - pruneopts = "NUT" revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b" version = "v1.2.0" [[projects]] - digest = "1:4e771d1c6e15ca4516ad971c34205c822b5cff2747179679d7b321e4e1bfe431" name = "github.com/gosimple/slug" packages = ["."] - pruneopts = "NUT" revision = "e9f42fa127660e552d0ad2b589868d403a9be7c6" version = "v1.1.1" [[projects]] branch = "master" - digest = "1:08e53c69cd267ef7d71eeae5d953153d0d2bc1b8e0b498731fe9acaead7001b6" name = "github.com/grafana/grafana-plugin-model" packages = [ "go/datasource", - "go/renderer", + "go/renderer" ] - pruneopts = "NUT" revision = "84176c64269d8060f99e750ee8aba6f062753336" [[projects]] branch = "master" - digest = "1:58ba5285227b0f635652cd4aa82c4cfd00b590191eadd823462f0c9f64e3ae07" name = "github.com/hashicorp/go-hclog" packages = ["."] - pruneopts = "NUT" revision = "69ff559dc25f3b435631604f573a5fa1efdb6433" [[projects]] - digest = "1:532090ffc3b05a7e4c0229dd2698d79149f2e0683df993224a8b202f607fb605" name = "github.com/hashicorp/go-plugin" packages = ["."] - pruneopts = "NUT" revision = "e8d22c780116115ae5624720c9af0c97afe4f551" [[projects]] branch = "master" - digest = "1:8925116d1edcd85fc0c014e1aa69ce12892489b48ee633a605c46d893b8c151f" name = "github.com/hashicorp/go-version" packages = ["."] - pruneopts = "NUT" revision = "23480c0665776210b5fbbac6eaaee40e3e6a96b7" [[projects]] branch = "master" - digest = "1:8deb0c5545c824dfeb0ac77ab8eb67a3d541eab76df5c85ce93064ef02d44cd0" name = "github.com/hashicorp/yamux" packages = ["."] - pruneopts = "NUT" revision = "7221087c3d281fda5f794e28c2ea4c6e4d5c4558" [[projects]] - digest = "1:efbe016b6d198cf44f1db0ed2fbdf1b36ebf1f6956cc9b76d6affa96f022d368" name = "github.com/inconshreveable/log15" packages = ["."] - pruneopts = "NUT" revision = "0decfc6c20d9ca0ad143b0e89dcaa20f810b4fb3" version = "v2.13" [[projects]] - digest = "1:ac6d01547ec4f7f673311b4663909269bfb8249952de3279799289467837c3cc" name = "github.com/jmespath/go-jmespath" packages = ["."] - pruneopts = "NUT" revision = "0b12d6b5" [[projects]] - digest = "1:6ddab442e52381bab82fb6c07ef3f4b565ff7ec4b8fae96d8dd4b8573a460597" name = "github.com/jtolds/gls" packages = ["."] - pruneopts = "NUT" revision = "77f18212c9c7edc9bd6a33d383a7b545ce62f064" version = "v4.2.1" [[projects]] - digest = "1:1da1796a71eb70f1e3e085984d044f67840bb0326816ec8276231aa87b1b9fc3" name = "github.com/klauspost/compress" packages = [ "flate", - "gzip", + "gzip" ] - pruneopts = "NUT" revision = "6c8db69c4b49dd4df1fff66996cf556176d0b9bf" version = "v1.2.1" [[projects]] - digest = "1:5e55a8699c9ff7aba1e4c8952aeda209685d88d4cb63a8766c338e333b8e65d6" name = "github.com/klauspost/cpuid" packages = ["."] - pruneopts = "NUT" revision = "ae7887de9fa5d2db4eaa8174a7eff2c1ac00f2da" version = "v1.1" [[projects]] - digest = "1:b95da1293525625ef6f07be79d537b9bf2ecd7901efcf9a92193edafbd55b9ef" name = "github.com/klauspost/crc32" packages = ["."] - pruneopts = "NUT" revision = "cb6bfca970f6908083f26f39a79009d608efd5cd" version = "v1.1" [[projects]] - digest = "1:7b21c7fc5551b46d1308b4ffa9e9e49b66c7a8b0ba88c0130474b0e7a20d859f" name = "github.com/kr/pretty" packages = ["."] - pruneopts = "NUT" revision = "73f6ac0b30a98e433b289500d779f50c1a6f0712" version = "v0.1.0" [[projects]] - digest = "1:c3a7836b5904db0f8b609595b619916a6831cb35b8b714aec39f96d00c6155d8" name = "github.com/kr/text" packages = ["."] - pruneopts = "NUT" revision = "e2ffdb16a802fe2bb95e2e35ff34f0e53aeef34f" version = "v0.1.0" [[projects]] branch = "master" - digest = "1:7a1e592f0349d56fac8ce47f28469e4e7f4ce637cb26f40c88da9dff25db1c98" name = "github.com/lib/pq" packages = [ ".", - "oid", + "oid" ] - pruneopts = "NUT" revision = "d34b9ff171c21ad295489235aec8b6626023cd04" [[projects]] - digest = "1:08c231ec84231a7e23d67e4b58f975e1423695a32467a362ee55a803f9de8061" name = "github.com/mattn/go-colorable" packages = ["."] - pruneopts = "NUT" revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" version = "v0.0.9" [[projects]] - digest = "1:bc4f7eec3b7be8c6cb1f0af6c1e3333d5bb71072951aaaae2f05067b0803f287" name = "github.com/mattn/go-isatty" packages = ["."] - pruneopts = "NUT" revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" version = "v0.0.3" [[projects]] - digest = "1:536979f1c56397dbf91c2785159b37dec37e35d3bffa3cd1cfe66d25f51f8088" name = "github.com/mattn/go-sqlite3" packages = ["."] - pruneopts = "NUT" revision = "323a32be5a2421b8c7087225079c6c900ec397cd" version = "v1.7.0" [[projects]] - digest = "1:5985ef4caf91ece5d54817c11ea25f182697534f8ae6521eadcd628c142ac4b6" name = "github.com/matttproud/golang_protobuf_extensions" packages = ["pbutil"] - pruneopts = "NUT" revision = "3247c84500bff8d9fb6d579d800f20b3e091582c" version = "v1.0.0" [[projects]] branch = "master" - digest = "1:18b773b92ac82a451c1276bd2776c1e55ce057ee202691ab33c8d6690efcc048" name = "github.com/mitchellh/go-testing-interface" packages = ["."] - pruneopts = "NUT" revision = "a61a99592b77c9ba629d254a693acffaeb4b7e28" [[projects]] - digest = "1:3b517122f3aad1ecce45a630ea912b3092b4729f25532a911d0cb2935a1f9352" name = "github.com/oklog/run" packages = ["."] - pruneopts = "NUT" revision = "4dadeb3030eda0273a12382bb2348ffc7c9d1a39" version = "v1.0.0" [[projects]] - digest = "1:7da29c22bcc5c2ffb308324377dc00b5084650348c2799e573ed226d8cc9faf0" name = "github.com/opentracing/opentracing-go" packages = [ ".", "ext", - "log", + "log" ] - pruneopts = "NUT" revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" version = "v1.0.2" [[projects]] - digest = "1:748946761cf99c8b73cef5a3c0ee3e040859dd713a20cece0d0e0dc04e6ceca7" name = "github.com/patrickmn/go-cache" packages = ["."] - pruneopts = "NUT" revision = "a3647f8e31d79543b2d0f0ae2fe5c379d72cedc0" version = "v2.1.0" [[projects]] - digest = "1:5cf3f025cbee5951a4ee961de067c8a89fc95a5adabead774f82822efabab121" name = "github.com/pkg/errors" packages = ["."] - pruneopts = "NUT" revision = "645ef00459ed84a119197bfb8d8205042c6df63d" version = "v0.8.0" [[projects]] - digest = "1:4759bed95e3a52febc18c071db28790a5c6e9e106ee201a37add6f6a056f8f9c" name = "github.com/prometheus/client_golang" packages = [ "api", "api/prometheus/v1", "prometheus", - "prometheus/promhttp", + "prometheus/promhttp" ] - pruneopts = "NUT" revision = "967789050ba94deca04a5e84cce8ad472ce313c1" version = "v0.9.0-pre1" [[projects]] branch = "master" - digest = "1:32d10bdfa8f09ecf13598324dba86ab891f11db3c538b6a34d1c3b5b99d7c36b" name = "github.com/prometheus/client_model" packages = ["go"] - pruneopts = "NUT" revision = "99fa1f4be8e564e8a6b613da7fa6f46c9edafc6c" [[projects]] branch = "master" - digest = "1:768b555b86742de2f28beb37f1dedce9a75f91f871d75b5717c96399c1a78c08" name = "github.com/prometheus/common" packages = [ "expfmt", "internal/bitbucket.org/ww/goautoneg", - "model", + "model" ] - pruneopts = "NUT" revision = "d811d2e9bf898806ecfb6ef6296774b13ffc314c" [[projects]] branch = "master" - digest = "1:c4a213a8d73fbb0b13f717ba7996116602ef18ecb42b91d77405877914cb0349" name = "github.com/prometheus/procfs" packages = [ ".", "internal/util", "nfs", - "xfs", + "xfs" ] - pruneopts = "NUT" revision = "8b1c2da0d56deffdbb9e48d4414b4e674bd8083e" [[projects]] branch = "master" - digest = "1:16e2136a67ec44aa2d1d6b0fd65394b3c4a8b2a1b6730c77967f7b7b06b179b2" name = "github.com/rainycape/unidecode" packages = ["."] - pruneopts = "NUT" revision = "cb7f23ec59bec0d61b19c56cd88cee3d0cc1870c" [[projects]] - digest = "1:d917313f309bda80d27274d53985bc65651f81a5b66b820749ac7f8ef061fd04" name = "github.com/sergi/go-diff" packages = ["diffmatchpatch"] - pruneopts = "NUT" revision = "1744e2970ca51c86172c8190fadad617561ed6e7" version = "v1.0.0" [[projects]] - digest = "1:1f0b284a6858827de4c27c66b49b2b25df3e16b031c2b57b7892273131e7dd2b" name = "github.com/smartystreets/assertions" packages = [ ".", "internal/go-render/render", - "internal/oglematchers", + "internal/oglematchers" ] - pruneopts = "NUT" revision = "7678a5452ebea5b7090a6b163f844c133f523da2" version = "1.8.3" [[projects]] - digest = "1:7efd0b2309cdd6468029fa30c808c50a820c9344df07e1a4bbdaf18f282907aa" name = "github.com/smartystreets/goconvey" packages = [ "convey", "convey/gotest", - "convey/reporting", + "convey/reporting" ] - pruneopts = "NUT" revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857" version = "1.6.3" [[projects]] branch = "master" - digest = "1:a66add8dd963bfc72649017c1b321198f596cb4958cb1a11ff91a1be8691020b" name = "github.com/teris-io/shortid" packages = ["."] - pruneopts = "NUT" revision = "771a37caa5cf0c81f585d7b6df4dfc77e0615b5c" [[projects]] - digest = "1:3d48c38e0eca8c66df62379c5ae7a83fb5cd839b94f241354c07ba077da7bc45" name = "github.com/uber/jaeger-client-go" packages = [ ".", @@ -604,55 +471,45 @@ "thrift-gen/jaeger", "thrift-gen/sampling", "thrift-gen/zipkincore", - "utils", + "utils" ] - pruneopts = "NUT" revision = "b043381d944715b469fd6b37addfd30145ca1758" version = "v2.14.0" [[projects]] - digest = "1:0f09db8429e19d57c8346ad76fbbc679341fa86073d3b8fb5ac919f0357d8f4c" name = "github.com/uber/jaeger-lib" packages = ["metrics"] - pruneopts = "NUT" revision = "ed3a127ec5fef7ae9ea95b01b542c47fbd999ce5" version = "v1.5.0" [[projects]] - digest = "1:4c7d12ad3ef47bb03892a52e2609dc9a9cff93136ca9c7d31c00b79fcbc23c7b" name = "github.com/yudai/gojsondiff" packages = [ ".", - "formatter", + "formatter" ] - pruneopts = "NUT" revision = "7b1b7adf999dab73a6eb02669c3d82dbb27a3dd6" version = "1.0.0" [[projects]] branch = "master" - digest = "1:e50cbf8eba568d59b71e08c22c2a77809ed4646ae06ef4abb32b3d3d3fdb1a77" name = "github.com/yudai/golcs" packages = ["."] - pruneopts = "NUT" revision = "ecda9a501e8220fae3b4b600c3db4b0ba22cfc68" [[projects]] branch = "master" - digest = "1:758f363e0dff33cf00b234be2efb12f919d79b42d5ae3909ff9eb69ef2c3cca5" name = "golang.org/x/crypto" packages = [ "ed25519", "ed25519/internal/edwards25519", "md4", - "pbkdf2", + "pbkdf2" ] - pruneopts = "NUT" revision = "1a580b3eff7814fc9b40602fd35256c63b50f491" [[projects]] branch = "master" - digest = "1:0b3fee9c4472022a0982ee0d81e08b3cc3e595f50befd7a4b358b48540d9d8c5" name = "golang.org/x/net" packages = [ "context", @@ -662,43 +519,35 @@ "http2/hpack", "idna", "internal/timeseries", - "trace", + "trace" ] - pruneopts = "NUT" revision = "2491c5de3490fced2f6cff376127c667efeed857" [[projects]] branch = "master" - digest = "1:46bd4e66bfce5e77f08fc2e8dcacc3676e679241ce83d9c150ff0397d686dd44" name = "golang.org/x/oauth2" packages = [ ".", "google", "internal", "jws", - "jwt", + "jwt" ] - pruneopts = "NUT" revision = "cdc340f7c179dbbfa4afd43b7614e8fcadde4269" [[projects]] branch = "master" - digest = "1:39ebcc2b11457b703ae9ee2e8cca0f68df21969c6102cb3b705f76cca0ea0239" name = "golang.org/x/sync" packages = ["errgroup"] - pruneopts = "NUT" revision = "1d60e4601c6fd243af51cc01ddf169918a5407ca" [[projects]] branch = "master" - digest = "1:ec21c5bf0572488865b93e30ffd9132afbf85bec0b20c2d6cbcf349cf2031ed5" name = "golang.org/x/sys" packages = ["unix"] - pruneopts = "NUT" revision = "7c87d13f8e835d2fb3a70a2912c811ed0c1d241b" [[projects]] - digest = "1:e7071ed636b5422cc51c0e3a6cebc229d6c9fffc528814b519a980641422d619" name = "golang.org/x/text" packages = [ "collate", @@ -714,14 +563,12 @@ "unicode/bidi", "unicode/cldr", "unicode/norm", - "unicode/rangetable", + "unicode/rangetable" ] - pruneopts = "NUT" revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" version = "v0.3.0" [[projects]] - digest = "1:dbd5568923513ee74aa626d027e2a8a352cf8f35df41d19f4e34491d1858c38b" name = "google.golang.org/appengine" packages = [ ".", @@ -734,22 +581,18 @@ "internal/modules", "internal/remote_api", "internal/urlfetch", - "urlfetch", + "urlfetch" ] - pruneopts = "NUT" revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" version = "v1.0.0" [[projects]] branch = "master" - digest = "1:3c24554c312721e98fa6b76403e7100cf974eb46b1255ea7fc6471db9a9ce498" name = "google.golang.org/genproto" packages = ["googleapis/rpc/status"] - pruneopts = "NUT" revision = "7bb2a897381c9c5ab2aeb8614f758d7766af68ff" [[projects]] - digest = "1:840b77b6eb539b830bb760b6e30b688ed2ff484bd83466fce2395835ed9367fe" name = "google.golang.org/grpc" packages = [ ".", @@ -776,167 +619,78 @@ "stats", "status", "tap", - "transport", + "transport" ] - pruneopts = "NUT" revision = "1e2570b1b19ade82d8dbb31bba4e65e9f9ef5b34" version = "v1.11.1" [[projects]] branch = "v3" - digest = "1:1244a9b3856f70d5ffb74bbfd780fc9d47f93f2049fa265c6fb602878f507bf8" name = "gopkg.in/alexcesaro/quotedprintable.v3" packages = ["."] - pruneopts = "NUT" revision = "2caba252f4dc53eaf6b553000885530023f54623" [[projects]] - digest = "1:aea6e9483c167cc6fdf1274c442558c5dda8fd3373372be04d98c79100868da1" name = "gopkg.in/asn1-ber.v1" packages = ["."] - pruneopts = "NUT" revision = "379148ca0225df7a432012b8df0355c2a2063ac0" version = "v1.2" [[projects]] - digest = "1:24bfc2e8bf971485cb5ba0f0e5b08a1b806cca5828134df76b32d1ea50f2ab49" name = "gopkg.in/bufio.v1" packages = ["."] - pruneopts = "NUT" revision = "567b2bfa514e796916c4747494d6ff5132a1dfce" version = "v1" [[projects]] - digest = "1:e05711632e1515319b014e8fe4cbe1d30ab024c473403f60cf0fdeb4c586a474" name = "gopkg.in/ini.v1" packages = ["."] - pruneopts = "NUT" revision = "6529cf7c58879c08d927016dde4477f18a0634cb" version = "v1.36.0" [[projects]] - digest = "1:3b0cf3a465fd07f76e5fc1a9d0783c662dac0de9fc73d713ebe162768fd87b5f" + name = "gopkg.in/ldap.v3" + packages = ["."] + revision = "5c2c0f997205c29de14cb6c35996370c2c5dfab1" + version = "v3" + +[[projects]] name = "gopkg.in/macaron.v1" packages = ["."] - pruneopts = "NUT" revision = "c1be95e6d21e769e44e1ec33cec9da5837861c10" version = "v1.3.1" [[projects]] branch = "v2" - digest = "1:d52332f9e9f2c6343652e13aa3fd40cfd03353520c9a48d90f21215d3012d50f" name = "gopkg.in/mail.v2" packages = ["."] - pruneopts = "NUT" revision = "5bc5c8bb07bd8d2803831fbaf8cbd630fcde2c68" [[projects]] - digest = "1:00126f697efdcab42f07c89ac8bf0095fb2328aef6464e070055154088cea859" name = "gopkg.in/redis.v2" packages = ["."] - pruneopts = "NUT" revision = "e6179049628164864e6e84e973cfb56335748dea" version = "v2.3.2" [[projects]] - digest = "1:a50fabe7a46692dc7c656310add3d517abe7914df02afd151ef84da884605dc8" name = "gopkg.in/square/go-jose.v2" packages = [ ".", "cipher", - "json", + "json" ] - pruneopts = "NUT" revision = "ef984e69dd356202fd4e4910d4d9c24468bdf0b8" version = "v2.1.9" [[projects]] branch = "v2" - digest = "1:7c95b35057a0ff2e19f707173cc1a947fa43a6eb5c4d300d196ece0334046082" name = "gopkg.in/yaml.v2" packages = ["."] - pruneopts = "NUT" revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" [solve-meta] analyzer-name = "dep" analyzer-version = 1 - input-imports = [ - "github.com/BurntSushi/toml", - "github.com/Unknwon/com", - "github.com/VividCortex/mysqlerr", - "github.com/aws/aws-sdk-go/aws", - "github.com/aws/aws-sdk-go/aws/awserr", - "github.com/aws/aws-sdk-go/aws/awsutil", - "github.com/aws/aws-sdk-go/aws/credentials", - "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds", - "github.com/aws/aws-sdk-go/aws/credentials/endpointcreds", - "github.com/aws/aws-sdk-go/aws/defaults", - "github.com/aws/aws-sdk-go/aws/ec2metadata", - "github.com/aws/aws-sdk-go/aws/endpoints", - "github.com/aws/aws-sdk-go/aws/request", - "github.com/aws/aws-sdk-go/aws/session", - "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/aws/aws-sdk-go/service/s3", - "github.com/aws/aws-sdk-go/service/sts", - "github.com/benbjohnson/clock", - "github.com/bmizerany/assert", - "github.com/codegangsta/cli", - "github.com/davecgh/go-spew/spew", - "github.com/denisenkom/go-mssqldb", - "github.com/facebookgo/inject", - "github.com/fatih/color", - "github.com/go-ldap/ldap", - "github.com/go-macaron/binding", - "github.com/go-macaron/gzip", - "github.com/go-macaron/session", - "github.com/go-macaron/session/memcache", - "github.com/go-macaron/session/postgres", - "github.com/go-macaron/session/redis", - "github.com/go-sql-driver/mysql", - "github.com/go-stack/stack", - "github.com/go-xorm/core", - "github.com/go-xorm/xorm", - "github.com/gorilla/websocket", - "github.com/gosimple/slug", - "github.com/grafana/grafana-plugin-model/go/datasource", - "github.com/grafana/grafana-plugin-model/go/renderer", - "github.com/hashicorp/go-hclog", - "github.com/hashicorp/go-plugin", - "github.com/hashicorp/go-version", - "github.com/inconshreveable/log15", - "github.com/lib/pq", - "github.com/mattn/go-isatty", - "github.com/mattn/go-sqlite3", - "github.com/opentracing/opentracing-go", - "github.com/opentracing/opentracing-go/ext", - "github.com/opentracing/opentracing-go/log", - "github.com/patrickmn/go-cache", - "github.com/pkg/errors", - "github.com/prometheus/client_golang/api", - "github.com/prometheus/client_golang/api/prometheus/v1", - "github.com/prometheus/client_golang/prometheus", - "github.com/prometheus/client_golang/prometheus/promhttp", - "github.com/prometheus/client_model/go", - "github.com/prometheus/common/expfmt", - "github.com/prometheus/common/model", - "github.com/smartystreets/goconvey/convey", - "github.com/teris-io/shortid", - "github.com/uber/jaeger-client-go/config", - "github.com/yudai/gojsondiff", - "github.com/yudai/gojsondiff/formatter", - "golang.org/x/net/context/ctxhttp", - "golang.org/x/oauth2", - "golang.org/x/oauth2/google", - "golang.org/x/oauth2/jwt", - "golang.org/x/sync/errgroup", - "gopkg.in/ini.v1", - "gopkg.in/macaron.v1", - "gopkg.in/mail.v2", - "gopkg.in/square/go-jose.v2", - "gopkg.in/yaml.v2", - ] + inputs-digest = "88f0eb826b9c154ba46ea3bb64767707d86db75449ec75199eb2b8cf2b337fd4" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index e3cbdeabb5d..83e6890b0f4 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -58,10 +58,6 @@ ignored = [ 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" @@ -211,3 +207,7 @@ ignored = [ [[constraint]] name = "gopkg.in/square/go-jose.v2" version = "2.1.9" + +[[constraint]] + name = "gopkg.in/ldap.v3" + version = "3.0.0" diff --git a/README.md b/README.md index 269c525e983..1ce4ffbe109 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ the latest master builds [here](https://grafana.com/grafana/download) ### Dependencies - Go (Latest Stable) -- NodeJS LTS +- Node.js LTS ### Building the backend ```bash @@ -37,7 +37,7 @@ go run build.go build ### Building frontend assets -For this you need nodejs (v.6+). +For this you need Node.js (LTS version). To build the assets, rebuild on file change, and serve them by Grafana's webserver (http://localhost:3000): ```bash @@ -90,7 +90,7 @@ Choose this option to build on platforms other than linux/amd64 and/or not have The resulting image will be tagged as `grafana/grafana:dev` -Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Perferences -> Advanced), otherwize you may faild at `grunt build` +Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Preferences -> Advanced), otherwize you may faild at `grunt build` ### Dev config diff --git a/appveyor.yml b/appveyor.yml index 4bbd3668e19..5f97784dd38 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana environment: nodejs_version: "8" GOPATH: C:\gopath - GOVERSION: 1.11 + GOVERSION: 1.11.4 install: - rmdir c:\go /s /q diff --git a/conf/defaults.ini b/conf/defaults.ini index 2ef2ad7942a..97c87edb8b2 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -335,6 +335,7 @@ tls_skip_verify_insecure = false tls_client_cert = tls_client_key = tls_client_ca = +send_client_credentials_via_post = false #################################### Basic Auth ########################## [auth.basic] diff --git a/conf/sample.ini b/conf/sample.ini index ba65727dc4b..473e4e8450c 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -284,6 +284,10 @@ log_queries = ;tls_client_key = ;tls_client_ca = +; Set to true to enable sending client_id and client_secret via POST body instead of Basic authentication HTTP header +; This might be required if the OAuth provider is not RFC6749 compliant, only supporting credentials passed via POST payload +;send_client_credentials_via_post = false + #################################### Grafana.com Auth #################### [auth.grafana_com] ;enabled = false diff --git a/docs/sources/auth/generic-oauth.md b/docs/sources/auth/generic-oauth.md index 6fa6531fc98..c3c44426ba7 100644 --- a/docs/sources/auth/generic-oauth.md +++ b/docs/sources/auth/generic-oauth.md @@ -17,7 +17,7 @@ can find examples using Okta, BitBucket, OneLogin and Azure. This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the prefix path of `/login/generic_oauth`. -You may have to set the `root_url` option of `[server]` for the callback URL to be +You may have to set the `root_url` option of `[server]` for the callback URL to be correct. For example in case you are serving Grafana behind a proxy. Example config: @@ -209,6 +209,17 @@ allowed_organizations = token_url = https://.my.centrify.com/OAuth2/Token/ ``` +## Set up OAuth2 with non-compliant providers + +Some OAuth2 providers might not support `client_id` and `client_secret` passed via Basic Authentication HTTP header, which +results in `invalid_client` error. To allow Grafana to authenticate via these type of providers, the client identifiers must be +send via POST body, which can be enabled via the following settings: + + ```bash + [auth.generic_oauth] + send_client_credentials_via_post = true + ``` +
diff --git a/docs/sources/auth/github.md b/docs/sources/auth/github.md index b4ffc0fc2d4..22a10044d93 100644 --- a/docs/sources/auth/github.md +++ b/docs/sources/auth/github.md @@ -1,5 +1,5 @@ +++ -title = "Google OAuth2 Authentication" +title = "GitHub OAuth2 Authentication" description = "Grafana OAuthentication Guide " keywords = ["grafana", "configuration", "documentation", "oauth"] type = "docs" diff --git a/docs/sources/auth/gitlab.md b/docs/sources/auth/gitlab.md index 56fc3b131a5..541aed3fd1f 100644 --- a/docs/sources/auth/gitlab.md +++ b/docs/sources/auth/gitlab.md @@ -1,5 +1,5 @@ +++ -title = "Google OAuth2 Authentication" +title = "GitLab OAuth2 Authentication" description = "Grafana OAuthentication Guide " keywords = ["grafana", "configuration", "documentation", "oauth"] type = "docs" diff --git a/package.json b/package.json index d8ea4c036d4..d374cd524b4 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,9 @@ "@types/enzyme": "^3.1.13", "@types/jest": "^23.3.2", "@types/node": "^8.0.31", - "@types/react": "^16.4.14", + "@types/react": "^16.7.6", "@types/react-custom-scrollbars": "^4.0.5", - "@types/react-dom": "^16.0.7", + "@types/react-dom": "^16.0.9", "@types/react-select": "^2.0.4", "angular-mocks": "1.6.6", "autoprefixer": "^6.4.0", @@ -148,17 +148,18 @@ "prismjs": "^1.6.0", "prop-types": "^15.6.2", "rc-cascader": "^0.14.0", - "react": "^16.5.0", + "react": "^16.6.3", "react-custom-scrollbars": "^4.2.1", - "react-dom": "^16.5.0", + "react-dom": "^16.6.3", "react-grid-layout": "0.16.6", + "react-popper": "^1.3.0", "react-highlight-words": "0.11.0", - "react-popper": "^0.7.5", "react-redux": "^5.0.7", - "react-select": "2.1.0", + "@torkelo/react-select": "2.1.1", "react-sizeme": "^2.3.6", "react-table": "^6.8.6", "react-transition-group": "^2.2.1", + "react-virtualized": "^9.21.0", "redux": "^4.0.0", "redux-logger": "^3.0.6", "redux-thunk": "^2.3.0", @@ -175,6 +176,7 @@ "tslint-react": "^3.6.0" }, "resolutions": { - "caniuse-db": "1.0.30000772" + "caniuse-db": "1.0.30000772", + "**/@types/react": "16.7.6" } } diff --git a/packaging/publish/publish_both.sh b/packaging/publish/publish_both.sh index b1d480567e9..b86c20011e0 100755 --- a/packaging/publish/publish_both.sh +++ b/packaging/publish/publish_both.sh @@ -1,17 +1,17 @@ #! /usr/bin/env bash -version=5.4.1 +version=5.4.2 -wget https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb +# wget https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb +# +# package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb +# package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb +# package_cloud push grafana/stable/debian/stretch grafana_${version}_amd64.deb +# +# package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb +# package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose +# package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose -package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb -package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb -package_cloud push grafana/stable/debian/stretch grafana_${version}_amd64.deb - -package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb -package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose -package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose - -wget https://dl.grafana.com/release/grafana-${version}-1.x86_64.rpm +wget https://dl.grafana.com/oss/release/grafana-${version}-1.x86_64.rpm package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm --verbose package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm --verbose diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index e4e9c9d040f..ca7e78a58cd 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -1,10 +1,15 @@ package api import ( + "bytes" + "encoding/json" + "fmt" + "net/http" "time" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/metrics" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/guardian" @@ -12,6 +17,11 @@ import ( "github.com/grafana/grafana/pkg/util" ) +var client = &http.Client{ + Timeout: time.Second * 5, + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, +} + func GetSharingOptions(c *m.ReqContext) { c.JSON(200, util.DynMap{ "externalSnapshotURL": setting.ExternalSnapshotUrl, @@ -20,26 +30,79 @@ func GetSharingOptions(c *m.ReqContext) { }) } +type CreateExternalSnapshotResponse struct { + Key string `json:"key"` + DeleteKey string `json:"deleteKey"` + Url string `json:"url"` + DeleteUrl string `json:"deleteUrl"` +} + +func createExternalDashboardSnapshot(cmd m.CreateDashboardSnapshotCommand) (*CreateExternalSnapshotResponse, error) { + var createSnapshotResponse CreateExternalSnapshotResponse + message := map[string]interface{}{ + "name": cmd.Name, + "expires": cmd.Expires, + "dashboard": cmd.Dashboard, + } + + messageBytes, err := simplejson.NewFromAny(message).Encode() + if err != nil { + return nil, err + } + + response, err := client.Post(setting.ExternalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes)) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + return nil, fmt.Errorf("Create external snapshot response status code %d", response.StatusCode) + } + + if err := json.NewDecoder(response.Body).Decode(&createSnapshotResponse); err != nil { + return nil, err + } + + return &createSnapshotResponse, nil +} + +// POST /api/snapshots func CreateDashboardSnapshot(c *m.ReqContext, cmd m.CreateDashboardSnapshotCommand) { if cmd.Name == "" { cmd.Name = "Unnamed snapshot" } + var url string + cmd.ExternalUrl = "" + cmd.OrgId = c.OrgId + cmd.UserId = c.UserId + if cmd.External { - // external snapshot ref requires key and delete key - if cmd.Key == "" || cmd.DeleteKey == "" { - c.JsonApiErr(400, "Missing key and delete key for external snapshot", nil) + if !setting.ExternalEnabled { + c.JsonApiErr(403, "External dashboard creation is disabled", nil) return } - cmd.OrgId = -1 - cmd.UserId = -1 + response, err := createExternalDashboardSnapshot(cmd) + if err != nil { + c.JsonApiErr(500, "Failed to create external snaphost", err) + return + } + + url = response.Url + cmd.Key = response.Key + cmd.DeleteKey = response.DeleteKey + cmd.ExternalUrl = response.Url + cmd.ExternalDeleteUrl = response.DeleteUrl + cmd.Dashboard = simplejson.New() + metrics.M_Api_Dashboard_Snapshot_External.Inc() } else { cmd.Key = util.GetRandomString(32) cmd.DeleteKey = util.GetRandomString(32) - cmd.OrgId = c.OrgId - cmd.UserId = c.UserId + url = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key) + metrics.M_Api_Dashboard_Snapshot_Create.Inc() } @@ -51,7 +114,7 @@ func CreateDashboardSnapshot(c *m.ReqContext, cmd m.CreateDashboardSnapshotComma c.JSON(200, util.DynMap{ "key": cmd.Key, "deleteKey": cmd.DeleteKey, - "url": setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key), + "url": url, "deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey), }) } @@ -91,6 +154,33 @@ func GetDashboardSnapshot(c *m.ReqContext) { c.JSON(200, dto) } +func deleteExternalDashboardSnapshot(externalUrl string) error { + response, err := client.Get(externalUrl) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode == 200 { + return nil + } + + // Gracefully ignore "snapshot not found" errors as they could have already + // been removed either via the cleanup script or by request. + if response.StatusCode == 500 { + var respJson map[string]interface{} + if err := json.NewDecoder(response.Body).Decode(&respJson); err != nil { + return err + } + + if respJson["message"] == "Failed to get dashboard snapshot" { + return nil + } + } + + return fmt.Errorf("Unexpected response when deleting external snapshot. Status code: %d", response.StatusCode) +} + // GET /api/snapshots-delete/:deleteKey func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response { key := c.Params(":deleteKey") @@ -102,6 +192,13 @@ func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response { return Error(500, "Failed to get dashboard snapshot", err) } + if query.Result.External { + err := deleteExternalDashboardSnapshot(query.Result.ExternalDeleteUrl) + if err != nil { + return Error(500, "Failed to delete external dashboard", err) + } + } + cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey} if err := bus.Dispatch(cmd); err != nil { @@ -138,6 +235,13 @@ func DeleteDashboardSnapshot(c *m.ReqContext) Response { return Error(403, "Access denied to this snapshot", nil) } + if query.Result.External { + err := deleteExternalDashboardSnapshot(query.Result.ExternalDeleteUrl) + if err != nil { + return Error(500, "Failed to delete external dashboard", err) + } + } + cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey} if err := bus.Dispatch(cmd); err != nil { diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index e58f2c4712d..a24d0f38d85 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -1,6 +1,9 @@ package api import ( + "fmt" + "net/http" + "net/http/httptest" "testing" "time" @@ -13,13 +16,17 @@ import ( func TestDashboardSnapshotApiEndpoint(t *testing.T) { Convey("Given a single snapshot", t, func() { + var externalRequest *http.Request jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`)) mockSnapshotResult := &m.DashboardSnapshot{ Id: 1, + Key: "12345", + DeleteKey: "54321", Dashboard: jsonModel, Expires: time.Now().Add(time.Duration(1000) * time.Second), UserId: 999999, + External: true, } bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error { @@ -45,13 +52,25 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { return nil }) + setupRemoteServer := func(fn func(http.ResponseWriter, *http.Request)) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + fn(rw, r) + })) + } + Convey("When user has editor role and is not in the ACL", func() { Convey("Should not be able to delete snapshot", func() { loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + externalRequest = req + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL sc.handlerFunc = DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() So(sc.resp.Code, ShouldEqual, 403) + So(externalRequest, ShouldBeNil) }) }) }) @@ -59,6 +78,12 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { Convey("When user is anonymous", func() { Convey("Should be able to delete snapshot by deleteKey", func() { anonymousUserScenario("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(200) + externalRequest = req + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL sc.handlerFunc = DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec() @@ -67,6 +92,10 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { So(err, ShouldBeNil) So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted") + + So(externalRequest.Method, ShouldEqual, http.MethodGet) + So(fmt.Sprintf("http://%s", externalRequest.Host), ShouldEqual, ts.URL) + So(externalRequest.URL.EscapedPath(), ShouldEqual, "/") }) }) }) @@ -79,6 +108,12 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { Convey("Should be able to delete a snapshot", func() { loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(200) + externalRequest = req + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL sc.handlerFunc = DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -87,6 +122,8 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { So(err, ShouldBeNil) So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted") + So(fmt.Sprintf("http://%s", externalRequest.Host), ShouldEqual, ts.URL) + So(externalRequest.URL.EscapedPath(), ShouldEqual, "/") }) }) }) @@ -94,6 +131,7 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { Convey("When user is editor and is the creator of the snapshot", func() { aclMockResp = []*m.DashboardAclInfoDTO{} mockSnapshotResult.UserId = TestUserID + mockSnapshotResult.External = false Convey("Should be able to delete a snapshot", func() { loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { @@ -108,5 +146,54 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { }) }) }) + + Convey("When deleting an external snapshot", func() { + aclMockResp = []*m.DashboardAclInfoDTO{} + mockSnapshotResult.UserId = TestUserID + + Convey("Should gracefully delete local snapshot when remote snapshot has already been removed", func() { + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"message":"Failed to get dashboard snapshot"}`)) + rw.WriteHeader(500) + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + Convey("Should fail to delete local snapshot when an unexpected 500 error occurs", func() { + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(500) + rw.Write([]byte(`{"message":"Unexpected"}`)) + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 500) + }) + }) + + Convey("Should fail to delete local snapshot when an unexpected remote error occurs", func() { + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(404) + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 500) + }) + }) + }) }) } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 65affe83e98..8f5457cf271 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -186,16 +186,18 @@ func getPanelSort(id string) int { sort = 1 case "singlestat": sort = 2 - case "table": + case "gauge": sort = 3 - case "text": + case "table": sort = 4 - case "heatmap": + case "text": sort = 5 - case "alertlist": + case "heatmap": sort = 6 - case "dashlist": + case "alertlist": sort = 7 + case "dashlist": + sort = 8 } return sort } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 455420e4688..0d54f0707a6 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -164,6 +164,14 @@ func GetPluginMarkdown(c *m.ReqContext) Response { return Error(500, "Could not get markdown file", err) } + // fallback try readme + if len(content) == 0 { + content, err = plugins.GetPluginMarkdown(pluginID, "readme") + if err != nil { + return Error(501, "Could not get markdown file", err) + } + } + resp := Respond(200, content) resp.Header("Content-Type", "text/plain; charset=utf-8") return resp diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index d9fd1e62ce7..4781361b9b9 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -28,6 +28,7 @@ import ( // self registering services _ "github.com/grafana/grafana/pkg/extensions" + _ "github.com/grafana/grafana/pkg/infra/serverlock" _ "github.com/grafana/grafana/pkg/metrics" _ "github.com/grafana/grafana/pkg/plugins" _ "github.com/grafana/grafana/pkg/services/alerting" diff --git a/pkg/infra/serverlock/model.go b/pkg/infra/serverlock/model.go new file mode 100644 index 00000000000..7e0ea7814b1 --- /dev/null +++ b/pkg/infra/serverlock/model.go @@ -0,0 +1,8 @@ +package serverlock + +type serverLock struct { + Id int64 + OperationUid string + LastExecution int64 + Version int64 +} diff --git a/pkg/infra/serverlock/serverlock.go b/pkg/infra/serverlock/serverlock.go new file mode 100644 index 00000000000..824fa0484b2 --- /dev/null +++ b/pkg/infra/serverlock/serverlock.go @@ -0,0 +1,116 @@ +package serverlock + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func init() { + registry.RegisterService(&ServerLockService{}) +} + +// ServerLockService allows servers in HA mode to claim a lock +// and execute an function if the server was granted the lock +type ServerLockService struct { + SQLStore *sqlstore.SqlStore `inject:""` + log log.Logger +} + +// Init this service +func (sl *ServerLockService) Init() error { + sl.log = log.New("infra.lockservice") + return nil +} + +// LockAndExecute try to create a lock for this server and only executes the +// `fn` function when successful. This should not be used at low internal. But services +// that needs to be run once every ex 10m. +func (sl *ServerLockService) LockAndExecute(ctx context.Context, actionName string, maxInterval time.Duration, fn func()) error { + // gets or creates a lockable row + rowLock, err := sl.getOrCreate(ctx, actionName) + if err != nil { + return err + } + + // avoid execution if last lock happened less than `maxInterval` ago + if rowLock.LastExecution != 0 { + lastExeuctionTime := time.Unix(rowLock.LastExecution, 0) + if lastExeuctionTime.Unix() > time.Now().Add(-maxInterval).Unix() { + return nil + } + } + + // try to get lock based on rowLow version + acquiredLock, err := sl.acquireLock(ctx, rowLock) + if err != nil { + return err + } + + if acquiredLock { + fn() + } + + return nil +} + +func (sl *ServerLockService) acquireLock(ctx context.Context, serverLock *serverLock) (bool, error) { + var result bool + + err := sl.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + newVersion := serverLock.Version + 1 + sql := `UPDATE server_lock SET + version = ?, + last_execution = ? + WHERE + id = ? AND version = ?` + + res, err := dbSession.Exec(sql, newVersion, time.Now().Unix(), serverLock.Id, serverLock.Version) + if err != nil { + return err + } + + affected, err := res.RowsAffected() + result = affected == 1 + + return err + }) + + return result, err +} + +func (sl *ServerLockService) getOrCreate(ctx context.Context, actionName string) (*serverLock, error) { + var result *serverLock + + err := sl.SQLStore.WithTransactionalDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + lockRows := []*serverLock{} + err := dbSession.Where("operation_uid = ?", actionName).Find(&lockRows) + if err != nil { + return err + } + + if len(lockRows) > 0 { + result = lockRows[0] + return nil + } + + lockRow := &serverLock{ + OperationUid: actionName, + LastExecution: 0, + } + + _, err = dbSession.Insert(lockRow) + if err != nil { + return err + } + + result = lockRow + + return nil + }) + + return result, err +} diff --git a/pkg/infra/serverlock/serverlock_integration_test.go b/pkg/infra/serverlock/serverlock_integration_test.go new file mode 100644 index 00000000000..8bcd9c2ca25 --- /dev/null +++ b/pkg/infra/serverlock/serverlock_integration_test.go @@ -0,0 +1,40 @@ +// +build integration + +package serverlock + +import ( + "context" + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestServerLok(t *testing.T) { + sl := createTestableServerLock(t) + + Convey("Server lock integration tests", t, func() { + counter := 0 + var err error + incCounter := func() { counter++ } + atInterval := time.Second * 1 + ctx := context.Background() + + //this time `fn` should be executed + So(sl.LockAndExecute(ctx, "test-operation", atInterval, incCounter), ShouldBeNil) + + //this should not execute `fn` + So(sl.LockAndExecute(ctx, "test-operation", atInterval, incCounter), ShouldBeNil) + So(sl.LockAndExecute(ctx, "test-operation", atInterval, incCounter), ShouldBeNil) + So(sl.LockAndExecute(ctx, "test-operation", atInterval, incCounter), ShouldBeNil) + So(sl.LockAndExecute(ctx, "test-operation", atInterval, incCounter), ShouldBeNil) + + // wait 5 second. + <-time.After(atInterval * 2) + + // now `fn` should be executed again + err = sl.LockAndExecute(ctx, "test-operation", atInterval, incCounter) + So(err, ShouldBeNil) + So(counter, ShouldEqual, 2) + }) +} diff --git a/pkg/infra/serverlock/serverlock_test.go b/pkg/infra/serverlock/serverlock_test.go new file mode 100644 index 00000000000..ccd1c252090 --- /dev/null +++ b/pkg/infra/serverlock/serverlock_test.go @@ -0,0 +1,55 @@ +package serverlock + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/sqlstore" + . "github.com/smartystreets/goconvey/convey" +) + +func createTestableServerLock(t *testing.T) *ServerLockService { + t.Helper() + + sqlstore := sqlstore.InitTestDB(t) + + return &ServerLockService{ + SQLStore: sqlstore, + log: log.New("test-logger"), + } +} + +func TestServerLock(t *testing.T) { + Convey("Server lock", t, func() { + sl := createTestableServerLock(t) + operationUID := "test-operation" + + first, err := sl.getOrCreate(context.Background(), operationUID) + So(err, ShouldBeNil) + + lastExecution := first.LastExecution + Convey("trying to create three new row locks", func() { + for i := 0; i < 3; i++ { + first, err = sl.getOrCreate(context.Background(), operationUID) + So(err, ShouldBeNil) + So(first.OperationUid, ShouldEqual, operationUID) + So(first.Id, ShouldEqual, 1) + } + + Convey("Should not create new since lock already exist", func() { + So(lastExecution, ShouldEqual, first.LastExecution) + }) + }) + + Convey("Should be able to create lock on first row", func() { + gotLock, err := sl.acquireLock(context.Background(), first) + So(err, ShouldBeNil) + So(gotLock, ShouldBeTrue) + + gotLock, err = sl.acquireLock(context.Background(), first) + So(err, ShouldBeNil) + So(gotLock, ShouldBeFalse) + }) + }) +} diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index d4e81d2bd46..218f7321e65 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -9,11 +9,11 @@ import ( "strings" "github.com/davecgh/go-spew/spew" - "github.com/go-ldap/ldap" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" + "gopkg.in/ldap.v3" ) type ILdapConn interface { diff --git a/pkg/login/ldap_test.go b/pkg/login/ldap_test.go index 1cf98bd1e14..f1cf44dc554 100644 --- a/pkg/login/ldap_test.go +++ b/pkg/login/ldap_test.go @@ -5,10 +5,10 @@ import ( "crypto/tls" "testing" - "github.com/go-ldap/ldap" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" . "github.com/smartystreets/goconvey/convey" + "gopkg.in/ldap.v3" ) func TestLdapAuther(t *testing.T) { diff --git a/pkg/middleware/auth_proxy.go b/pkg/middleware/auth_proxy.go index 29bd305b336..fc109ac707f 100644 --- a/pkg/middleware/auth_proxy.go +++ b/pkg/middleware/auth_proxy.go @@ -198,17 +198,31 @@ func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error } proxies := strings.Split(setting.AuthProxyWhitelist, ",") - sourceIP, _, err := net.SplitHostPort(remoteAddr) - if err != nil { - return err + var proxyObjs []*net.IPNet + for _, proxy := range proxies { + proxyObjs = append(proxyObjs, coerceProxyAddress(proxy)) } - // Compare allowed IP addresses to actual address - for _, proxyIP := range proxies { - if sourceIP == strings.TrimSpace(proxyIP) { + sourceIP, _, _ := net.SplitHostPort(remoteAddr) + sourceObj := net.ParseIP(sourceIP) + + for _, proxyObj := range proxyObjs { + if proxyObj.Contains(sourceObj) { return nil } } - return fmt.Errorf("Request for user (%s) from %s is not from the authentication proxy", proxyHeaderValue, sourceIP) } + +func coerceProxyAddress(proxyAddr string) *net.IPNet { + proxyAddr = strings.TrimSpace(proxyAddr) + if !strings.Contains(proxyAddr, "/") { + proxyAddr = strings.Join([]string{proxyAddr, "32"}, "/") + } + + _, network, err := net.ParseCIDR(proxyAddr) + if err != nil { + fmt.Println(err) + } + return network +} diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index e9a3c8059f8..b9a8afce6c6 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -271,6 +271,23 @@ func TestMiddlewareContext(t *testing.T) { }) }) + middlewareScenario("When auth_proxy is enabled and IPv4 request RemoteAddr is not within trusted CIDR block", func(sc *scenarioContext) { + setting.AuthProxyEnabled = true + setting.AuthProxyHeaderName = "X-WEBAUTH-USER" + setting.AuthProxyHeaderProperty = "username" + setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120" + + sc.fakeReq("GET", "/") + sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") + sc.req.RemoteAddr = "192.168.3.1:12345" + sc.exec() + + Convey("should return 407 status code", func() { + So(sc.resp.Code, ShouldEqual, 407) + So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 192.168.3.1 is not from the authentication proxy") + }) + }) + middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is not trusted", func(sc *scenarioContext) { setting.AuthProxyEnabled = true setting.AuthProxyHeaderName = "X-WEBAUTH-USER" @@ -288,6 +305,23 @@ func TestMiddlewareContext(t *testing.T) { }) }) + middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is not within trusted CIDR block", func(sc *scenarioContext) { + setting.AuthProxyEnabled = true + setting.AuthProxyHeaderName = "X-WEBAUTH-USER" + setting.AuthProxyHeaderProperty = "username" + setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120" + + sc.fakeReq("GET", "/") + sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") + sc.req.RemoteAddr = "[2001:23]:12345" + sc.exec() + + Convey("should return 407 status code", func() { + So(sc.resp.Code, ShouldEqual, 407) + So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 2001:23 is not from the authentication proxy") + }) + }) + middlewareScenario("When auth_proxy is enabled and request RemoteAddr is trusted", func(sc *scenarioContext) { setting.AuthProxyEnabled = true setting.AuthProxyHeaderName = "X-WEBAUTH-USER" @@ -316,6 +350,62 @@ func TestMiddlewareContext(t *testing.T) { }) }) + middlewareScenario("When auth_proxy is enabled and IPv4 request RemoteAddr is within trusted CIDR block", func(sc *scenarioContext) { + setting.AuthProxyEnabled = true + setting.AuthProxyHeaderName = "X-WEBAUTH-USER" + setting.AuthProxyHeaderProperty = "username" + setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120" + + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 4, UserId: 33} + return nil + }) + + bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { + cmd.Result = &m.User{Id: 33} + return nil + }) + + sc.fakeReq("GET", "/") + sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") + sc.req.RemoteAddr = "192.168.1.10:12345" + sc.exec() + + Convey("Should init context with user info", func() { + So(sc.context.IsSignedIn, ShouldBeTrue) + So(sc.context.UserId, ShouldEqual, 33) + So(sc.context.OrgId, ShouldEqual, 4) + }) + }) + + middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is within trusted CIDR block", func(sc *scenarioContext) { + setting.AuthProxyEnabled = true + setting.AuthProxyHeaderName = "X-WEBAUTH-USER" + setting.AuthProxyHeaderProperty = "username" + setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120" + + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 4, UserId: 33} + return nil + }) + + bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { + cmd.Result = &m.User{Id: 33} + return nil + }) + + sc.fakeReq("GET", "/") + sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") + sc.req.RemoteAddr = "[2001::23]:12345" + sc.exec() + + Convey("Should init context with user info", func() { + So(sc.context.IsSignedIn, ShouldBeTrue) + So(sc.context.UserId, ShouldEqual, 33) + So(sc.context.OrgId, ShouldEqual, 4) + }) + }) + middlewareScenario("When session exists for previous user, create a new session", func(sc *scenarioContext) { setting.AuthProxyEnabled = true setting.AuthProxyHeaderName = "X-WEBAUTH-USER" diff --git a/pkg/models/dashboard_snapshot.go b/pkg/models/dashboard_snapshot.go index 3024ba94122..e8db0372758 100644 --- a/pkg/models/dashboard_snapshot.go +++ b/pkg/models/dashboard_snapshot.go @@ -8,14 +8,15 @@ import ( // DashboardSnapshot model type DashboardSnapshot struct { - Id int64 - Name string - Key string - DeleteKey string - OrgId int64 - UserId int64 - External bool - ExternalUrl string + Id int64 + Name string + Key string + DeleteKey string + OrgId int64 + UserId int64 + External bool + ExternalUrl string + ExternalDeleteUrl string Expires time.Time Created time.Time @@ -48,7 +49,10 @@ type CreateDashboardSnapshotCommand struct { Expires int64 `json:"expires"` // these are passed when storing an external snapshot ref - External bool `json:"external"` + External bool `json:"external"` + ExternalUrl string `json:"-"` + ExternalDeleteUrl string `json:"-"` + Key string `json:"key"` DeleteKey string `json:"deleteKey"` diff --git a/pkg/plugins/datasource_plugin.go b/pkg/plugins/datasource_plugin.go index ff44805e35f..dd4ae6972aa 100644 --- a/pkg/plugins/datasource_plugin.go +++ b/pkg/plugins/datasource_plugin.go @@ -3,10 +3,8 @@ package plugins import ( "context" "encoding/json" - "os" "os/exec" "path" - "path/filepath" "time" "github.com/grafana/grafana-plugin-model/go/datasource" @@ -24,11 +22,11 @@ type DataSourcePlugin struct { Metrics bool `json:"metrics"` Alerting bool `json:"alerting"` Explore bool `json:"explore"` + Table bool `json:"tables"` Logs bool `json:"logs"` QueryOptions map[string]bool `json:"queryOptions,omitempty"` BuiltIn bool `json:"builtIn,omitempty"` Mixed bool `json:"mixed,omitempty"` - HasQueryHelp bool `json:"hasQueryHelp,omitempty"` Routes []*AppPluginRoute `json:"routes"` Backend bool `json:"backend,omitempty"` @@ -47,15 +45,6 @@ func (p *DataSourcePlugin) Load(decoder *json.Decoder, pluginDir string) error { return err } - // look for help markdown - helpPath := filepath.Join(p.PluginDir, "QUERY_HELP.md") - if _, err := os.Stat(helpPath); os.IsNotExist(err) { - helpPath = filepath.Join(p.PluginDir, "query_help.md") - } - if _, err := os.Stat(helpPath); err == nil { - p.HasQueryHelp = true - } - DataSources[p.Id] = p return nil } diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index 9ce50eadd6b..75c68615750 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -166,7 +166,7 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds [] var result notifierStateSlice for _, notification := range query.Result { - not, err := n.createNotifierFor(notification) + not, err := InitNotifier(notification) if err != nil { n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err) continue @@ -195,7 +195,8 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds [] return result, nil } -func (n *notificationService) createNotifierFor(model *m.AlertNotification) (Notifier, error) { +// InitNotifier instantiate a new notifier based on the model +func InitNotifier(model *m.AlertNotification) (Notifier, error) { notifierPlugin, found := notifierFactories[model.Type] if !found { return nil, errors.New("Unsupported notification type") @@ -208,6 +209,7 @@ type NotifierFactory func(notification *m.AlertNotification) (Notifier, error) var notifierFactories = make(map[string]*NotifierPlugin) +// RegisterNotifier register an notifier func RegisterNotifier(plugin *NotifierPlugin) { notifierFactories[plugin.Type] = plugin } diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go index 8aa1b80aa22..b6e59f694c8 100644 --- a/pkg/services/alerting/test_notification.go +++ b/pkg/services/alerting/test_notification.go @@ -32,7 +32,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error { Settings: cmd.Settings, } - notifiers, err := notifier.createNotifierFor(model) + notifiers, err := InitNotifier(model) if err != nil { log.Error2("Failed to create notifier", "error", err.Error()) diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index c15ae8ef36c..63d829bccec 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -8,6 +8,7 @@ import ( "time" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" @@ -15,8 +16,9 @@ import ( ) type CleanUpService struct { - log log.Logger - Cfg *setting.Cfg `inject:""` + log log.Logger + Cfg *setting.Cfg `inject:""` + ServerLockService *serverlock.ServerLockService `inject:""` } func init() { @@ -38,7 +40,10 @@ func (srv *CleanUpService) Run(ctx context.Context) error { srv.cleanUpTmpFiles() srv.deleteExpiredSnapshots() srv.deleteExpiredDashboardVersions() - srv.deleteOldLoginAttempts() + srv.ServerLockService.LockAndExecute(ctx, "delete old login attempts", time.Minute*10, func() { + srv.deleteOldLoginAttempts() + }) + case <-ctx.Done(): return ctx.Err() } diff --git a/pkg/services/sqlstore/dashboard_snapshot.go b/pkg/services/sqlstore/dashboard_snapshot.go index 2e2ea8a4783..d0af676a305 100644 --- a/pkg/services/sqlstore/dashboard_snapshot.go +++ b/pkg/services/sqlstore/dashboard_snapshot.go @@ -47,16 +47,18 @@ func CreateDashboardSnapshot(cmd *m.CreateDashboardSnapshotCommand) error { } snapshot := &m.DashboardSnapshot{ - Name: cmd.Name, - Key: cmd.Key, - DeleteKey: cmd.DeleteKey, - OrgId: cmd.OrgId, - UserId: cmd.UserId, - External: cmd.External, - Dashboard: cmd.Dashboard, - Expires: expires, - Created: time.Now(), - Updated: time.Now(), + Name: cmd.Name, + Key: cmd.Key, + DeleteKey: cmd.DeleteKey, + OrgId: cmd.OrgId, + UserId: cmd.UserId, + External: cmd.External, + ExternalUrl: cmd.ExternalUrl, + ExternalDeleteUrl: cmd.ExternalDeleteUrl, + Dashboard: cmd.Dashboard, + Expires: expires, + Created: time.Now(), + Updated: time.Now(), } _, err := sess.Insert(snapshot) diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go index 7f70e5c25fc..ccab1106880 100644 --- a/pkg/services/sqlstore/datasource.go +++ b/pkg/services/sqlstore/datasource.go @@ -53,14 +53,14 @@ func GetDataSourceByName(query *m.GetDataSourceByNameQuery) error { } func GetDataSources(query *m.GetDataSourcesQuery) error { - sess := x.Limit(1000, 0).Where("org_id=?", query.OrgId).Asc("name") + sess := x.Limit(5000, 0).Where("org_id=?", query.OrgId).Asc("name") query.Result = make([]*m.DataSource, 0) return sess.Find(&query.Result) } func GetAllDataSources(query *m.GetAllDataSourcesQuery) error { - sess := x.Limit(1000, 0).Asc("name") + sess := x.Limit(5000, 0).Asc("name") query.Result = make([]*m.DataSource, 0) return sess.Find(&query.Result) diff --git a/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go b/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go index b880497cd23..be0bc80134c 100644 --- a/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go @@ -60,4 +60,8 @@ func addDashboardSnapshotMigrations(mg *Migrator) { {Name: "external_url", Type: DB_NVarchar, Length: 255, Nullable: false}, {Name: "dashboard", Type: DB_MediumText, Nullable: false}, })) + + mg.AddMigration("Add column external_delete_url to dashboard_snapshots table", NewAddColumnMigration(snapshotV5, &Column{ + Name: "external_delete_url", Type: DB_NVarchar, Length: 255, Nullable: true, + })) } diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 58ac6256f41..36cd8e5ed62 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -31,6 +31,7 @@ func AddMigrations(mg *Migrator) { addTagMigration(mg) addLoginAttemptMigrations(mg) addUserAuthMigrations(mg) + addServerlockMigrations(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrations/serverlock_migrations.go b/pkg/services/sqlstore/migrations/serverlock_migrations.go new file mode 100644 index 00000000000..bc817a90665 --- /dev/null +++ b/pkg/services/sqlstore/migrations/serverlock_migrations.go @@ -0,0 +1,22 @@ +package migrations + +import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addServerlockMigrations(mg *migrator.Migrator) { + serverLock := migrator.Table{ + Name: "server_lock", + Columns: []*migrator.Column{ + {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "operation_uid", Type: migrator.DB_NVarchar, Length: 100}, + {Name: "version", Type: migrator.DB_BigInt}, + {Name: "last_execution", Type: migrator.DB_BigInt, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"operation_uid"}, Type: migrator.UniqueIndex}, + }, + } + + mg.AddMigration("create server_lock table", migrator.NewAddTableMigration(serverLock)) + + mg.AddMigration("add index server_lock.operation_uid", migrator.NewAddIndexMigration(serverLock, serverLock.Indices[0])) +} diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index a3ccb93b30c..312877751c9 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -345,8 +345,12 @@ func GetUserOrgList(query *m.GetUserOrgListQuery) error { return err } +func newSignedInUserCacheKey(orgID, userID int64) string { + return fmt.Sprintf("signed-in-user-%d-%d", userID, orgID) +} + func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) error { - cacheKey := fmt.Sprintf("signed-in-user-%d-%d", query.UserId, query.OrgId) + cacheKey := newSignedInUserCacheKey(query.OrgId, query.UserId) if cached, found := ss.CacheService.Get(cacheKey); found { query.Result = cached.(*m.SignedInUser) return nil @@ -357,6 +361,7 @@ func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) erro return err } + cacheKey = newSignedInUserCacheKey(query.Result.OrgId, query.UserId) ss.CacheService.Set(cacheKey, query.Result, time.Second*5) return nil } diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go index 627f2ab1ca5..526c17a8256 100644 --- a/pkg/services/sqlstore/user_test.go +++ b/pkg/services/sqlstore/user_test.go @@ -13,7 +13,7 @@ import ( func TestUserDataAccess(t *testing.T) { Convey("Testing DB", t, func() { - InitTestDB(t) + ss := InitTestDB(t) Convey("Creating a user", func() { cmd := &m.CreateUserCommand{ @@ -153,6 +153,27 @@ func TestUserDataAccess(t *testing.T) { So(prefsQuery.Result.UserId, ShouldEqual, 0) }) }) + + Convey("when retreiving signed in user for orgId=0 result should return active org id", func() { + ss.CacheService.Flush() + + query := &m.GetSignedInUserQuery{OrgId: users[1].OrgId, UserId: users[1].Id} + err := ss.GetSignedInUserWithCache(query) + So(err, ShouldBeNil) + So(query.Result, ShouldNotBeNil) + So(query.OrgId, ShouldEqual, users[1].OrgId) + err = SetUsingOrg(&m.SetUsingOrgCommand{UserId: users[1].Id, OrgId: users[0].OrgId}) + So(err, ShouldBeNil) + query = &m.GetSignedInUserQuery{OrgId: 0, UserId: users[1].Id} + err = ss.GetSignedInUserWithCache(query) + So(err, ShouldBeNil) + So(query.Result, ShouldNotBeNil) + So(query.Result.OrgId, ShouldEqual, users[0].OrgId) + + cacheKey := newSignedInUserCacheKey(query.Result.OrgId, query.UserId) + _, found := ss.CacheService.Get(cacheKey) + So(found, ShouldBeTrue) + }) }) }) diff --git a/pkg/setting/setting_oauth.go b/pkg/setting/setting_oauth.go index 93b1ab6f101..f0a3beccb44 100644 --- a/pkg/setting/setting_oauth.go +++ b/pkg/setting/setting_oauth.go @@ -1,20 +1,21 @@ package setting type OAuthInfo struct { - ClientId, ClientSecret string - Scopes []string - AuthUrl, TokenUrl string - Enabled bool - EmailAttributeName string - AllowedDomains []string - HostedDomain string - ApiUrl string - AllowSignup bool - Name string - TlsClientCert string - TlsClientKey string - TlsClientCa string - TlsSkipVerify bool + ClientId, ClientSecret string + Scopes []string + AuthUrl, TokenUrl string + Enabled bool + EmailAttributeName string + AllowedDomains []string + HostedDomain string + ApiUrl string + AllowSignup bool + Name string + TlsClientCert string + TlsClientKey string + TlsClientCa string + TlsSkipVerify bool + SendClientCredentialsViaPost bool } type OAuther struct { diff --git a/pkg/social/social.go b/pkg/social/social.go index 8918507f3b9..60099a028d6 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -63,28 +63,34 @@ func NewOAuthService() { for _, name := range allOauthes { sec := setting.Raw.Section("auth." + name) info := &setting.OAuthInfo{ - ClientId: sec.Key("client_id").String(), - ClientSecret: sec.Key("client_secret").String(), - Scopes: util.SplitString(sec.Key("scopes").String()), - AuthUrl: sec.Key("auth_url").String(), - TokenUrl: sec.Key("token_url").String(), - ApiUrl: sec.Key("api_url").String(), - Enabled: sec.Key("enabled").MustBool(), - EmailAttributeName: sec.Key("email_attribute_name").String(), - AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()), - HostedDomain: sec.Key("hosted_domain").String(), - AllowSignup: sec.Key("allow_sign_up").MustBool(), - Name: sec.Key("name").MustString(name), - TlsClientCert: sec.Key("tls_client_cert").String(), - TlsClientKey: sec.Key("tls_client_key").String(), - TlsClientCa: sec.Key("tls_client_ca").String(), - TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(), + ClientId: sec.Key("client_id").String(), + ClientSecret: sec.Key("client_secret").String(), + Scopes: util.SplitString(sec.Key("scopes").String()), + AuthUrl: sec.Key("auth_url").String(), + TokenUrl: sec.Key("token_url").String(), + ApiUrl: sec.Key("api_url").String(), + Enabled: sec.Key("enabled").MustBool(), + EmailAttributeName: sec.Key("email_attribute_name").String(), + AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()), + HostedDomain: sec.Key("hosted_domain").String(), + AllowSignup: sec.Key("allow_sign_up").MustBool(), + Name: sec.Key("name").MustString(name), + TlsClientCert: sec.Key("tls_client_cert").String(), + TlsClientKey: sec.Key("tls_client_key").String(), + TlsClientCa: sec.Key("tls_client_ca").String(), + TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(), + SendClientCredentialsViaPost: sec.Key("send_client_credentials_via_post").MustBool(), } if !info.Enabled { continue } + // handle the clients that do not properly support Basic auth headers and require passing client_id/client_secret via POST payload + if info.SendClientCredentialsViaPost { + oauth2.RegisterBrokenAuthHeaderProvider(info.TokenUrl) + } + if name == "grafananet" { name = grafanaCom } diff --git a/public/app/core/components/Animations/FadeIn.tsx b/public/app/core/components/Animations/FadeIn.tsx new file mode 100644 index 00000000000..e12f22486f1 --- /dev/null +++ b/public/app/core/components/Animations/FadeIn.tsx @@ -0,0 +1,38 @@ +import React, { SFC } from 'react'; +import Transition from 'react-transition-group/Transition'; + +interface Props { + duration: number; + children: JSX.Element; + in: boolean; + unmountOnExit?: boolean; +} + +export const FadeIn: SFC = props => { + const defaultStyle = { + transition: `opacity ${props.duration}ms linear`, + opacity: 0, + }; + + const transitionStyles = { + exited: { opacity: 0, display: 'none' }, + entering: { opacity: 0 }, + entered: { opacity: 1 }, + exiting: { opacity: 0 }, + }; + + return ( + + {state => ( +
+ {props.children} +
+ )} +
+ ); +}; diff --git a/public/app/core/components/Animations/SlideDown.tsx b/public/app/core/components/Animations/SlideDown.tsx index 70dacd73849..6e8995298dc 100644 --- a/public/app/core/components/Animations/SlideDown.tsx +++ b/public/app/core/components/Animations/SlideDown.tsx @@ -23,7 +23,7 @@ export default ({ children, in: inProp, maxHeight = defaultMaxHeight, style = de const transitionStyles = { exited: { maxHeight: 0 }, entering: { maxHeight: maxHeight }, - entered: { maxHeight: maxHeight, overflow: 'visible' }, + entered: { maxHeight: 'unset', overflow: 'visible' }, exiting: { maxHeight: 0 }, }; diff --git a/public/app/core/components/AppNotifications/AppNotificationItem.tsx b/public/app/core/components/AppNotifications/AppNotificationItem.tsx index 5169c39e7a0..6b4b268eb13 100644 --- a/public/app/core/components/AppNotifications/AppNotificationItem.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationItem.tsx @@ -20,6 +20,7 @@ export default class AppNotificationItem extends Component { render() { const { appNotification, onClearNotification } = this.props; + return (
diff --git a/public/app/core/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx b/public/app/core/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx new file mode 100644 index 00000000000..63191862ebb --- /dev/null +++ b/public/app/core/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx @@ -0,0 +1,36 @@ +import { PureComponent } from 'react'; +import ReactDOM from 'react-dom'; + +export interface Props { + onClick: () => void; +} + +interface State { + hasEventListener: boolean; +} + +export class ClickOutsideWrapper extends PureComponent { + state = { + hasEventListener: false, + }; + + componentDidMount() { + window.addEventListener('click', this.onOutsideClick, false); + } + + componentWillUnmount() { + window.removeEventListener('click', this.onOutsideClick, false); + } + + onOutsideClick = event => { + const domNode = ReactDOM.findDOMNode(this) as Element; + + if (!domNode || !domNode.contains(event.target)) { + this.props.onClick(); + } + }; + + render() { + return this.props.children; + } +} diff --git a/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx b/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx new file mode 100644 index 00000000000..ea63de58b47 --- /dev/null +++ b/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx @@ -0,0 +1,67 @@ +import React, { PureComponent, ReactNode } from 'react'; +import ClipboardJS from 'clipboard'; + +interface Props { + text: () => string; + elType?: string; + onSuccess?: (evt: any) => void; + onError?: (evt: any) => void; + className?: string; + children?: ReactNode; +} + +export class CopyToClipboard extends PureComponent { + clipboardjs: any; + myRef: any; + + constructor(props) { + super(props); + this.myRef = React.createRef(); + } + + componentDidMount() { + const { text, onSuccess, onError } = this.props; + + this.clipboardjs = new ClipboardJS(this.myRef.current, { + text: text, + }); + + if (onSuccess) { + this.clipboardjs.on('success', evt => { + evt.clearSelection(); + onSuccess(evt); + }); + } + + if (onError) { + this.clipboardjs.on('error', evt => { + console.error('Action:', evt.action); + console.error('Trigger:', evt.trigger); + onError(evt); + }); + } + } + + componentWillUnmount() { + if (this.clipboardjs) { + this.clipboardjs.destroy(); + } + } + + getElementType = () => { + return this.props.elType || 'button'; + }; + + render() { + const { elType, text, children, onError, onSuccess, ...restProps } = this.props; + + return React.createElement( + this.getElementType(), + { + ref: this.myRef, + ...restProps, + }, + this.props.children + ); + } +} diff --git a/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx b/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx index 9b9a9c4d02a..590c11c0615 100644 --- a/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx +++ b/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx @@ -28,8 +28,8 @@ class CustomScrollbar extends PureComponent {
} renderTrackVertical={props =>
} renderThumbHorizontal={props =>
} diff --git a/public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap b/public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap index 37d8cea45be..310eb714af9 100644 --- a/public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap +++ b/public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap @@ -6,8 +6,8 @@ exports[`CustomScrollbar renders correctly 1`] = ` style={ Object { "height": "auto", - "maxHeight": "100%", - "minHeight": "100%", + "maxHeight": "inherit", + "minHeight": "inherit", "overflow": "hidden", "position": "relative", "width": "100%", @@ -23,8 +23,8 @@ exports[`CustomScrollbar renders correctly 1`] = ` "left": undefined, "marginBottom": 0, "marginRight": 0, - "maxHeight": "calc(100% + 0px)", - "minHeight": "calc(100% + 0px)", + "maxHeight": "calc(inherit + 0px)", + "minHeight": "calc(inherit + 0px)", "overflow": "scroll", "position": "relative", "right": undefined, diff --git a/public/app/core/components/Form/Element.tsx b/public/app/core/components/Form/Element.tsx new file mode 100644 index 00000000000..997d7f0e717 --- /dev/null +++ b/public/app/core/components/Form/Element.tsx @@ -0,0 +1,43 @@ +import React, { PureComponent, ReactNode, ReactElement } from 'react'; +import { Label } from './Label'; +import { uniqueId } from 'lodash'; + +interface Props { + label?: ReactNode; + labelClassName?: string; + id?: string; + children: ReactElement; +} + +export class Element extends PureComponent { + elementId: string = this.props.id || uniqueId('form-element-'); + + get elementLabel() { + const { label, labelClassName } = this.props; + + if (label) { + return ( + + ); + } + + return null; + } + + get children() { + const { children } = this.props; + + return React.cloneElement(children, { id: this.elementId }); + } + + render() { + return ( +
+ {this.elementLabel} + {this.children} +
+ ); + } +} diff --git a/public/app/core/components/Form/Input.test.tsx b/public/app/core/components/Form/Input.test.tsx new file mode 100644 index 00000000000..9e903208e80 --- /dev/null +++ b/public/app/core/components/Form/Input.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { shallow } from 'enzyme'; +import { Input, EventsWithValidation } from './Input'; +import { ValidationEvents } from 'app/types'; + +const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars'; +const testBlurValidation: ValidationEvents = { + [EventsWithValidation.onBlur]: [ + { + rule: (value: string) => { + if (!value || value.length < 3) { + return true; + } + return false; + }, + errorMessage: TEST_ERROR_MESSAGE, + }, + ], +}; + +describe('Input', () => { + it('renders correctly', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should validate with error onBlur', () => { + const wrapper = shallow(); + const evt = { + persist: jest.fn, + target: { + value: 'I can not be more than 2 chars', + }, + }; + + wrapper.find('input').simulate('blur', evt); + expect(wrapper.state('error')).toBe(TEST_ERROR_MESSAGE); + }); + + it('should validate without error onBlur', () => { + const wrapper = shallow(); + const evt = { + persist: jest.fn, + target: { + value: 'Hi', + }, + }; + + wrapper.find('input').simulate('blur', evt); + expect(wrapper.state('error')).toBe(null); + }); +}); diff --git a/public/app/core/components/Form/Input.tsx b/public/app/core/components/Form/Input.tsx new file mode 100644 index 00000000000..7940f3b1104 --- /dev/null +++ b/public/app/core/components/Form/Input.tsx @@ -0,0 +1,94 @@ +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import { ValidationEvents, ValidationRule } from 'app/types'; +import { validate, hasValidationEvent } from 'app/core/utils/validate'; + +export enum InputStatus { + Invalid = 'invalid', + Valid = 'valid', +} + +export enum InputTypes { + Text = 'text', + Number = 'number', + Password = 'password', + Email = 'email', +} + +export enum EventsWithValidation { + onBlur = 'onBlur', + onFocus = 'onFocus', + onChange = 'onChange', +} + +interface Props extends React.HTMLProps { + validationEvents?: ValidationEvents; + hideErrorMessage?: boolean; + + // Override event props and append status as argument + onBlur?: (event: React.FocusEvent, status?: InputStatus) => void; + onFocus?: (event: React.FocusEvent, status?: InputStatus) => void; + onChange?: (event: React.FormEvent, status?: InputStatus) => void; +} + +export class Input extends PureComponent { + static defaultProps = { + className: '', + }; + + state = { + error: null, + }; + + get status() { + return this.state.error ? InputStatus.Invalid : InputStatus.Valid; + } + + get isInvalid() { + return this.status === InputStatus.Invalid; + } + + validatorAsync = (validationRules: ValidationRule[]) => { + return evt => { + const errors = validate(evt.target.value, validationRules); + this.setState(prevState => { + return { + ...prevState, + error: errors ? errors[0] : null, + }; + }); + }; + }; + + populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => { + const inputElementProps = { ...restProps }; + Object.keys(EventsWithValidation).forEach((eventName: EventsWithValidation) => { + if (hasValidationEvent(eventName, validationEvents) || restProps[eventName]) { + inputElementProps[eventName] = async evt => { + evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling + if (hasValidationEvent(eventName, validationEvents)) { + await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]); + } + if (restProps[eventName]) { + restProps[eventName].apply(null, [evt, this.status]); + } + }; + } + }); + return inputElementProps; + }; + + render() { + const { validationEvents, className, hideErrorMessage, ...restProps } = this.props; + const { error } = this.state; + const inputClassName = classNames('gf-form-input', { invalid: this.isInvalid }, className); + const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents); + + return ( +
+ + {error && !hideErrorMessage && {error}} +
+ ); + } +} diff --git a/public/app/core/components/Form/Label.tsx b/public/app/core/components/Form/Label.tsx new file mode 100644 index 00000000000..385a1b325be --- /dev/null +++ b/public/app/core/components/Form/Label.tsx @@ -0,0 +1,19 @@ +import React, { PureComponent, ReactNode } from 'react'; + +interface Props { + children: ReactNode; + htmlFor?: string; + className?: string; +} + +export class Label extends PureComponent { + render() { + const { children, htmlFor, className } = this.props; + + return ( + + ); + } +} diff --git a/public/app/core/components/Form/__snapshots__/Input.test.tsx.snap b/public/app/core/components/Form/__snapshots__/Input.test.tsx.snap new file mode 100644 index 00000000000..249d6b0be8d --- /dev/null +++ b/public/app/core/components/Form/__snapshots__/Input.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Input renders correctly 1`] = ` +
+ +
+`; diff --git a/public/app/core/components/Form/index.ts b/public/app/core/components/Form/index.ts new file mode 100644 index 00000000000..e4c8197aaa9 --- /dev/null +++ b/public/app/core/components/Form/index.ts @@ -0,0 +1,3 @@ +export { Element } from './Element'; +export { Input } from './Input'; +export { Label } from './Label'; diff --git a/public/app/core/components/JSONFormatter/JSONFormatter.tsx b/public/app/core/components/JSONFormatter/JSONFormatter.tsx new file mode 100644 index 00000000000..73c055de94b --- /dev/null +++ b/public/app/core/components/JSONFormatter/JSONFormatter.tsx @@ -0,0 +1,51 @@ +import React, { PureComponent, createRef } from 'react'; +// import JSONFormatterJS, { JSONFormatterConfiguration } from 'json-formatter-js'; +import { JsonExplorer } from 'app/core/core'; // We have made some monkey-patching of json-formatter-js so we can't switch right now + +interface Props { + className?: string; + json: {}; + config?: any; + open?: number; + onDidRender?: (formattedJson: any) => void; +} + +export class JSONFormatter extends PureComponent { + private wrapperRef = createRef(); + + static defaultProps = { + open: 3, + config: { + animateOpen: true, + }, + }; + + componentDidMount() { + this.renderJson(); + } + + componentDidUpdate() { + this.renderJson(); + } + + renderJson = () => { + const { json, config, open, onDidRender } = this.props; + const wrapperEl = this.wrapperRef.current; + const formatter = new JsonExplorer(json, open, config); + const hasChildren: boolean = wrapperEl.hasChildNodes(); + if (hasChildren) { + wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild); + } else { + wrapperEl.appendChild(formatter.render()); + } + + if (onDidRender) { + onDidRender(formatter.json); + } + }; + + render() { + const { className } = this.props; + return
; + } +} diff --git a/public/app/core/components/Label/Label.tsx b/public/app/core/components/Label/Label.tsx index 9b8fb6c6e19..362c3c577f7 100644 --- a/public/app/core/components/Label/Label.tsx +++ b/public/app/core/components/Label/Label.tsx @@ -6,6 +6,7 @@ interface Props { for?: string; children: ReactNode; width?: number; + className?: string; } export const Label: SFC = props => { diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx index d6da7c68544..749bef680bf 100644 --- a/public/app/core/components/PermissionList/AddPermission.tsx +++ b/public/app/core/components/PermissionList/AddPermission.tsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; -import { UserPicker } from 'app/core/components/Picker/UserPicker'; -import { TeamPicker, Team } from 'app/core/components/Picker/TeamPicker'; -import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker'; +import { UserPicker } from 'app/core/components/Select/UserPicker'; +import { TeamPicker, Team } from 'app/core/components/Select/TeamPicker'; +import { Select, SelectOptionItem } from 'app/core/components/Select/Select'; import { User } from 'app/types'; import { dashboardPermissionLevels, @@ -61,7 +61,7 @@ class AddPermissions extends Component { this.setState({ teamId: team && !Array.isArray(team) ? team.id : 0 }); }; - onPermissionChanged = (permission: OptionWithDescription) => { + onPermissionChanged = (permission: SelectOptionItem) => { this.setState({ permission: permission.value }); }; @@ -121,11 +121,11 @@ class AddPermissions extends Component { ) : null}
-
diff --git a/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx b/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx index ff679f67ae2..d3f9ddbb1fb 100644 --- a/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx +++ b/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker'; +import Select from 'app/core/components/Select/Select'; import { dashboardPermissionLevels } from 'app/types/acl'; export interface Props { @@ -9,6 +9,7 @@ export interface Props { export default class DisabledPermissionListItem extends Component { render() { const { item } = this.props; + const currentPermissionLevel = dashboardPermissionLevels.find(dp => dp.value === item.permission); return ( @@ -23,12 +24,12 @@ export default class DisabledPermissionListItem extends Component { Can
- {}} - disabled={true} - className={'gf-form-select-box__control--menu-right'} - value={item.permission} + i.value} - getOptionLabel={i => i.label} - value={selectedOption} - /> -
- ); - } -} - -export default DescriptionPicker; diff --git a/public/app/core/components/Picker/NoOptionsMessage.tsx b/public/app/core/components/Picker/NoOptionsMessage.tsx deleted file mode 100644 index 1d2ad4a179e..00000000000 --- a/public/app/core/components/Picker/NoOptionsMessage.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { components } from 'react-select'; -import { OptionProps } from 'react-select/lib/components/Option'; - -export interface Props { - children: Element; -} - -export const PickerOption = (props: OptionProps) => { - const { children, className } = props; - return ( - -
{children}
-
- ); -}; - -export default PickerOption; diff --git a/public/app/core/components/Picker/PickerOption.tsx b/public/app/core/components/Picker/PickerOption.tsx deleted file mode 100644 index 98bb5dc566e..00000000000 --- a/public/app/core/components/Picker/PickerOption.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { components } from 'react-select'; -import { OptionProps } from 'react-select/lib/components/Option'; - -// https://github.com/JedWatson/react-select/issues/3038 -interface ExtendedOptionProps extends OptionProps { - data: any; -} - -export const PickerOption = (props: ExtendedOptionProps) => { - const { children, data, className } = props; - return ( - -
- {data.avatarUrl && {data.label}} - {children} -
-
- ); -}; - -export default PickerOption; diff --git a/public/app/core/components/Picker/SimplePicker.tsx b/public/app/core/components/Picker/SimplePicker.tsx deleted file mode 100644 index 817cd14150f..00000000000 --- a/public/app/core/components/Picker/SimplePicker.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React, { SFC } from 'react'; -import Select from 'react-select'; -import DescriptionOption from './DescriptionOption'; -import ResetStyles from './ResetStyles'; - -interface Props { - className?: string; - defaultValue?: any; - getOptionLabel: (item: any) => string; - getOptionValue: (item: any) => string; - onSelected: (item: any) => {} | void; - options: any[]; - placeholder?: string; - width: number; - value: any; -} - -const SimplePicker: SFC = ({ - className, - defaultValue, - getOptionLabel, - getOptionValue, - onSelected, - options, - placeholder, - width, - value, -}) => { - return ( - 'No datasources found'} + value={value} + /> +
+ ); + } +} + +export default DataSourcePicker; diff --git a/public/app/core/components/Picker/IndicatorsContainer.tsx b/public/app/core/components/Select/IndicatorsContainer.tsx similarity index 82% rename from public/app/core/components/Picker/IndicatorsContainer.tsx rename to public/app/core/components/Select/IndicatorsContainer.tsx index a0998fe95b8..d4de51a2cef 100644 --- a/public/app/core/components/Picker/IndicatorsContainer.tsx +++ b/public/app/core/components/Select/IndicatorsContainer.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { components } from 'react-select'; +import React from 'react'; +import { components } from '@torkelo/react-select'; export const IndicatorsContainer = props => { const isOpen = props.selectProps.menuIsOpen; diff --git a/public/app/core/components/Select/NoOptionsMessage.tsx b/public/app/core/components/Select/NoOptionsMessage.tsx new file mode 100644 index 00000000000..5fe229340a4 --- /dev/null +++ b/public/app/core/components/Select/NoOptionsMessage.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { components } from '@torkelo/react-select'; +import { OptionProps } from '@torkelo/react-select/lib/components/Option'; + +export interface Props { + children: Element; +} + +export const NoOptionsMessage = (props: OptionProps) => { + const { children } = props; + return ( + +
+
{children}
+
+
+ ); +}; + +export default NoOptionsMessage; diff --git a/public/app/core/components/Select/OptionGroup.tsx b/public/app/core/components/Select/OptionGroup.tsx new file mode 100644 index 00000000000..a001f58c681 --- /dev/null +++ b/public/app/core/components/Select/OptionGroup.tsx @@ -0,0 +1,53 @@ +import React, { PureComponent } from 'react'; +import { GroupProps } from 'react-select/lib/components/Group'; + +interface ExtendedGroupProps extends GroupProps { + data: any; +} + +interface State { + expanded: boolean; +} + +export default class OptionGroup extends PureComponent { + state = { + expanded: false, + }; + + componentDidMount() { + if (this.props.selectProps) { + const value = this.props.selectProps.value[this.props.selectProps.value.length - 1]; + + if (value && this.props.options.some(option => option.value === value)) { + this.setState({ expanded: true }); + } + } + } + + componentDidUpdate(nextProps) { + if (nextProps.selectProps.inputValue !== '') { + this.setState({ expanded: true }); + } + } + + onToggleChildren = () => { + this.setState(prevState => ({ + expanded: !prevState.expanded, + })); + }; + + render() { + const { children, label } = this.props; + const { expanded } = this.state; + + return ( +
+
+ {label} + {' '} +
+ {expanded && children} +
+ ); + } +} diff --git a/public/app/core/components/Picker/PickerOption.test.tsx b/public/app/core/components/Select/PickerOption.test.tsx similarity index 92% rename from public/app/core/components/Picker/PickerOption.test.tsx rename to public/app/core/components/Select/PickerOption.test.tsx index 7ebcac354e8..6b4aedcfcc0 100644 --- a/public/app/core/components/Picker/PickerOption.test.tsx +++ b/public/app/core/components/Select/PickerOption.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from 'react'; import renderer from 'react-test-renderer'; import PickerOption from './PickerOption'; @@ -24,7 +24,7 @@ const model = { children: 'Model title', data: { title: 'Model title', - avatarUrl: 'url/to/avatar', + imgUrl: 'url/to/avatar', label: 'User picker label', }, className: 'class-for-user-picker', diff --git a/public/app/core/components/Select/PickerOption.tsx b/public/app/core/components/Select/PickerOption.tsx new file mode 100644 index 00000000000..d263f6f832b --- /dev/null +++ b/public/app/core/components/Select/PickerOption.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { components } from '@torkelo/react-select'; +import { OptionProps } from 'react-select/lib/components/Option'; + +// https://github.com/JedWatson/react-select/issues/3038 +interface ExtendedOptionProps extends OptionProps { + data: { + description?: string; + imgUrl?: string; + }; +} + +export const Option = (props: ExtendedOptionProps) => { + const { children, isSelected, data } = props; + + return ( + +
+ {data.imgUrl && } +
+
{children}
+ {data.description &&
{data.description}
} +
+ {isSelected &&