diff --git a/.drone.yml b/.drone.yml index b2d33dbfe1f..bf831619285 100644 --- a/.drone.yml +++ b/.drone.yml @@ -14,7 +14,7 @@ steps: - echo $DRONE_RUNNER_NAME - name: initialize - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.29/grabpl @@ -28,7 +28,7 @@ steps: DOCKERIZE_VERSION: 0.6.1 - name: lint-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - golangci-lint run --config scripts/go/configs/.golangci.toml ./pkg/... - revive -formatter stylish -config scripts/go/configs/revive.toml ./pkg/... @@ -42,7 +42,7 @@ steps: - initialize - name: codespell - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\" > words_to_ignore.txt" - codespell -I words_to_ignore.txt docs/ @@ -50,21 +50,21 @@ steps: - initialize - name: shellcheck - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl shellcheck depends_on: - initialize - name: check-dashboard-schemas - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cue export --out openapi -o - ./dashboard-schemas/... depends_on: - initialize - name: test-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - ./bin/grabpl test-backend @@ -74,7 +74,7 @@ steps: - lint-backend - name: test-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn run ci:test-frontend environment: @@ -83,7 +83,7 @@ steps: - initialize - name: build-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --variants linux-x64,linux-x64-musl,osx64,win64 --no-pull-enterprise depends_on: @@ -92,7 +92,7 @@ steps: - test-backend - name: build-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise depends_on: @@ -100,7 +100,7 @@ steps: - test-frontend - name: build-plugins - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps depends_on: @@ -108,7 +108,7 @@ steps: - lint-backend - name: package - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - . scripts/build/gpg-test-vars.sh && ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --variants linux-x64,linux-x64-musl,osx64,win64 depends_on: @@ -122,7 +122,7 @@ steps: - check-dashboard-schemas - name: end-to-end-tests-server - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 detach: true commands: - ./e2e/start-server @@ -140,7 +140,7 @@ steps: - end-to-end-tests-server - name: build-storybook - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn storybook:build - ./bin/grabpl verify-storybook @@ -150,7 +150,7 @@ steps: - package - name: build-frontend-docs - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./scripts/ci-reference-docs-lint.sh ci depends_on: @@ -167,7 +167,7 @@ steps: - build-frontend-docs - name: copy-packages-for-docker - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cp dist/*.tar.gz* packaging/docker/ depends_on: @@ -183,7 +183,7 @@ steps: - copy-packages-for-docker - name: postgres-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq postgresql-client @@ -200,7 +200,7 @@ steps: - test-frontend - name: mysql-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq default-mysql-client @@ -251,7 +251,7 @@ steps: - echo $DRONE_RUNNER_NAME - name: initialize - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.29/grabpl @@ -277,7 +277,7 @@ steps: from_secret: drone_token - name: lint-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - golangci-lint run --config scripts/go/configs/.golangci.toml ./pkg/... - revive -formatter stylish -config scripts/go/configs/revive.toml ./pkg/... @@ -291,7 +291,7 @@ steps: - initialize - name: codespell - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\" > words_to_ignore.txt" - codespell -I words_to_ignore.txt docs/ @@ -299,21 +299,21 @@ steps: - initialize - name: shellcheck - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl shellcheck depends_on: - initialize - name: check-dashboard-schemas - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cue export --out openapi -o - ./dashboard-schemas/... depends_on: - initialize - name: test-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - ./bin/grabpl test-backend @@ -323,7 +323,7 @@ steps: - lint-backend - name: test-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn run ci:test-frontend environment: @@ -332,7 +332,7 @@ steps: - initialize - name: publish-frontend-metrics - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./scripts/ci-frontend-metrics.sh | ./bin/grabpl publish-metrics $${GRAFANA_MISC_STATS_API_KEY} environment: @@ -343,7 +343,7 @@ steps: - initialize - name: build-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise depends_on: @@ -352,7 +352,7 @@ steps: - test-backend - name: build-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise depends_on: @@ -360,7 +360,7 @@ steps: - test-frontend - name: build-plugins - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin environment: @@ -371,7 +371,7 @@ steps: - lint-backend - name: package - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --sign environment: @@ -396,7 +396,7 @@ steps: - check-dashboard-schemas - name: end-to-end-tests-server - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 detach: true commands: - ./e2e/start-server @@ -414,7 +414,7 @@ steps: - end-to-end-tests-server - name: build-storybook - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn storybook:build - ./bin/grabpl verify-storybook @@ -437,14 +437,14 @@ steps: - end-to-end-tests - name: build-frontend-docs - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./scripts/ci-reference-docs-lint.sh ci depends_on: - build-frontend - name: copy-packages-for-docker - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cp dist/*.tar.gz* packaging/docker/ depends_on: @@ -474,7 +474,7 @@ steps: - copy-packages-for-docker - name: postgres-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq postgresql-client @@ -491,7 +491,7 @@ steps: - test-frontend - name: mysql-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq default-mysql-client @@ -506,15 +506,13 @@ steps: - test-backend - test-frontend -- name: release-next-npm-packages - image: grafana/build-container:1.2.30 +- name: release-canary-npm-packages + image: grafana/build-container:1.3.0 commands: - - ./node_modules/.bin/lerna bootstrap - - echo "//registry.npmjs.org/:_authToken=$${NPM_TOKEN}" >> ~/.npmrc - - ./scripts/circle-release-next-packages.sh + - ./scripts/circle-release-canary-packages.sh environment: - NPM_TOKEN: - from_secret: npm_token + GITHUB_PACKAGE_TOKEN: + from_secret: github_package_token depends_on: - end-to-end-tests @@ -620,7 +618,7 @@ steps: - echo $DRONE_RUNNER_NAME - name: initialize - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.29/grabpl @@ -705,7 +703,7 @@ steps: - echo $DRONE_RUNNER_NAME - name: initialize - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.29/grabpl @@ -720,7 +718,7 @@ steps: DOCKERIZE_VERSION: 0.6.1 - name: lint-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - golangci-lint run --config scripts/go/configs/.golangci.toml ./pkg/... - revive -formatter stylish -config scripts/go/configs/revive.toml ./pkg/... @@ -734,7 +732,7 @@ steps: - initialize - name: codespell - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\" > words_to_ignore.txt" - codespell -I words_to_ignore.txt docs/ @@ -742,21 +740,21 @@ steps: - initialize - name: shellcheck - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl shellcheck depends_on: - initialize - name: check-dashboard-schemas - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cue export --out openapi -o - ./dashboard-schemas/... depends_on: - initialize - name: test-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - ./bin/grabpl test-backend @@ -766,7 +764,7 @@ steps: - lint-backend - name: test-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn run ci:test-frontend environment: @@ -775,7 +773,7 @@ steps: - initialize - name: build-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise ${DRONE_TAG} environment: @@ -787,7 +785,7 @@ steps: - test-backend - name: build-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition oss --no-pull-enterprise ${DRONE_TAG} depends_on: @@ -795,7 +793,7 @@ steps: - test-frontend - name: build-plugins - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin environment: @@ -806,7 +804,7 @@ steps: - lint-backend - name: package - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign ${DRONE_TAG} environment: @@ -831,7 +829,7 @@ steps: - check-dashboard-schemas - name: end-to-end-tests-server - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 detach: true commands: - ./e2e/start-server @@ -849,7 +847,7 @@ steps: - end-to-end-tests-server - name: build-storybook - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn storybook:build - ./bin/grabpl verify-storybook @@ -859,7 +857,7 @@ steps: - package - name: copy-packages-for-docker - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cp dist/*.tar.gz* packaging/docker/ depends_on: @@ -889,7 +887,7 @@ steps: - copy-packages-for-docker - name: postgres-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq postgresql-client @@ -906,7 +904,7 @@ steps: - test-frontend - name: mysql-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq default-mysql-client @@ -949,10 +947,8 @@ steps: - end-to-end-tests - name: release-npm-packages - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - - ./node_modules/.bin/lerna bootstrap - - echo "//registry.npmjs.org/:_authToken=$${NPM_TOKEN}" >> ~/.npmrc - ./scripts/build/release-packages.sh ${DRONE_TAG} environment: NPM_TOKEN: @@ -1048,7 +1044,7 @@ steps: - echo $DRONE_RUNNER_NAME - name: clone - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.29/grabpl @@ -1061,7 +1057,7 @@ steps: from_secret: github_token - name: initialize - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mv bin/grabpl /tmp/ - rmdir bin @@ -1081,7 +1077,7 @@ steps: - clone - name: lint-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - golangci-lint run --config scripts/go/configs/.golangci.toml ./pkg/... - revive -formatter stylish -config scripts/go/configs/revive.toml ./pkg/... @@ -1095,7 +1091,7 @@ steps: - initialize - name: codespell - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\" > words_to_ignore.txt" - codespell -I words_to_ignore.txt docs/ @@ -1103,21 +1099,21 @@ steps: - initialize - name: shellcheck - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl shellcheck depends_on: - initialize - name: check-dashboard-schemas - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cue export --out openapi -o - ./dashboard-schemas/... depends_on: - initialize - name: test-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - ./bin/grabpl test-backend @@ -1127,7 +1123,7 @@ steps: - lint-backend - name: test-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn run ci:test-frontend environment: @@ -1136,7 +1132,7 @@ steps: - initialize - name: build-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise ${DRONE_TAG} environment: @@ -1148,7 +1144,7 @@ steps: - test-backend - name: build-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition enterprise --no-pull-enterprise ${DRONE_TAG} depends_on: @@ -1156,7 +1152,7 @@ steps: - test-frontend - name: build-plugins - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign --signing-admin environment: @@ -1167,7 +1163,7 @@ steps: - lint-backend - name: package - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign ${DRONE_TAG} environment: @@ -1192,7 +1188,7 @@ steps: - check-dashboard-schemas - name: end-to-end-tests-server - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 detach: true commands: - ./e2e/start-server @@ -1210,7 +1206,7 @@ steps: - end-to-end-tests-server - name: copy-packages-for-docker - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cp dist/*.tar.gz* packaging/docker/ depends_on: @@ -1240,7 +1236,7 @@ steps: - copy-packages-for-docker - name: postgres-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq postgresql-client @@ -1257,7 +1253,7 @@ steps: - test-frontend - name: mysql-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq default-mysql-client @@ -1391,7 +1387,7 @@ steps: - echo $DRONE_RUNNER_NAME - name: initialize - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.29/grabpl @@ -1496,7 +1492,7 @@ steps: - echo $DRONE_RUNNER_NAME - name: initialize - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.29/grabpl @@ -1511,7 +1507,7 @@ steps: DOCKERIZE_VERSION: 0.6.1 - name: lint-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - golangci-lint run --config scripts/go/configs/.golangci.toml ./pkg/... - revive -formatter stylish -config scripts/go/configs/revive.toml ./pkg/... @@ -1525,7 +1521,7 @@ steps: - initialize - name: codespell - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\" > words_to_ignore.txt" - codespell -I words_to_ignore.txt docs/ @@ -1533,21 +1529,21 @@ steps: - initialize - name: shellcheck - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl shellcheck depends_on: - initialize - name: check-dashboard-schemas - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cue export --out openapi -o - ./dashboard-schemas/... depends_on: - initialize - name: test-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - ./bin/grabpl test-backend @@ -1557,7 +1553,7 @@ steps: - lint-backend - name: test-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn run ci:test-frontend environment: @@ -1566,7 +1562,7 @@ steps: - initialize - name: build-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise v7.3.0-test environment: @@ -1578,7 +1574,7 @@ steps: - test-backend - name: build-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition oss --no-pull-enterprise v7.3.0-test depends_on: @@ -1586,7 +1582,7 @@ steps: - test-frontend - name: build-plugins - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin environment: @@ -1597,7 +1593,7 @@ steps: - lint-backend - name: package - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign v7.3.0-test environment: @@ -1622,7 +1618,7 @@ steps: - check-dashboard-schemas - name: end-to-end-tests-server - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 detach: true commands: - ./e2e/start-server @@ -1640,7 +1636,7 @@ steps: - end-to-end-tests-server - name: build-storybook - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn storybook:build - ./bin/grabpl verify-storybook @@ -1650,7 +1646,7 @@ steps: - package - name: copy-packages-for-docker - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cp dist/*.tar.gz* packaging/docker/ depends_on: @@ -1674,7 +1670,7 @@ steps: - copy-packages-for-docker - name: postgres-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq postgresql-client @@ -1691,7 +1687,7 @@ steps: - test-frontend - name: mysql-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq default-mysql-client @@ -1731,10 +1727,7 @@ steps: - end-to-end-tests - name: release-npm-packages - image: grafana/build-container:1.2.30 - commands: - - ./node_modules/.bin/lerna bootstrap - - echo "//registry.npmjs.org/:_authToken=$${NPM_TOKEN}" >> ~/.npmrc + image: grafana/build-container:1.3.0 environment: NPM_TOKEN: from_secret: npm_token @@ -1829,7 +1822,7 @@ steps: - echo $DRONE_RUNNER_NAME - name: clone - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.29/grabpl @@ -1842,7 +1835,7 @@ steps: from_secret: github_token - name: initialize - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mv bin/grabpl /tmp/ - rmdir bin @@ -1862,7 +1855,7 @@ steps: - clone - name: lint-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - golangci-lint run --config scripts/go/configs/.golangci.toml ./pkg/... - revive -formatter stylish -config scripts/go/configs/revive.toml ./pkg/... @@ -1876,7 +1869,7 @@ steps: - initialize - name: codespell - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\" > words_to_ignore.txt" - codespell -I words_to_ignore.txt docs/ @@ -1884,21 +1877,21 @@ steps: - initialize - name: shellcheck - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl shellcheck depends_on: - initialize - name: check-dashboard-schemas - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cue export --out openapi -o - ./dashboard-schemas/... depends_on: - initialize - name: test-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - ./bin/grabpl test-backend @@ -1908,7 +1901,7 @@ steps: - lint-backend - name: test-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn run ci:test-frontend environment: @@ -1917,7 +1910,7 @@ steps: - initialize - name: build-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise v7.3.0-test environment: @@ -1929,7 +1922,7 @@ steps: - test-backend - name: build-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition enterprise --no-pull-enterprise v7.3.0-test depends_on: @@ -1937,7 +1930,7 @@ steps: - test-frontend - name: build-plugins - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign --signing-admin environment: @@ -1948,7 +1941,7 @@ steps: - lint-backend - name: package - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign v7.3.0-test environment: @@ -1973,7 +1966,7 @@ steps: - check-dashboard-schemas - name: end-to-end-tests-server - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 detach: true commands: - ./e2e/start-server @@ -1991,7 +1984,7 @@ steps: - end-to-end-tests-server - name: copy-packages-for-docker - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cp dist/*.tar.gz* packaging/docker/ depends_on: @@ -2015,7 +2008,7 @@ steps: - copy-packages-for-docker - name: postgres-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq postgresql-client @@ -2032,7 +2025,7 @@ steps: - test-frontend - name: mysql-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq default-mysql-client @@ -2166,7 +2159,7 @@ steps: - echo $DRONE_RUNNER_NAME - name: initialize - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.29/grabpl @@ -2253,7 +2246,7 @@ steps: - echo $DRONE_RUNNER_NAME - name: initialize - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.29/grabpl @@ -2267,7 +2260,7 @@ steps: DOCKERIZE_VERSION: 0.6.1 - name: lint-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - golangci-lint run --config scripts/go/configs/.golangci.toml ./pkg/... - revive -formatter stylish -config scripts/go/configs/revive.toml ./pkg/... @@ -2281,7 +2274,7 @@ steps: - initialize - name: codespell - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\" > words_to_ignore.txt" - codespell -I words_to_ignore.txt docs/ @@ -2289,21 +2282,21 @@ steps: - initialize - name: shellcheck - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl shellcheck depends_on: - initialize - name: check-dashboard-schemas - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cue export --out openapi -o - ./dashboard-schemas/... depends_on: - initialize - name: test-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - ./bin/grabpl test-backend @@ -2313,7 +2306,7 @@ steps: - lint-backend - name: test-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn run ci:test-frontend environment: @@ -2322,7 +2315,7 @@ steps: - initialize - name: build-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise depends_on: @@ -2331,7 +2324,7 @@ steps: - test-backend - name: build-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise depends_on: @@ -2339,7 +2332,7 @@ steps: - test-frontend - name: build-plugins - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin environment: @@ -2350,7 +2343,7 @@ steps: - lint-backend - name: package - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --sign environment: @@ -2375,7 +2368,7 @@ steps: - check-dashboard-schemas - name: end-to-end-tests-server - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 detach: true commands: - ./e2e/start-server @@ -2393,7 +2386,7 @@ steps: - end-to-end-tests-server - name: build-storybook - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn storybook:build - ./bin/grabpl verify-storybook @@ -2403,7 +2396,7 @@ steps: - package - name: copy-packages-for-docker - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cp dist/*.tar.gz* packaging/docker/ depends_on: @@ -2427,7 +2420,7 @@ steps: - copy-packages-for-docker - name: postgres-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq postgresql-client @@ -2444,7 +2437,7 @@ steps: - test-frontend - name: mysql-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq default-mysql-client @@ -2556,7 +2549,7 @@ steps: - echo $DRONE_RUNNER_NAME - name: clone - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.29/grabpl @@ -2569,7 +2562,7 @@ steps: from_secret: github_token - name: initialize - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - mv bin/grabpl /tmp/ - rmdir bin @@ -2588,7 +2581,7 @@ steps: - clone - name: lint-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - golangci-lint run --config scripts/go/configs/.golangci.toml ./pkg/... - revive -formatter stylish -config scripts/go/configs/revive.toml ./pkg/... @@ -2602,7 +2595,7 @@ steps: - initialize - name: codespell - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\" > words_to_ignore.txt" - codespell -I words_to_ignore.txt docs/ @@ -2610,21 +2603,21 @@ steps: - initialize - name: shellcheck - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl shellcheck depends_on: - initialize - name: check-dashboard-schemas - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cue export --out openapi -o - ./dashboard-schemas/... depends_on: - initialize - name: test-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - ./bin/grabpl test-backend @@ -2634,7 +2627,7 @@ steps: - lint-backend - name: test-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn run ci:test-frontend environment: @@ -2643,7 +2636,7 @@ steps: - initialize - name: build-backend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise depends_on: @@ -2652,7 +2645,7 @@ steps: - test-backend - name: build-frontend - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition enterprise --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise depends_on: @@ -2660,7 +2653,7 @@ steps: - test-frontend - name: build-plugins - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign --signing-admin environment: @@ -2671,7 +2664,7 @@ steps: - lint-backend - name: package - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - ./bin/grabpl package --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --sign environment: @@ -2696,7 +2689,7 @@ steps: - check-dashboard-schemas - name: end-to-end-tests-server - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 detach: true commands: - ./e2e/start-server @@ -2714,7 +2707,7 @@ steps: - end-to-end-tests-server - name: build-storybook - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - yarn storybook:build - ./bin/grabpl verify-storybook @@ -2724,7 +2717,7 @@ steps: - package - name: copy-packages-for-docker - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - cp dist/*.tar.gz* packaging/docker/ depends_on: @@ -2748,7 +2741,7 @@ steps: - copy-packages-for-docker - name: postgres-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq postgresql-client @@ -2765,7 +2758,7 @@ steps: - test-frontend - name: mysql-integration-tests - image: grafana/build-container:1.2.30 + image: grafana/build-container:1.3.0 commands: - apt-get update - apt-get install -yq default-mysql-client diff --git a/.eslintrc b/.eslintrc index 6df70e8521f..5d7f9ea5c0d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,6 +1,9 @@ { "extends": ["@grafana/eslint-config"], "root": true, + "plugins": [ + "no-only-tests" + ], "overrides": [ { "files": [ @@ -12,13 +15,7 @@ "react-hooks/rules-of-hooks": "off", "react-hooks/exhaustive-deps": "off", "react/prop-types": "off", - "react/no-unescaped-entities": "off", - "react/display-name": "off", - "react/no-deprecated": "off", - "react/no-unknown-property": "off", - "react/no-children-prop": "off", - "react/no-find-dom-node": "off", - "react/no-render-return-value": "off" + "no-only-tests/no-only-tests": "error" } } ] diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 350a6100ff7..5fd37546d61 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,7 +24,7 @@ jobs: uses: actions/setup-node@v1 id: generate-docs with: - node-version: '12' + node-version: '14' - run: yarn install --pure-lockfile --no-progress - run: ./scripts/ci-reference-docs-build.sh - name: publish-to-git @@ -41,4 +41,4 @@ jobs: - shell: bash run: | test -n "${{ steps.publish.outputs.commit_hash }}" - test -n "${{ steps.publish.outputs.working_directory }}" \ No newline at end of file + test -n "${{ steps.publish.outputs.working_directory }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 619214f863f..22202d9d3a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,7 +59,7 @@ In order not to break all angular panel plugins and data sources we have some cu * **Dashboard**: Fixes kiosk state after being redirected to login page and back. [#29273](https://github.com/grafana/grafana/pull/29273), [@torkelo](https://github.com/torkelo) * **InfluxDB**: Update flux library to fix support for boolean label values. [#29310](https://github.com/grafana/grafana/pull/29310), [@ryantxu](https://github.com/ryantxu) * **Security**: Fixes minor security issue with alert notification webhooks that allowed GET & DELETE requests. [#29330](https://github.com/grafana/grafana/pull/29330), [@wbrowne](https://github.com/wbrowne) -* **Table**: Fix incorrect condition for rendering table filter. [#29165](https://github.com/grafana/grafana/pull/29165), [@dprokop](https://github.com/dprokop) +* **Table**: Fixes issues with phantom extra 0 for zero values. [#29165](https://github.com/grafana/grafana/pull/29165), [@dprokop](https://github.com/dprokop) diff --git a/Dockerfile b/Dockerfile index d44436bdb06..da5d72a9567 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.19.0-alpine3.12 as js-builder +FROM node:14.15.1-alpine3.12 as js-builder WORKDIR /usr/src/app/ @@ -7,14 +7,14 @@ COPY packages packages RUN yarn install --pure-lockfile --no-progress -COPY Gruntfile.js tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ +COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ COPY public public COPY tools tools COPY scripts scripts COPY emails emails ENV NODE_ENV production -RUN ./node_modules/.bin/grunt build +RUN yarn build FROM golang:1.15.1-alpine3.12 as go-builder @@ -64,6 +64,7 @@ RUN export GF_GID_NAME=$(getent group $GF_GID | cut -d':' -f1) && \ mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ "$GF_PATHS_PROVISIONING/dashboards" \ "$GF_PATHS_PROVISIONING/notifiers" \ + "$GF_PATHS_PROVISIONING/plugins" \ "$GF_PATHS_LOGS" \ "$GF_PATHS_PLUGINS" \ "$GF_PATHS_DATA" && \ diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index 195d3b9289b..00ed09edb09 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -1,4 +1,4 @@ -FROM node:12.19.0-slim AS js-builder +FROM node:14.15.1-slim AS js-builder WORKDIR /usr/src/app/ @@ -7,14 +7,14 @@ COPY packages packages RUN yarn install --pure-lockfile -COPY Gruntfile.js tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ +COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ COPY public public COPY tools tools COPY scripts scripts COPY emails emails ENV NODE_ENV production -RUN ./node_modules/.bin/grunt build +RUN yarn build FROM golang:1.15.1 AS go-builder @@ -58,6 +58,7 @@ RUN mkdir -p "$GF_PATHS_HOME/.aws" && \ mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ "$GF_PATHS_PROVISIONING/dashboards" \ "$GF_PATHS_PROVISIONING/notifiers" \ + "$GF_PATHS_PROVISIONING/plugins" \ "$GF_PATHS_LOGS" \ "$GF_PATHS_PLUGINS" \ "$GF_PATHS_DATA" && \ diff --git a/Gruntfile.js b/Gruntfile.js deleted file mode 100644 index b07bca92a13..00000000000 --- a/Gruntfile.js +++ /dev/null @@ -1,66 +0,0 @@ -'use strict'; -module.exports = function(grunt) { - var os = require('os'); - var config = { - pkg: grunt.file.readJSON('package.json'), - baseDir: '.', - srcDir: 'public', - genDir: 'public_gen', - destDir: 'dist', - tempDir: 'tmp', - platform: process.platform.replace('win32', 'windows'), - enterprise: false, - libc: null, - }; - - if (grunt.option('platform')) { - config.platform = grunt.option('platform'); - } - - if (grunt.option('enterprise')) { - config.enterprise = true; - } - - if (grunt.option('arch')) { - config.arch = grunt.option('arch'); - } else { - config.arch = os.arch(); - - if (process.platform.match(/^win/)) { - config.arch = process.env.hasOwnProperty('ProgramFiles(x86)') ? 'x64' : 'x86'; - } - } - - if (grunt.option('libc')) { - config.libc = grunt.option('libc'); - } - - config.pkg.version = grunt.option('pkgVer') || config.pkg.version; - - console.log('Version', config.pkg.version); - - // load plugins - require('load-grunt-tasks')(grunt); - - // load task definitions - grunt.loadTasks('./scripts/grunt'); - - // Utility function to load plugin settings into config - function loadConfig(config, path) { - require('glob') - .sync('*', { cwd: path }) - .forEach(function(option) { - var key = option.replace(/\.js$/, ''); - // If key already exists, extend it. It is your responsibility to avoid naming collisions - config[key] = config[key] || {}; - grunt.util._.extend(config[key], require(path + option)(config, grunt)); - }); - // technically not required - return config; - } - - // Merge that object with what with whatever we have here - loadConfig(config, './scripts/grunt/options/'); - // pass the config to grunt - grunt.initConfig(config); -}; diff --git a/build.go b/build.go index 906678fd485..4fcb08e6d8b 100644 --- a/build.go +++ b/build.go @@ -30,13 +30,13 @@ const ( var ( //versionRe = regexp.MustCompile(`-[0-9]{1,3}-g[0-9a-f]{5,10}`) - goarch string - goos string - gocc string - cgo bool - libc string - pkgArch string - version string = "v1" + goarch string + goos string + gocc string + cgo bool + libc string + pkgArch string + version string = "v1" buildTags []string // deb & rpm does not support semver so have to handle their version a little differently linuxPackageVersion string = "v1" @@ -125,34 +125,7 @@ func main() { } case "build-frontend": - grunt(gruntBuildArg("build")...) - - case "test": - test("./pkg/...") - grunt("test") - - case "package": - grunt(gruntBuildArg("build")...) - grunt(gruntBuildArg("package")...) - if goos == linux { - createLinuxPackages() - } - - case "package-only": - grunt(gruntBuildArg("package")...) - if goos == linux { - createLinuxPackages() - } - case "pkg-archive": - grunt(gruntBuildArg("package")...) - - case "pkg-rpm": - grunt(gruntBuildArg("release")...) - createRpmPackages() - - case "pkg-deb": - grunt(gruntBuildArg("release")...) - createDebPackages() + yarn("build") case "sha-dist": shaFilesInDist() @@ -233,202 +206,8 @@ func readVersionFromPackageJson() { } } -type linuxPackageOptions struct { - packageType string - packageArch string - homeDir string - homeBinDir string - binPath string - serverBinPath string - cliBinPath string - configDir string - ldapFilePath string - etcDefaultPath string - etcDefaultFilePath string - initdScriptFilePath string - systemdServiceFilePath string - - postinstSrc string - initdScriptSrc string - defaultFileSrc string - systemdFileSrc string - cliBinaryWrapperSrc string - - depends []string -} - -func createDebPackages() { - debPkgArch := pkgArch - if pkgArch == "armv7" || pkgArch == "armv6" { - debPkgArch = "armhf" - } - - createPackage(linuxPackageOptions{ - packageType: "deb", - packageArch: debPkgArch, - homeDir: "/usr/share/grafana", - homeBinDir: "/usr/share/grafana/bin", - binPath: "/usr/sbin", - configDir: "/etc/grafana", - etcDefaultPath: "/etc/default", - etcDefaultFilePath: "/etc/default/grafana-server", - initdScriptFilePath: "/etc/init.d/grafana-server", - systemdServiceFilePath: "/usr/lib/systemd/system/grafana-server.service", - - postinstSrc: "packaging/deb/control/postinst", - initdScriptSrc: "packaging/deb/init.d/grafana-server", - defaultFileSrc: "packaging/deb/default/grafana-server", - systemdFileSrc: "packaging/deb/systemd/grafana-server.service", - cliBinaryWrapperSrc: "packaging/wrappers/grafana-cli", - - depends: []string{"adduser", "libfontconfig1"}, - }) -} - -func createRpmPackages() { - rpmPkgArch := pkgArch - switch { - case pkgArch == "armv7": - rpmPkgArch = "armhfp" - case pkgArch == "arm64": - rpmPkgArch = "aarch64" - } - createPackage(linuxPackageOptions{ - packageType: "rpm", - packageArch: rpmPkgArch, - homeDir: "/usr/share/grafana", - homeBinDir: "/usr/share/grafana/bin", - binPath: "/usr/sbin", - configDir: "/etc/grafana", - etcDefaultPath: "/etc/sysconfig", - etcDefaultFilePath: "/etc/sysconfig/grafana-server", - initdScriptFilePath: "/etc/init.d/grafana-server", - systemdServiceFilePath: "/usr/lib/systemd/system/grafana-server.service", - - postinstSrc: "packaging/rpm/control/postinst", - initdScriptSrc: "packaging/rpm/init.d/grafana-server", - defaultFileSrc: "packaging/rpm/sysconfig/grafana-server", - systemdFileSrc: "packaging/rpm/systemd/grafana-server.service", - cliBinaryWrapperSrc: "packaging/wrappers/grafana-cli", - - depends: []string{"/sbin/service", "fontconfig", "freetype", "urw-fonts"}, - }) -} - -func createLinuxPackages() { - if !skipDebGen { - createDebPackages() - } - - if !skipRpmGen { - createRpmPackages() - } -} - -func createPackage(options linuxPackageOptions) { - packageRoot, _ := ioutil.TempDir("", "grafana-linux-pack") - - // create directories - runPrint("mkdir", "-p", filepath.Join(packageRoot, options.homeDir)) - runPrint("mkdir", "-p", filepath.Join(packageRoot, options.configDir)) - runPrint("mkdir", "-p", filepath.Join(packageRoot, "/etc/init.d")) - runPrint("mkdir", "-p", filepath.Join(packageRoot, options.etcDefaultPath)) - runPrint("mkdir", "-p", filepath.Join(packageRoot, "/usr/lib/systemd/system")) - runPrint("mkdir", "-p", filepath.Join(packageRoot, "/usr/sbin")) - - // copy grafana-cli wrapper - runPrint("cp", "-p", options.cliBinaryWrapperSrc, filepath.Join(packageRoot, "/usr/sbin/"+cliBinary)) - - // copy grafana-server binary - runPrint("cp", "-p", filepath.Join(workingDir, "tmp/bin/"+serverBinary), filepath.Join(packageRoot, "/usr/sbin/"+serverBinary)) - - // copy init.d script - runPrint("cp", "-p", options.initdScriptSrc, filepath.Join(packageRoot, options.initdScriptFilePath)) - // copy environment var file - runPrint("cp", "-p", options.defaultFileSrc, filepath.Join(packageRoot, options.etcDefaultFilePath)) - // copy systemd file - runPrint("cp", "-p", options.systemdFileSrc, filepath.Join(packageRoot, options.systemdServiceFilePath)) - // copy release files - runPrint("cp", "-a", filepath.Join(workingDir, "tmp")+"/.", filepath.Join(packageRoot, options.homeDir)) - // remove bin path - runPrint("rm", "-rf", filepath.Join(packageRoot, options.homeDir, "bin")) - - // create /bin within home - runPrint("mkdir", "-p", filepath.Join(packageRoot, options.homeBinDir)) - // The grafana-cli binary is exposed through a wrapper to ensure a proper - // configuration is in place. To enable that, we need to store the original - // binary in a separate location to avoid conflicts. - runPrint("cp", "-p", filepath.Join(workingDir, "tmp/bin/"+cliBinary), filepath.Join(packageRoot, options.homeBinDir, cliBinary)) - - args := []string{ - "-s", "dir", - "--description", "Grafana", - "-C", packageRoot, - "--url", "https://grafana.com", - "--maintainer", "contact@grafana.com", - "--config-files", options.initdScriptFilePath, - "--config-files", options.etcDefaultFilePath, - "--config-files", options.systemdServiceFilePath, - "--after-install", options.postinstSrc, - - "--version", linuxPackageVersion, - "-p", "./dist", - } - - name := "grafana" - if enterprise { - name += "-enterprise" - args = append(args, "--replaces", "grafana") - } - fmt.Printf("pkgArch is set to '%s', generated arch is '%s'\n", pkgArch, options.packageArch) - if pkgArch == "armv6" { - name += "-rpi" - args = append(args, "--replaces", "grafana") - } - args = append(args, "--name", name) - - description := "Grafana" - if enterprise { - description += " Enterprise" - } - - if !enterprise { - args = append(args, "--license", "\"Apache 2.0\"") - } - - if options.packageType == "rpm" { - args = append(args, "--rpm-posttrans", "packaging/rpm/control/posttrans") - } - - if options.packageType == "deb" { - args = append(args, "--deb-no-default-config-files") - } - - if options.packageArch != "" { - args = append(args, "-a", options.packageArch) - } - - if linuxPackageIteration != "" { - args = append(args, "--iteration", linuxPackageIteration) - } - - // add dependencies - for _, dep := range options.depends { - args = append(args, "--depends", dep) - } - - args = append(args, ".") - - fmt.Println("Creating package: ", options.packageType) - runPrint("fpm", append([]string{"-t", options.packageType}, args...)...) -} - -func grunt(params ...string) { - if runtime.GOOS == windows { - runPrint(`.\node_modules\.bin\grunt`, params...) - } else { - runPrint("./node_modules/.bin/grunt", params...) - } +func yarn(params ...string) { + runPrint(`yarn run`, params...) } func genPackageVersion() string { @@ -439,24 +218,6 @@ func genPackageVersion() string { } } -func gruntBuildArg(task string) []string { - args := []string{task} - args = append(args, fmt.Sprintf("--pkgVer=%v", genPackageVersion())) - if pkgArch != "" { - args = append(args, fmt.Sprintf("--arch=%v", pkgArch)) - } - if libc != "" { - args = append(args, fmt.Sprintf("--libc=%s", libc)) - } - if enterprise { - args = append(args, "--enterprise") - } - - args = append(args, fmt.Sprintf("--platform=%v", goos)) - - return args -} - func setup() { args := []string{"install", "-v"} if goos == windows { diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index 26ed45cacdf..5f3d4f1853d 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -20,7 +20,7 @@ We recommend using [Homebrew](https://brew.sh/) for installing any missing depen ``` brew install git brew install go -brew install node@12 +brew install node@14 npm install -g yarn ``` diff --git a/contribute/style-guides/documentation-markdown-guide.md b/contribute/style-guides/documentation-markdown-guide.md index 070481ad817..b773edc3732 100644 --- a/contribute/style-guides/documentation-markdown-guide.md +++ b/contribute/style-guides/documentation-markdown-guide.md @@ -116,12 +116,29 @@ Build a list of points - an unordered or unnumbered list - by using "\-" (hyphen ## Images +_Do not_ use image shortcodes at this time. + Include images in a document using the following syntax: -**Example** \!\[Grafana Logo](/link/to/grafanalogo/logo.png) +``` +![Alt text](link to image, starting with /img/docs/ if it is to an internal image) +``` + +**Examples:** +- \!\[Grafana Logo](/link/to/grafanalogo/logo.png) +- \!\[Example](/img/docs/folder_name/alert_test_rule.png) This follows the format of "!", alt text wrapped in "[]" and the link URL wrapped in "()". +You can also use HTML such as the following: +``` +Example image +``` + +In most cases, use the markdown syntax rather than the HTML syntax. Only use the HTML if you need to change the image in ways unsupported by Markdown. + ## Comments You can include comments that will not appear in published markdown using the following syntax: diff --git a/contribute/style-guides/documentation-style-guide.md b/contribute/style-guides/documentation-style-guide.md index 3e224196276..6845a1fc302 100644 --- a/contribute/style-guides/documentation-style-guide.md +++ b/contribute/style-guides/documentation-style-guide.md @@ -110,12 +110,13 @@ If that is the case, then use the following format for headings: - Assume all graphics will be exclusively viewed on the web. - Maximum image size is 3840px X 2160px. - Screenshots should be readable, but not too large. +- _Do not_ use image shortcodes. Follow the guidance in the [Grafana markdown guide](https://github.com/grafana/grafana/blob/master/contribute/style-guides/documentation-markdown-guide.md#images). +- Markdown image links are preferred. Only use the HTML image links if you need to style the image in ways unsupported in Markdown. ### Capitalization - Grafana, Loki, and Prometheus are always capitalized unless part of a code block. - API names are always Title Case, followed by "API"—for example, "Dashboard Permissions API" -- Git is always capitalized, unless part of a code block. - Abbreviations are always capitalized (such as API, HTTP, ID, JSON, SQL, or URL) unless they are part of a code block. - Menu and submenu titles always use sentence case: capitalize the first word, and lowercase the rest. - "Dashboards" when referring to the submenu title. @@ -125,6 +126,22 @@ If that is the case, then use the following format for headings: - Lowercase "dashboards" when referring to multiple dashboards. - **Exceptions:** If a term is lowercased in the Grafana UI, then match the UI. +#### Git, GitHub + +Git is always capitalized, unless part of a code block. GitHub is the correct spelling and capitalization. + +#### Integrations + +In general, "integration" is not capitalized. Only capitalize it if it is capitalized in the UI or part of a proper noun, like the name of a specific integration. + +The first letter of the name of an integration is always capitalized, even if the original named source is lowercase. + +**Examples:** +- MySQL Integration +- CockroachDB Integration +- Etcd Integration +- I installed an integration on my local Grafana. + ### Links and references When referencing another document, use "Refer to" rather than alternatives such as "See" or "Check out." @@ -192,6 +209,18 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk Grafana products has some words, abbreviations, and terms particular to the Grafana discourse community. +#### changelog + +One word, not two. + +**Example** + +- Read the full changelog. + +**Exception:** + +- When referring to the file containing the official changelog, use the filename: `CHANGELOG.md`. + #### checkout, check out Two words if used as a verb, one word if used as a noun. diff --git a/devenv/dev-dashboards/feature-templating/templating-textbox-e2e-scenarios.json b/devenv/dev-dashboards/feature-templating/templating-textbox-e2e-scenarios.json new file mode 100644 index 00000000000..37c853cafcc --- /dev/null +++ b/devenv/dev-dashboards/feature-templating/templating-textbox-e2e-scenarios.json @@ -0,0 +1,116 @@ +{ + "__inputs": [ + { + "name": "DS_GDEV-TESTDATA", + "label": "gdev-testdata", + "description": "", + "type": "datasource", + "pluginId": "testdata", + "pluginName": "TestData DB" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "7.4.0-pre" + }, + { + "type": "datasource", + "id": "testdata", + "name": "TestData DB", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1606804991052, + "links": [], + "panels": [ + { + "datasource": "${DS_GDEV-TESTDATA}", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "content": "# variable: ${text}\n ", + "mode": "markdown" + }, + "pluginVersion": "7.4.0-pre", + "timeFrom": null, + "timeShift": null, + "title": "Panel Title", + "type": "text" + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "default value", + "value": "default value" + }, + "description": null, + "error": null, + "hide": 0, + "label": null, + "name": "text", + "options": [ + { + "selected": true, + "text": "default value", + "value": "default value" + } + ], + "query": "default value", + "skipUrlSync": false, + "type": "textbox" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Templating - Textbox e2e scenarios", + "uid": "AejrN1AMz", + "version": 1 +} diff --git a/devenv/docker/blocks/influxdb/docker-compose.yaml b/devenv/docker/blocks/influxdb/docker-compose.yaml index 0a8c7c2d7de..a83e1553cce 100644 --- a/devenv/docker/blocks/influxdb/docker-compose.yaml +++ b/devenv/docker/blocks/influxdb/docker-compose.yaml @@ -8,6 +8,7 @@ environment: INFLUXDB_ADMIN_USER: grafana INFLUXDB_ADMIN_PASSWORD: grafana + INFLUXDB_DB: site volumes: - ./docker/blocks/influxdb/influxdb.conf:/etc/influxdb/influxdb.conf diff --git a/devenv/docker/blocks/mysql_tests/.env b/devenv/docker/blocks/mysql_tests/.env new file mode 100644 index 00000000000..29440f18d52 --- /dev/null +++ b/devenv/docker/blocks/mysql_tests/.env @@ -0,0 +1 @@ +mysql_version=5.6 diff --git a/devenv/docker/blocks/mysql_tests/Dockerfile b/devenv/docker/blocks/mysql_tests/Dockerfile index 89e16bc2ed6..63bc031a682 100644 --- a/devenv/docker/blocks/mysql_tests/Dockerfile +++ b/devenv/docker/blocks/mysql_tests/Dockerfile @@ -1,3 +1,4 @@ -FROM mysql:5.6 +ARG mysql_version=5.6 +FROM mysql:${mysql_version} ADD setup.sql /docker-entrypoint-initdb.d CMD ["mysqld"] diff --git a/devenv/docker/blocks/mysql_tests/docker-compose.yaml b/devenv/docker/blocks/mysql_tests/docker-compose.yaml index a7509d47880..485657cf152 100644 --- a/devenv/docker/blocks/mysql_tests/docker-compose.yaml +++ b/devenv/docker/blocks/mysql_tests/docker-compose.yaml @@ -1,6 +1,8 @@ mysqltests: build: context: docker/blocks/mysql_tests + args: + - mysql_version=${mysql_version} environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: grafana_tests diff --git a/devenv/docker/blocks/postgres_tests/.env b/devenv/docker/blocks/postgres_tests/.env new file mode 100644 index 00000000000..2494e5f2ee9 --- /dev/null +++ b/devenv/docker/blocks/postgres_tests/.env @@ -0,0 +1 @@ +postgres_version=9.3 diff --git a/devenv/docker/blocks/postgres_tests/Dockerfile b/devenv/docker/blocks/postgres_tests/Dockerfile index df188e1094d..f8deb7d6815 100644 --- a/devenv/docker/blocks/postgres_tests/Dockerfile +++ b/devenv/docker/blocks/postgres_tests/Dockerfile @@ -1,3 +1,4 @@ -FROM postgres:9.3 +ARG postgres_version=9.3 +FROM postgres:${postgres_version} ADD setup.sql /docker-entrypoint-initdb.d CMD ["postgres"] diff --git a/devenv/docker/blocks/postgres_tests/docker-compose.yaml b/devenv/docker/blocks/postgres_tests/docker-compose.yaml index 7e6da7d8517..fbd68ec0749 100644 --- a/devenv/docker/blocks/postgres_tests/docker-compose.yaml +++ b/devenv/docker/blocks/postgres_tests/docker-compose.yaml @@ -1,6 +1,8 @@ postgrestest: build: context: docker/blocks/postgres_tests + args: + - postgres_version=${postgres_version} environment: POSTGRES_USER: grafanatest POSTGRES_PASSWORD: grafanatest diff --git a/devenv/docker/blocks/sensugo/backend.yml b/devenv/docker/blocks/sensugo/backend.yml new file mode 100644 index 00000000000..1e0047daf3c --- /dev/null +++ b/devenv/docker/blocks/sensugo/backend.yml @@ -0,0 +1,63 @@ +--- +# Sensu backend configuration + +## +# backend configuration +## +state-dir: "/var/lib/sensu/sensu-backend" +#cache-dir: "/var/cache/sensu/sensu-backend" +#config-file: "/etc/sensu/backend.yml" +#debug: false +#deregistration-handler: "example_handler" +#log-level: "warn" # available log levels: panic, fatal, error, warn, info, debug + +## +# agent configuration +## +#agent-host: "[::]" # listen on all IPv4 and IPv6 addresses +#agent-port: 8081 + +## +# api configuration +## +#api-listen-address: "[::]:8080" # listen on all IPv4 and IPv6 addresses +#api-url: "http://localhost:8080" + +## +# dashboard configuration +## +#dashboard-cert-file: "/path/to/ssl/cert.pem" +#dashboard-key-file: "/path/to/ssl/key.pem" +#dashboard-host: "[::]" # listen on all IPv4 and IPv6 addresses +#dashboard-port: 3000 + +## +# ssl configuration +## +#cert-file: "/path/to/ssl/cert.pem" +#key-file: "/path/to/ssl/key.pem" +#trusted-ca-file: "/path/to/trusted-certificate-authorities.pem" +#insecure-skip-tls-verify: false + +## +# store configuration +## +#etcd-advertise-client-urls: "http://localhost:2379" +#etcd-cert-file: "/path/to/ssl/cert.pem" +#etcd-client-cert-auth: false +#etcd-initial-advertise-peer-urls: "http://127.0.0.1:2380" +#etcd-initial-cluster: "default=http://127.0.0.1:2380" +#etcd-initial-cluster-state: "new" # new or existing +#etcd-initial-cluster-token: "sensu" +#etcd-key-file: "/path/to/ssl/key.pem" +#etcd-listen-client-urls: "http://127.0.0.1:2379" +#etcd-listen-peer-urls: "http://127.0.0.1:2380" +#etcd-name: "default" +#etcd-peer-cert-file: "/path/to/ssl/cert.pem" +#etcd-peer-client-cert-auth: false +#etcd-peer-key-file: "/path/to/ssl/key.pem" +#etcd-peer-trusted-ca-file: "/path/to/ssl/key.pem" +#etcd-trusted-ca-file: "/path/to/ssl/key.pem" +#no-embed-etcd: false +#etcd-cipher-suits +# - TLS_EXAMPLE diff --git a/devenv/docker/blocks/sensugo/docker-compose.yaml b/devenv/docker/blocks/sensugo/docker-compose.yaml new file mode 100644 index 00000000000..69de571d35f --- /dev/null +++ b/devenv/docker/blocks/sensugo/docker-compose.yaml @@ -0,0 +1,18 @@ + sensu-backend: + image: sensu/sensu:latest + container_name: sensu-backend + ports: + - "3080:3000" + - "8080:8080" + - "8081:8081" + volumes: + - ./docker/blocks/sensugo/backend.yml:/etc/sensu/backend.yml + - sensu-backend-data:/var/lib/sensu/etcd + environment: + SENSU_BACKEND_CLUSTER_ADMIN_USERNAME: admin + SENSU_BACKEND_CLUSTER_ADMIN_PASSWORD: Password123 + command: "sensu-backend start --log-level info" + +volumes: + sensu-backend-data: {} + diff --git a/devenv/docker/blocks/sensugo/notes.md b/devenv/docker/blocks/sensugo/notes.md new file mode 100644 index 00000000000..f6f0b7e74b8 --- /dev/null +++ b/devenv/docker/blocks/sensugo/notes.md @@ -0,0 +1,39 @@ +# Notes on Sensu Go Docker Block + +The API Key needed to connect to Sensu Go has to be created manually. + +## Create the API Key + +`docker exec -it sensu-backend /bin/ash` + +Configure the `sensuctl` command using the pre-set username and password: + +```bash +sensuctl configure -n --url http://127.0.0.1:8080 --username admin --password 'Password123' --namespace default +``` + +Generate the API Key: + +```bash +sensuctl api-key grant admin +``` + +The output should look similar to this: + +``` +Created: /api/core/v2/apikeys/0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d +``` + + +## Configuring the notification channel + +### Backend URL + +The Backend URL is the API port (8080) forwarded to the container, it should be +`http://localhost:8080` + +### API Key + +The `0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d` in the output above is the API Key +to use in configuring the Sensu Go notification channel. + diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index e288425e4c6..1ce057d93ca 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -147,19 +147,20 @@ Since not all datasources have the same configuration settings we only have the | tlsAuthWithCACert | boolean | _All_ | Enable TLS authentication using CA cert | | tlsSkipVerify | boolean | _All_ | Controls whether a client verifies the server's certificate chain and host name. | | graphiteVersion | string | Graphite | Graphite version | -| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL and MSSQL | Lowest interval/step value that should be used for this data source | -| httpMode | string | Influxdb | HTTP Method. 'GET', 'POST', defaults to GET | -| httpMethod | string | Prometheus | HTTP Method. 'GET', 'POST', defaults to GET | +| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL and MSSQL | Lowest interval/step value that should be used for this data source. | +| httpMode | string | Influxdb | HTTP Method. 'GET', 'POST', defaults to GET | +| httpMethod | string | Prometheus | HTTP Method. 'GET', 'POST', defaults to GET | | esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56/60/70) | | timeField | string | Elasticsearch | Which field that should be used as timestamp | | interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' | | logMessageField | string | Elasticsearch | Which field should be used as the log message | | logLevelField | string | Elasticsearch | Which field should be used to indicate the priority of the log message | -| sigV4AuthType | string | Elasticsearch | SigV4 auth provider. default/credentials/keys | -| sigV4ExternalId | string | Elasticsearch | Optional SigV4 External ID | -| sigV4AssumeRoleArn | string | Elasticsearch | Optional SigV4 ARN role to assume | -| sigV4Region | string | Elasticsearch | SigV4 AWS region | -| sigV4Profile | string | Elasticsearch | Optional SigV4 credentials profile | +| sigV4Auth | boolean | Elasticsearch | Enable usage of SigV4 | +| sigV4AuthType | string | Elasticsearch | SigV4 auth provider. default/credentials/keys | +| sigV4ExternalId | string | Elasticsearch | Optional SigV4 External ID | +| sigV4AssumeRoleArn | string | Elasticsearch | Optional SigV4 ARN role to assume | +| sigV4Region | string | Elasticsearch | SigV4 AWS region | +| sigV4Profile | string | Elasticsearch | Optional SigV4 credentials profile | | authType | string | Cloudwatch | Auth provider. default/credentials/keys | | externalId | string | Cloudwatch | Optional External ID | | assumeRoleArn | string | Cloudwatch | Optional ARN role to assume | @@ -194,7 +195,7 @@ Secure json data is a map of settings that will be encrypted with [secret key]({ | basicAuthPassword | string | _All_ | password for basic authentication | | accessKey | string | Cloudwatch | Access key for connecting to Cloudwatch | | secretKey | string | Cloudwatch | Secret key for connecting to Cloudwatch | -| sigV4AccessKey | string | Elasticsearch | SigV4 access key. Required when using keys auth provider | +| sigV4AccessKey | string | Elasticsearch | SigV4 access key. Required when using keys auth provider | | sigV4SecretKey | string | Elasticsearch | SigV4 secret key. Required when using keys auth provider | #### Custom HTTP headers for datasources @@ -479,6 +480,17 @@ The following sections detail the supported settings and secure settings for eac | username | | | password | yes | +#### Alert notification `sensugo` + +| Name | Secure setting | +| -------- | -------------- | +| url | | +| apikey | yes | +| entity | | +| check | | +| handler | | +| namespace | | + #### Alert notification `prometheus-alertmanager` | Name | Secure setting | diff --git a/docs/sources/alerting/create-alerts.md b/docs/sources/alerting/create-alerts.md index 3cd0fd28a4c..31fd1b4e278 100644 --- a/docs/sources/alerting/create-alerts.md +++ b/docs/sources/alerting/create-alerts.md @@ -9,7 +9,7 @@ weight = 200 Grafana alerting allows you to attach rules to your dashboard panels. When you save the dashboard, Grafana extracts the alert rules into a separate alert rule storage and schedules them for evaluation. -{{< imgbox max-width="1000px" img="/img/docs/alerting/drag_handles_gif.gif" caption="Alerting overview" >}} +![Alerting overview](/img/docs/alerting/drag_handles_gif.gif) In the Alert tab of the graph panel you can configure how often the alert rule should be evaluated and the conditions that need to be met for the alert to change state and trigger its [notifications]({{< relref "notifications.md" >}}). diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 7a38e0df53c..058b665fbf9 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -64,6 +64,7 @@ OpsGenie | `opsgenie` | yes, external only | yes Prometheus Alertmanager | `prometheus-alertmanager` | yes, external only | yes Pushover | `pushover` | yes | no Sensu | `sensu` | yes, external only | no +[Sensu Go](#sensu-go) | `sensugo` | yes, external only | no [Slack](#slack) | `slack` | yes | no Telegram | `telegram` | yes | no Threema | `threema` | yes, external only | no @@ -212,6 +213,10 @@ Alertmanager handles alerts sent by client applications such as Prometheus serve [Zenduty](https://www.zenduty.com) is an incident alerting and response orchestration platform that not alerts the right teams via SMS, Phone(Voice), Email, Slack, Microsoft Teams and Push notifications(Android/iOS) whenever a Grafana alert is triggered, but also helps you rapidly triage and remediate critical, user impacting incidents. Grafana alert are sent to Zenduty through Grafana's native webhook dispatcher. Refer the Zenduty-Grafana [integration documentation](https://docs.zenduty.com/docs/grafana) for configuring the integration. +### Sensu Go + +[Sensu](https://sensu.io) is a complete solution for monitoring and observability at scale. Sensu Go is designed to give you visibility into everything you care about: traditional server closets, containers, applications, the cloud, and more. Grafana notifications can be sent to Sensu Go as events via the API. This operation requires an API Key. Refer to the [Sensu Go documentation](https://docs.sensu.io/sensu-go/latest/operations/control-access/use-apikeys/#api-key-authentication) for information on creating this key. + ## Enable images in notifications {#external-image-store} Grafana can render the panel associated with the alert rule as a PNG image and include that in the notification. Read more about the requirements and how to configure diff --git a/docs/sources/alerting/troubleshoot-alerts.md b/docs/sources/alerting/troubleshoot-alerts.md index 36c156d1779..df94dd2b3d3 100644 --- a/docs/sources/alerting/troubleshoot-alerts.md +++ b/docs/sources/alerting/troubleshoot-alerts.md @@ -9,7 +9,7 @@ weight = 500 If alerts are not behaving as you expect, here are some steps you can take to troubleshoot and figure out what is going wrong. -{{< imgbox max-width="1000px" img="/img/docs/v4/alert_test_rule.png" caption="Test Rule" >}} +![Test Rule](/img/docs/v4/alert_test_rule.png) The first level of troubleshooting you can do is click **Test Rule**. You will get result back that you can expand to the point where you can see the raw data that was returned from your query. diff --git a/docs/sources/auth/azuread.md b/docs/sources/auth/azuread.md index 7fd8317d77f..fd5d604fd40 100644 --- a/docs/sources/auth/azuread.md +++ b/docs/sources/auth/azuread.md @@ -118,7 +118,7 @@ allowed_groups = 8bab1c86-8fba-33e5-2089-1d1c80ec267d You'll need to ensure that you've [enabled group attributes](https://docs.microsoft.com/en-us/azure/active-directory/hybrid/how-to-connect-fed-group-claims#configure-the-azure-ad-application-registration-for-group-attributes) in your Azure AD Application Registration manifest file (Azure Portal -> Azure Active Directory -> Application Registrations -> Select Application -> Manifest) ```json -"groupMembershipClaims": "ApplicationGroup" +"groupMembershipClaims": "ApplicationGroup, SecurityGroup" ``` The `allowed_domains` option limits access to the users belonging to the specific domains. Domains should be separated by space or comma. diff --git a/docs/sources/datasources/influxdb.md b/docs/sources/datasources/influxdb.md index 314ef69639f..de41fb1546c 100644 --- a/docs/sources/datasources/influxdb.md +++ b/docs/sources/datasources/influxdb.md @@ -286,12 +286,12 @@ datasources: httpMode: GET ``` -### InfluxDB 2.x example +### InfluxDB 2.x for Flux example ```yaml apiVersion: 1 datasources: - - name: InfluxDB_v2 + - name: InfluxDB_v2_Flux type: influxdb access: proxy url: http://localhost:8086 @@ -303,3 +303,22 @@ datasources: defaultBucket: bucket tlsSkipVerify: true ``` + +### InfluxDB 2.x for InfluxQl example +```yaml +apiVersion: 1 + +datasources: + - name: InfluxDB_v2_InfluxQL + type: influxdb + access: proxy + url: http://localhost:8086 + # This database should be mapped to a bucket + database: site + jsonData: + httpMode: GET + httpHeaderName1: 'Authorization' + secureJsonData: + httpHeaderValue1: 'Token ' + +``` diff --git a/docs/sources/developers/_index.md b/docs/sources/developers/_index.md index 6b523397bf2..2d27bc4e9fb 100644 --- a/docs/sources/developers/_index.md +++ b/docs/sources/developers/_index.md @@ -1,9 +1,9 @@ +++ title = "Developers" -aliases = ["/docs/plugins/developing/"] +aliases = ["/docs/grafana/latest/plugins/developing/"] weight = 190 +++ # Developers -This section of the documentation contains pages with resources for Grafana developers. \ No newline at end of file +This section of the documentation contains pages with resources for Grafana developers. diff --git a/docs/sources/developers/plugins/_index.md b/docs/sources/developers/plugins/_index.md index 252e3d10973..0531c67a4eb 100644 --- a/docs/sources/developers/plugins/_index.md +++ b/docs/sources/developers/plugins/_index.md @@ -1,5 +1,6 @@ +++ -title = "Build a plugin." +title = "Build a plugin" +aliases = ["/docs/grafana/latest/plugins/developing/"] +++ # Build a plugin diff --git a/docs/sources/developers/plugins/metadata.md b/docs/sources/developers/plugins/metadata.md index 9a40f8c732a..0885fa2e320 100644 --- a/docs/sources/developers/plugins/metadata.md +++ b/docs/sources/developers/plugins/metadata.md @@ -15,59 +15,69 @@ The plugin.json file is required for all plugins. When Grafana starts, it scans | Property | Type | Required | Description | |-----------------|-------------------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `dependencies` | [object](#dependencies) | **Yes** | Plugin dependencies. | +| `dependencies` | [object](#dependencies) | **Yes** | Dependencies needed by the plugin. | | `id` | string | **Yes** | Unique name of the plugin. If the plugin is published on grafana.com, then the plugin id has to follow the naming conventions. | | `info` | [object](#info) | **Yes** | Metadata for the plugin. Some fields are used on the plugins page in Grafana and others on grafana.com if the plugin is published. | | `name` | string | **Yes** | Human-readable name of the plugin that is shown to the user in the UI. | -| `type` | string | **Yes** | Plugin type. Possible values are `app`, `datasource`, and `panel`. | +| `type` | string | **Yes** | Plugin type. Possible values are: `app`, `datasource`, `panel`. | | `$schema` | string | No | Schema definition for the plugin.json file | | `alerting` | boolean | No | For data source plugins. If the plugin supports alerting. | | `annotations` | boolean | No | For data source plugins. If the plugin supports annotation queries. | | `backend` | boolean | No | If the plugin has a backend component. | -| `category` | string | No | Plugin category used on the Add data source page. Possible values are: `tsdb`, `logging`, `cloud`, `tracing`, `sql`, `enterprise` and `other`. | +| `category` | string | No | Plugin category used on the Add data source page. Possible values are: `tsdb`, `logging`, `cloud`, `tracing`, `sql`, `enterprise`, `other`. | | `executable` | string | No | The first part of the file name of the backend component executable. There can be multiple executables built for different operating system and architecture. Grafana will check for executables named `_<$GOOS>_<.exe for Windows>`, e.g. `plugin_linux_amd64`. Combination of $GOOS and $GOARCH can be found here: https://golang.org/doc/install/source#environment. | | `hiddenQueries` | boolean | No | | -| `includes` | [object](#includes)[] | No | | +| `includes` | [object](#includes)[] | No | Resources to include in plugin. | | `logs` | boolean | No | For data source plugins. If the plugin supports logs. | | `metrics` | boolean | No | For data source plugins. If the plugin supports metric queries. Used in the Explore feature. | -| `mixed` | boolean | No | Not to be used by external plugins. Special property for the built-in mixed plugin. | -| `module` | string | No | | -| `partials` | [object](#partials) | No | | | `preload` | boolean | No | Initialize plugin on startup. By default, the plugin initializes on first use. | | `queryOptions` | [object](#queryoptions) | No | For data source plugins. There is a query options section in the plugin's query editor and these options can be turned on if needed. | | `routes` | [object](#routes)[] | No | For data source plugins. Proxy routes used for plugin authentication and adding headers to HTTP requests made by the plugin. For more information, refer to [Authentication for data source plugins](https://grafana.com/docs/grafana/latest/developers/plugins/authentication/). | | `skipDataQuery` | boolean | No | For panel plugins. Hides the query editor. | -| `sort` | number | No | Internal property for sorting. Cannot be used as will be overwritten by Grafana. | -| `state` | string | No | Marks a plugin as a pre-release. | -| `staticRoot` | string | No | | +| `state` | string | No | Marks a plugin as a pre-release. Possible values are: `alpha`, `beta`. | | `streaming` | boolean | No | For data source plugins. If the plugin supports streaming. | | `tables` | boolean | No | | | `tracing` | boolean | No | For data source plugins. If the plugin supports tracing. | ## dependencies -Plugin dependencies. +Dependencies needed by the plugin. ### Properties -| Property | Type | Required | Description | -|---------------------|----------|----------|-------------------------------------------------------------------------------------------------------------------------------| -| `grafanaDependency` | string | **Yes** | Required Grafana version for this plugin. Validated using https://github.com/npm/node-semver. | -| `grafanaVersion` | string | No | (Deprecated) Required Grafana version for this plugin, e.g. `6.x.x 7.x.x` to denote plugin requires Grafana v6.x.x or v7.x.x. | -| `plugins` | string[] | No | An array of required plugins on which this plugin depends. | +| Property | Type | Required | Description | +|---------------------|----------------------|----------|-------------------------------------------------------------------------------------------------------------------------------| +| `grafanaDependency` | string | **Yes** | Required Grafana version for this plugin. Validated using https://github.com/npm/node-semver. | +| `grafanaVersion` | string | No | (Deprecated) Required Grafana version for this plugin, e.g. `6.x.x 7.x.x` to denote plugin requires Grafana v6.x.x or v7.x.x. | +| `plugins` | [object](#plugins)[] | No | An array of required plugins on which this plugin depends. | + +### plugins + +Plugin dependency. Used to display information about plugin dependencies in the Grafana UI. + +#### Properties + +| Property | Type | Required | Description | +|-----------|--------|----------|----------------------------------------------------| +| `id` | string | **Yes** | | +| `name` | string | **Yes** | | +| `type` | string | **Yes** | Possible values are: `app`, `datasource`, `panel`. | +| `version` | string | **Yes** | | ## includes ### Properties -| Property | Type | Required | Description | -|--------------|---------|----------|-------------| -| `addToNav` | boolean | No | | -| `component` | string | No | | -| `defaultNav` | boolean | No | | -| `name` | string | No | | -| `role` | string | No | | -| `type` | string | No | | +| Property | Type | Required | Description | +|--------------|---------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `addToNav` | boolean | No | Add the include to the side menu. | +| `component` | string | No | (Legacy) The Angular component to use for a page. | +| `defaultNav` | boolean | No | Page or dashboard when user clicks the icon in the side menu. | +| `icon` | string | No | Icon to use in the side menu. For information on available icon, refer to [Icons Overview](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview). | +| `name` | string | No | | +| `path` | string | No | Used for app plugins. | +| `role` | string | No | Possible values are: `Admin`, `Editor`, `Viewer`. | +| `type` | string | No | Possible values are: `dashboard`, `page`, `panel`, `datasource`. | ## info @@ -82,6 +92,7 @@ Metadata for the plugin. Some fields are used on the plugins page in Grafana and | `updated` | string | **Yes** | Date when this plugin was built. | | `version` | string | **Yes** | Project version of this commit, e.g. `6.7.x`. | | `author` | [object](#author) | No | Information about the plugin author. | +| `build` | [object](#build) | No | Build information | | `description` | string | No | Description of plugin. Used on the plugins page in Grafana and for search on grafana.com. | | `links` | [object](#links)[] | No | An array of link objects to be displayed on this plugin's project page in the form `{name: 'foo', url: 'http://example.com'}` | | `screenshots` | [object](#screenshots)[] | No | An array of screenshot objects in the form `{name: 'bar', path: 'img/screenshot.png'}` | @@ -98,6 +109,21 @@ Information about the plugin author. | `name` | string | No | Author's name. | | `url` | string | No | Link to author's website. | +### build + +Build information + +#### Properties + +| Property | Type | Required | Description | +|----------|--------|----------|------------------------------------------------------| +| `branch` | string | No | Git branch the plugin was built from. | +| `hash` | string | No | Git hash of the commit the plugin was built from | +| `number` | number | No | | +| `pr` | number | No | GitHub pull request the plugin was built from | +| `repo` | string | No | | +| `time` | number | No | Time when the plugin was built, as a Unix timestamp. | + ### links #### Properties @@ -127,14 +153,6 @@ SVG images that are used as plugin icons. | `name` | string | No | | | `path` | string | No | | -## partials - -### Properties - -| Property | Type | Required | Description | -|----------|--------|----------|-------------| -| `config` | string | No | | - ## queryOptions For data source plugins. There is a query options section in the plugin's query editor and these options can be turned on if needed. diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developers/plugins/plugin.schema.json index fd9163766f0..1496c6856d2 100644 --- a/docs/sources/developers/plugins/plugin.schema.json +++ b/docs/sources/developers/plugins/plugin.schema.json @@ -18,7 +18,7 @@ }, "type": { "type": "string", - "description": "Plugin type. Possible values are `app`, `datasource`, and `panel`.", + "description": "Plugin type.", "enum": ["app", "datasource", "panel"] }, "name": { @@ -27,7 +27,7 @@ }, "category": { "type": "string", - "description": "Plugin category used on the Add data source page. Possible values are: `tsdb`, `logging`, `cloud`, `tracing`, `sql`, `enterprise` and `other`.", + "description": "Plugin category used on the Add data source page.", "enum": ["tsdb", "logging", "cloud", "tracing", "sql", "enterprise", "other"] }, "annotations": { @@ -57,27 +57,41 @@ }, "includes": { "type": "array", + "description": "Resources to include in plugin.", "items": { "type": "object", "additionalItems": false, "properties": { "type": { - "type": "string" + "type": "string", + "enum": ["dashboard", "page", "panel", "datasource"] }, "name": { "type": "string" }, "component": { - "type": "string" + "type": "string", + "description": "(Legacy) The Angular component to use for a page." }, "role": { - "type": "string" + "type": "string", + "enum": ["Admin", "Editor", "Viewer"] + }, + "path": { + "type": "string", + "description": "Used for app plugins." }, "addToNav": { - "type": "boolean" + "type": "boolean", + "description": "Add the include to the side menu." }, "defaultNav": { - "type": "boolean" + "type": "boolean", + "description": "Page or dashboard when user clicks the icon in the side menu." + }, + "icon": { + "type": "string", + "description": "Icon to use in the side menu. For information on available icon, refer to [Icons Overview](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)." } } } @@ -90,34 +104,10 @@ "type": "boolean", "description": "For panel plugins. Hides the query editor." }, - "partials": { - "type": "object", - "additionalItems": false, - "properties": { - "config": { - "type": "string" - } - } - }, "metrics": { "type": "boolean", "description": "For data source plugins. If the plugin supports metric queries. Used in the Explore feature." }, - "mixed": { - "type": "boolean", - "description": "Not to be used by external plugins. Special property for the built-in mixed plugin." - }, - "module": { - "type": "string", - "description": "" - }, - "sort": { - "type": "number", - "description": "Internal property for sorting. Cannot be used as will be overwritten by Grafana." - }, - "staticRoot": { - "type": "string" - }, "streaming": { "type": "boolean", "description": "For data source plugins. If the plugin supports streaming." @@ -134,7 +124,7 @@ }, "dependencies": { "type": "object", - "description": "Plugin dependencies.", + "description": "Dependencies needed by the plugin.", "required": ["grafanaDependency"], "additionalProperties": false, "properties": { @@ -151,8 +141,27 @@ "plugins": { "type": "array", "description": "An array of required plugins on which this plugin depends.", + "additionalItems": false, "items": { - "type": "string" + "type": "object", + "description": "Plugin dependency. Used to display information about plugin dependencies in the Grafana UI.", + "required": ["id", "name", "type", "version"], + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9a-z]+\\-([0-9a-z]+\\-)?(app|panel|datasource)$" + }, + "type": { + "type": "string", + "enum": ["app", "datasource", "panel"] + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } } } } @@ -184,6 +193,37 @@ } } }, + "build": { + "type": "object", + "description": "Build information", + "additionalProperties": false, + "properties": { + "time": { + "type": "number", + "description": "Time when the plugin was built, as a Unix timestamp." + }, + "repo": { + "type": "string", + "description": "" + }, + "branch": { + "type": "string", + "description": "Git branch the plugin was built from." + }, + "hash": { + "type": "string", + "description": "Git hash of the commit the plugin was built from" + }, + "number": { + "type": "number", + "description": "" + }, + "pr": { + "type": "number", + "description": "GitHub pull request the plugin was built from" + } + } + }, "description": { "type": "string", "description": "Description of plugin. Used on the plugins page in Grafana and for search on grafana.com." diff --git a/docs/sources/getting-started/getting-started-sql.md b/docs/sources/getting-started/getting-started-sql.md index 6e2f81dba37..45d5b56a47a 100644 --- a/docs/sources/getting-started/getting-started-sql.md +++ b/docs/sources/getting-started/getting-started-sql.md @@ -1,5 +1,5 @@ +++ -title = "With Grafana and MS SQL" +title = "With Grafana and MS SQL Server" description = "Guide for getting started with Grafana" keywords = ["grafana", "intro", "guide", "started", "SQL", "MSSQL"] aliases = ["/docs/grafana/latest/guides/gettingstarted","/docs/grafana/latest/guides/getting_started"] @@ -15,7 +15,7 @@ Use the instructions in [Getting started with Grafana]({{< relref "getting-start - Install Grafana. - Log in to Grafana. - Create your first dashboard. -> **Note:** You must install Grafana 5.1+ in order to use the integrated MSSQL data source. +> **Note:** You must install Grafana 5.1+ in order to use the integrated MS SQL data source. ## Step 2. Download MS SQL Server @@ -63,7 +63,7 @@ To add MS SQL Server data source: `User` | Database user's login/username. `Password` | Database user's password. -For installations from the [grafana/grafana](https://github.com/grafana/grafana/tree/master) repository, `gdev-mssql` data source is available. Once you add this data source, the `Datasource tests - MSSQL` dashboard with three panels showing metrics generated from test database is available. +For installations from the [grafana/grafana](https://github.com/grafana/grafana/tree/master) repository, `gdev-mssql` data source is available. Once you add this data source, you can use the `Datasource tests - MSSQL` dashboard with three panels showing metrics generated from a test database. diff --git a/docs/sources/getting-started/getting-started.md b/docs/sources/getting-started/getting-started.md index 562c0664b39..8fe9fda5c0c 100644 --- a/docs/sources/getting-started/getting-started.md +++ b/docs/sources/getting-started/getting-started.md @@ -30,7 +30,7 @@ To log in to Grafana for the first time: To create your first dashboard: 1. Click the **+** icon on the left panel, select **Create Dashboard**, and then click **Add new panel**. -1. In the New Dashbard/Edit Panel view, go to the **Query** tab. +1. In the New Dashboard/Edit Panel view, go to the **Query** tab. 1. Configure your [query]({{< relref "../panels/queries.md" >}}) by selecting ``-- Grafana --`` from the [data source selector]({{< relref "../panels/queries.md/#data-source-selector" >}}). This generates the Random Walk dashboard. 1. Click the **Save** icon in the top right corner of your screen to save the dashboard. 1. Add a descriptive name, and then click **Save**. @@ -56,4 +56,4 @@ The following topics are of interest to Grafana server admin users: - [Provisioning]({{< relref "../administration/provisioning.md" >}}) - [Grafana CLI]({{< relref "../administration/cli.md" >}}) - \ No newline at end of file + diff --git a/docs/sources/http_api/snapshot.md b/docs/sources/http_api/snapshot.md index 778087b620e..a602fa37bbb 100644 --- a/docs/sources/http_api/snapshot.md +++ b/docs/sources/http_api/snapshot.md @@ -68,7 +68,8 @@ JSON Body schema: "deleteKey":"XXXXXXX", "deleteUrl":"myurl/api/snapshots-delete/XXXXXXX", "key":"YYYYYYY", - "url":"myurl/dashboard/snapshot/YYYYYYY" + "url":"myurl/dashboard/snapshot/YYYYYYY", + "id": 1, } ``` @@ -192,7 +193,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk HTTP/1.1 200 Content-Type: application/json -{"message":"Snapshot deleted. It might take an hour before it's cleared from any CDN caches."} +{"message":"Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", "id": 1} ``` ## Delete Snapshot by deleteKey @@ -214,5 +215,5 @@ Accept: application/json HTTP/1.1 200 Content-Type: application/json -{"message":"Snapshot deleted. It might take an hour before it's cleared from any CDN caches."} -``` \ No newline at end of file +{"message":"Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", "id": 1} +``` diff --git a/docs/sources/panels/transformations/types-options.md b/docs/sources/panels/transformations/types-options.md index a4850410067..9f287e1f50a 100644 --- a/docs/sources/panels/transformations/types-options.md +++ b/docs/sources/panels/transformations/types-options.md @@ -7,34 +7,72 @@ weight = 300 Grafana comes with the following transformations: - - [Reduce](#reduce) - - [Filter by name](#filter-by-name) - - [Filter data by query](#filter-data-by-query) - - [Organize fields](#organize-fields) - - [Outer join](#join-by-field-outer-join) - - [Series to rows](#series-to-rows) - - [Add field from calculation](#add-field-from-calculation) - - [Labels to fields](#labels-to-fields) - - [Group by](#group-by) - - [Merge](#merge) +- [Reduce](#reduce) +- [Filter by name](#filter-by-name) +- [Filter data by query](#filter-data-by-query) +- [Filter data by value](#filter-data-by-value) +- [Organize fields](#organize-fields) +- [Outer join](#join-by-field-outer-join) +- [Series to rows](#series-to-rows) +- [Add field from calculation](#add-field-from-calculation) +- [Labels to fields](#labels-to-fields) +- [Concatenate fields](#concatenate-fields) +- [Group by](#group-by) +- [Merge](#merge) Keep reading for detailed descriptions of each type of transformation and the options available for each, as well as suggestions on how to use them. ## Reduce -Apply a _Reduce_ transformation when you want to simplify your results down to one value. Reduce removes the time component. If visualized as a table, it reduces a column down to one row (value). +The _Reduce_ transformation will apply a calculation to each field in the frame and return a single value. Time fields are removed when applying +this transformation. -In the **Calculations** field, enter one or more calculation types. Click to see a list of calculation choices. For information about available calculations, refer to the [Calculation list]({{< relref "../calculations-list.md" >}}). +Consider the input: -Once you select at least one calculation, Grafana reduces the results down to one value using the calculation you select. If you select more than one calculation, then more than one value is displayed. +Query A: -Here's an example of a table with time series data. Before I apply the transformation, you can see all the data organized by time. +| Time | Temp | Uptime | +| ------------------- | ------- | ------- | +| 2020-07-07 11:34:20 | 12.3 | 256122 | +| 2020-07-07 11:24:20 | 15.4 | 1230233 | -{{< docs-imagebox img="/img/docs/transformations/reduce-before-7-0.png" class="docs-image--no-shadow" max-width= "1100px" >}} +Query B: + +| Time | AQI | Errors | +| ------------------- | ------- | ------ | +| 2020-07-07 11:34:20 | 6.5 | 15 | +| 2020-07-07 11:24:20 | 3.2 | 5 | + +The reduce transformer has two modes: +- **Series to rows -** Creates a row for each field and a column for each calculation. +- **Reduce fields -** Keeps the existing frame structure, but collapses each field into a single value. + +For example, if you used the **First** and **Last** calculation with a **Series to rows** transformation, then +the result would be: + +| Field | First | Last | +| ------- | ------- | ------- | +| Temp | 12.3 | 15.4 | +| Uptime | 256122 | 1230233 | +| AQI | 6.5 | 3.2 | +| Errors | 15 | 5 | + +The **Reduce fields** with the **Last** calculation, +results in two frames, each with one row: + +Query A: + +| Temp | Uptime | +| ------- | ------- | +| 15.4 | 1230233 | + +Query B: + +| AQI | Errors | +| ------- | ------ | +| 3.2 | 5 | -After I apply the transformation, there is no time value and each column has been reduced to one row showing the results of the calculations that I chose. -{{< docs-imagebox img="/img/docs/transformations/reduce-after-7-0.png" class="docs-image--no-shadow" max-width= "1100px" >}} ## Merge @@ -138,7 +176,8 @@ Use this transformation to add a new field calculated from two other fields. Eac - **Reduce row -** Apply selected calculation on each row of selected fields independently. - **Binary option -** Apply basic math operation(sum, multiply, etc) on values in a single row from two selected fields. - **Field name -** Select the names of fields you want to use in the calculation for the new field. -- **Calculation -** Select a calculation to use when Grafana creates the new field. Click in the field to see a list of calculation choices. For information about available calculations, refer to the [Calculation list]({{< relref "../calculations-list.md" >}}). +- **Calculation -** If you select **Reduce row** mode, then the **Calculation** field appears. Click in the field to see a list of calculation choices you can use to create the new field. For information about available calculations, refer to the [Calculation list]({{< relref "../calculations-list.md" >}}). +- **Operation -** If you select **Binary option** mode, then the **Operation** fields appear. These fields allow you to do basic math operations on values in a single row from two selected fields. You can also use numerical values for binary operations. - **Alias -** (Optional) Enter the name of your new field. If you leave this blank, then the field will be named to match the calculation. - **Replace all fields -** (Optional) Select this option if you want to hide all other fields and display only your calculated field in the visualization. @@ -184,7 +223,7 @@ After I apply the transformation, my labels appear in the table as fields. > **Note:** This transformation is only available in Grafana 7.2+. -This transformation groups the data by a specified field (column) value and processes calculations on each group. Click to see a list of calculation choices. For information about available calculations, refer to the [List of calculations]({{< relref "../calculations-list.md" >}}). +This transformation groups the data by a specified field (column) value and processes calculations on each group. Click to see a list of calculation choices. For information about available calculations, refer to the [List of calculations]({{< relref "../calculations-list.md" >}}). Here's an example of original data. @@ -202,17 +241,17 @@ Here's an example of original data. This transformation goes in two steps. First you specify one or multiple fields to group the data by. This will group all the same values of those fields together, as if you sorted them. For instance if we group by the Server ID field, then it would group the data this way: -| Time | Server ID | CPU Temperature | Server Status | -| ------------------- | ------------ | --------------- | ------------- | -| 2020-07-07 11:34:20 | **server 1** | 80 | Shutdown | -| 2020-07-07 09:28:06 | **server 1** | 80 | OK | -| 2020-07-07 09:23:07 | **server 1** | 86 | OK | -| 2020-07-07 10:32:20 | server 2 | 90 | Overload | -| 2020-07-07 09:30:05 | server 2 | 88 | OK | -| 2020-07-07 09:25:05 | server 2 | 88 | OK | -| 2020-07-07 11:34:20 | **_server 3_** | 62 | OK | -| 2020-07-07 10:31:22 | **_server 3_** | 55 | OK | -| 2020-07-07 09:30:57 | **_server 3_** | 62 | Rebooting | +| Time | Server ID | CPU Temperature | Server Status | +| ------------------- | -------------- | --------------- | ------------- | +| 2020-07-07 11:34:20 | **server 1** | 80 | Shutdown | +| 2020-07-07 09:28:06 | **server 1** | 80 | OK | +| 2020-07-07 09:23:07 | **server 1** | 86 | OK | +| 2020-07-07 10:32:20 | server 2 | 90 | Overload | +| 2020-07-07 09:30:05 | server 2 | 88 | OK | +| 2020-07-07 09:25:05 | server 2 | 88 | OK | +| 2020-07-07 11:34:20 | **_server 3_** | 62 | OK | +| 2020-07-07 10:31:22 | **_server 3_** | 55 | OK | +| 2020-07-07 09:30:57 | **_server 3_** | 62 | Rebooting | All rows with the same value of Server ID are grouped together. @@ -240,11 +279,36 @@ We would then get : This transformation allows you to extract some key information out of your time series and display them in a convenient way. +## Concatenate fields + +> **Note:** This transformation is only available in Grafana 7.3+. + +This transformation combines all fields from all frames into one result. Consider: + +Query A: + +| Temp | Uptime | +| ------- | ------- | +| 15.4 | 1230233 | + +Query B: + +| AQI | Errors | +| ------- | ------ | +| 3.2 | 5 | + + +After you concatenate the fields, the data frame would be: + +| Temp | Uptime | AQI | Errors | +| ------- | ------- | ------- | ------ | +| 15.4 | 1230233 | 3.2 | 5 | + ## Series to rows > **Note:** This transformation is only available in Grafana 7.1+. -Use this transformation to combine the result from multiple time series data queries into one single result. This is helpful when using the table panel visualization. +Use this transformation to combine the result from multiple time series data queries into one single result. This is helpful when using the table panel visualization. The result from this transformation will contain three columns: Time, Metric, and Value. The Metric column is added so you easily can see from which query the metric originates from. Customize this value by defining Label on the source query. @@ -276,3 +340,61 @@ Here is the result after applying the Series to rows transformation. | 2020-07-07 10:31:22 | Temperature | 22 | | 2020-07-07 09:30:57 | Humidity | 33 | | 2020-07-07 09:30:05 | Temperature | 19 | + +## Filter by value + +This transformation allows you to filter your data directly in Grafana and remove some data points from your query result. You have the option to include or exclude data that match one or more conditions you define. The conditions are applied on a selected field. + +The available conditions are: + +- **Regex**: match a regex expression +- **Is Null**: match if the value is null +- **Is Not Null**: match if the value is not null +- **Equal**: match if the value is equal to the specified value +- **Different**: match if the value is different than the specified value +- **Greater**\*: match if the value is greater than the specified value +- **Lower**\*: match if the value is lower than the specified value +- **Greater or equal**\*: match if the value is greater or equal +- **Lower or equal**\*: match if the value is lower or equal +- **Range**\*: match a range between a specified minimum and maximum, min and max included + +\* Those conditions are only available for number fields. + +Consider the following data set: + +| Time | Temperature | Altitude | +| ------------------- | ----------- | -------- | +| 2020-07-07 11:34:23 | 32 | 101 | +| 2020-07-07 11:34:22 | 28 | 125 | +| 2020-07-07 11:34:21 | 26 | 110 | +| 2020-07-07 11:34:20 | 23 | 98 | +| 2020-07-07 10:32:24 | 31 | 95 | +| 2020-07-07 10:31:22 | 20 | 85 | +| 2020-07-07 09:30:57 | 19 | 101 | + +If you **Include** the data points that have a temperature below 30°C, the configuration will look as follows: + +- Filter Type: `Include` +- Condition: Rows where `Temperature` matches `Lower Than` `100` + +And you will get the following result, where only the temperatures below 30°C are included: + +| Time | Temperature | Altitude | +| ------------------- | ----------- | -------- | +| 2020-07-07 11:34:22 | 28 | 125 | +| 2020-07-07 11:34:21 | 26 | 110 | +| 2020-07-07 11:34:20 | 23 | 98 | +| 2020-07-07 10:31:22 | 20 | 85 | +| 2020-07-07 09:30:57 | 19 | 101 | + +You can add more than one condition to the filter. For example, you might want to include the data only if the altitude is greater than 100. To do so, add that condition to the following configuration: + +- Filter type: `Include` rows that `Match All` conditions +- Condition 1: Rows where `Temperature` matches `Lower` than `30` +- Condition 2: Rows where `Altitude` matches `Greater` than `100` + +When you have more than one condition, you can choose if you want the action (include / exclude) to be applied on rows that **Match all** conditions or **Match any** of the conditions you added. + +In the example above we chose **Match all** because we wanted to include the rows that have a temperature lower than 30 _AND_ an altitude higher than 100. If we wanted to include the rows that have a temperature lower than 30 _OR_ an altitude higher than 100 instead, then we would select **Match any**. This would include the first row in the original data, which has a temperature of 32°C (does not match the first condition) but an altitude of 101 (which matches the second condition), so it is included. + +Conditions that are invalid or incompletely configured are ignored. diff --git a/docs/sources/plugins/installation.md b/docs/sources/plugins/installation.md index 475672505e1..6066f2a88b1 100644 --- a/docs/sources/plugins/installation.md +++ b/docs/sources/plugins/installation.md @@ -1,6 +1,6 @@ +++ title = "Install plugins" -aliases = ["/docs/plugins/installation/"] +aliases = ["/docs/grafana/latest/plugins/installation/"] weight = 1 +++ diff --git a/docs/sources/plugins/plugin-signatures.md b/docs/sources/plugins/plugin-signatures.md index 628b68ecdea..2b0ff883e14 100644 --- a/docs/sources/plugins/plugin-signatures.md +++ b/docs/sources/plugins/plugin-signatures.md @@ -1,7 +1,7 @@ +++ title = "Plugin signatures" type = "docs" -aliases = ["/docs/plugins/plugin-signature-verification"] +aliases = ["/docs/grafana/latest/plugins/plugin-signature-verification"] +++ # Plugin signatures diff --git a/docs/sources/release-notes/release-notes-7-3-4.md b/docs/sources/release-notes/release-notes-7-3-4.md index ac51f7b17a3..85ceb9dfbda 100644 --- a/docs/sources/release-notes/release-notes-7-3-4.md +++ b/docs/sources/release-notes/release-notes-7-3-4.md @@ -13,5 +13,5 @@ list = false * **Dashboard**: Fixes kiosk state after being redirected to login page and back. [#29273](https://github.com/grafana/grafana/pull/29273), [@torkelo](https://github.com/torkelo) * **InfluxDB**: Update flux library to fix support for boolean label values. [#29310](https://github.com/grafana/grafana/pull/29310), [@ryantxu](https://github.com/ryantxu) * **Security**: Fixes minor security issue with alert notification webhooks that allowed GET & DELETE requests. [#29330](https://github.com/grafana/grafana/pull/29330), [@wbrowne](https://github.com/wbrowne) -* **Table**: Fix incorrect condition for rendering table filter. [#29165](https://github.com/grafana/grafana/pull/29165), [@dprokop](https://github.com/dprokop) +* **Table**: Fixes issues with phantom extra 0 for zero values. [#29165](https://github.com/grafana/grafana/pull/29165), [@dprokop](https://github.com/dprokop) diff --git a/docs/sources/variables/filter-variables-with-regex.md b/docs/sources/variables/filter-variables-with-regex.md index e4e2cd03c43..fb25635b6ee 100644 --- a/docs/sources/variables/filter-variables-with-regex.md +++ b/docs/sources/variables/filter-variables-with-regex.md @@ -78,6 +78,8 @@ demo.robustperception.io:9100 ## Filter and modify using named text and value capture groups +> **Note:** This feature is available in Grafana 7.4+. + Using named capture groups, you can capture separate 'text' and 'value' parts from the options returned by the variable query. This allows the variable drop-down list to contain a friendly name for each value that can be selected. For example, when querying the `node_hwmon_chip_names` Prometheus metric, the `chip_name` is a lot friendlier that the `chip` value. So the following variable query result: diff --git a/docs/sources/variables/formatting-multi-value-variables.md b/docs/sources/variables/formatting-multi-value-variables.md index 434d2637edd..2ec4d9df884 100644 --- a/docs/sources/variables/formatting-multi-value-variables.md +++ b/docs/sources/variables/formatting-multi-value-variables.md @@ -1,9 +1,9 @@ +++ -title = "Formatting multi-value variables" +title = "Multi-value variables" weight = 600 +++ -# Formatting multi-value variables +# Multi-value variables Interpolating a variable with multiple values selected is tricky as it is not straight forward how to format the multiple values into a string that is valid in the given context where the variable is used. Grafana tries to solve this by allowing each data source plugin to inform the templating interpolation engine what format to use for multiple values. @@ -21,7 +21,7 @@ InfluxDB and Prometheus use regex expressions, so the same variable would be int Elasticsearch uses lucene query syntax, so the same variable would be formatted as `("host1" OR "host2" OR "host3")`. In this case, every value must be escaped so that the value only contains lucene control words and quotation marks. -## Formatting troubles +## Troubleshoot multi-value variables Automatic escaping and formatting can cause problems and it can be tricky to grasp the logic behind it. Especially for InfluxDB and Prometheus where the use of regex syntax requires that the variable is used in regex operator context. diff --git a/docs/sources/variables/repeat-panels-or-rows.md b/docs/sources/variables/repeat-panels-or-rows.md index 114b0a179dc..9ec9af93552 100644 --- a/docs/sources/variables/repeat-panels-or-rows.md +++ b/docs/sources/variables/repeat-panels-or-rows.md @@ -1,5 +1,5 @@ +++ -title = "Variables" +title = "Repeat panels or rows" keywords = ["grafana", "templating", "documentation", "guide", "template", "variable", "repeat"] weight = 800 +++ diff --git a/docs/sources/variables/syntax.md b/docs/sources/variables/syntax.md index 8bb408ca273..f1ffcf80d0f 100644 --- a/docs/sources/variables/syntax.md +++ b/docs/sources/variables/syntax.md @@ -1,5 +1,5 @@ +++ -title = "Variables syntax and types" +title = "Variable syntax" keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] aliases = ["/docs/grafana/latest/reference/templating"] weight = 100 diff --git a/e2e/suite1/specs/panelEdit_base.spec.ts b/e2e/suite1/specs/panelEdit_base.spec.ts index 8854912f422..bb4f2a3fc6d 100644 --- a/e2e/suite1/specs/panelEdit_base.spec.ts +++ b/e2e/suite1/specs/panelEdit_base.spec.ts @@ -26,7 +26,7 @@ e2e.scenario({ expect(li.text()).equals('Query1'); // there's already a query so therefore Query + 1 }); e2e.components.QueryTab.content().should('be.visible'); - e2e.components.TransformTab.content().should('not.be.visible'); + e2e.components.TransformTab.content().should('not.exist'); e2e.components.AlertTab.content().should('not.be.visible'); // Bottom pane tabs @@ -37,7 +37,9 @@ e2e.scenario({ e2e.components.Tab.active().within((li: JQuery) => { expect(li.text()).equals('Transform0'); // there's no transform so therefore Transform + 0 }); - e2e.components.TransformTab.content().should('be.visible'); + e2e.components.Transforms.card('Merge') + .scrollIntoView() + .should('be.visible'); e2e.components.QueryTab.content().should('not.be.visible'); e2e.components.AlertTab.content().should('not.be.visible'); @@ -50,7 +52,7 @@ e2e.scenario({ }); e2e.components.AlertTab.content().should('be.visible'); e2e.components.QueryTab.content().should('not.be.visible'); - e2e.components.TransformTab.content().should('not.be.visible'); + e2e.components.TransformTab.content().should('not.exist'); e2e.components.Tab.title('Query') .should('be.visible') diff --git a/e2e/suite1/specs/variables/new-query-variable.ts b/e2e/suite1/specs/variables/new-query-variable.ts index 3955481b03a..48485efb702 100644 --- a/e2e/suite1/specs/variables/new-query-variable.ts +++ b/e2e/suite1/specs/variables/new-query-variable.ts @@ -46,10 +46,9 @@ describe('Variables - Add variable', () => { e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() .should('be.visible') .within(select => { - e2e.components.Select.singleValue().should('have.text', ''); + e2e.components.Select.singleValue().should('have.text', 'gdev-testdata'); }); - e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput().should('not.exist'); e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelect() .should('be.visible') .within(select => { diff --git a/e2e/suite1/specs/variables/textbox-variables.ts b/e2e/suite1/specs/variables/textbox-variables.ts new file mode 100644 index 00000000000..185aadcb275 --- /dev/null +++ b/e2e/suite1/specs/variables/textbox-variables.ts @@ -0,0 +1,299 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = 'AejrN1AMz'; + +describe('TextBox - load options scenarios', function() { + it('default options should be correct', function() { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + e2e().server(); + e2e() + .route({ + method: 'GET', + url: `/api/dashboards/uid/${PAGE_UNDER_TEST}`, + }) + .as('dash'); + + e2e().wait('@dash'); + + validateTextboxAndMarkup('default value'); + }); + + it('loading variable from url should be correct', function() { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-text=not default value` }); + e2e().server(); + e2e() + .route({ + method: 'GET', + url: `/api/dashboards/uid/${PAGE_UNDER_TEST}`, + }) + .as('dash'); + + e2e().wait('@dash'); + + validateTextboxAndMarkup('not default value'); + }); +}); + +describe.skip('TextBox - change query scenarios', function() { + it('when changing the query value and not saving current as default should revert query value', function() { + copyExistingDashboard(); + + changeQueryInput(); + + e2e.components.BackButton.backArrow() + .should('be.visible') + .click({ force: true }); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(false); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('default value'); + + validateVariable('changed value'); + }); + }); + + it('when changing the query value and saving current as default should change query value', function() { + copyExistingDashboard(); + + changeQueryInput(); + + e2e.components.BackButton.backArrow() + .should('be.visible') + .click({ force: true }); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(true); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('changed value'); + + validateVariable('changed value'); + }); + }); +}); + +describe.skip('TextBox - change picker value scenarios', function() { + it('when changing the input value and not saving current as default should revert query value', function() { + copyExistingDashboard(); + + changeTextBoxInput(); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(false); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('default value'); + validateVariable('default value'); + }); + }); + + it('when changing the input value and saving current as default should change query value', function() { + copyExistingDashboard(); + + changeTextBoxInput(); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(true); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('changed value'); + validateVariable('changed value'); + }); + }); +}); + +function copyExistingDashboard() { + e2e.flows.login('admin', 'admin'); + e2e().server(); + e2e() + .route({ + method: 'GET', + url: '/api/search?query=&type=dash-folder&permission=Edit', + }) + .as('dash-settings'); + e2e() + .route({ + method: 'POST', + url: '/api/dashboards/db/', + }) + .as('save-dash'); + e2e() + .route({ + method: 'GET', + url: /\/api\/dashboards\/uid\/(?!AejrN1AMz)\w+/, + }) + .as('load-dash'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?editview=settings&orgId=1` }); + + e2e().wait('@dash-settings'); + + e2e.pages.Dashboard.Settings.General.saveAsDashBoard() + .should('be.visible') + .click(); + + e2e.pages.SaveDashboardAsModal.newName() + .should('be.visible') + .type(`${Date.now()}`); + + e2e.pages.SaveDashboardAsModal.save() + .should('be.visible') + .click(); + + e2e().wait('@save-dash'); + e2e().wait('@load-dash'); + + e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible'); + + e2e() + .location() + .then(loc => { + const dashuid = /\/d\/(\w+)\//.exec(loc.href)![1]; + e2e() + .wrap(dashuid) + .as('dashuid'); + }); + + e2e().wait(500); +} + +function saveDashboard(saveVariables: boolean) { + e2e.pages.Dashboard.Toolbar.toolbarItems('Save dashboard') + .should('be.visible') + .click(); + + if (saveVariables) { + e2e.pages.SaveDashboardModal.saveVariables() + .should('exist') + .click({ force: true }); + } + + e2e.pages.SaveDashboardModal.save() + .should('be.visible') + .click(); + + e2e().wait('@save-dash'); +} + +function validateTextboxAndMarkup(value: string) { + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('be.visible') + .within(() => { + e2e.pages.Dashboard.SubMenu.submenuItemLabels('text').should('be.visible'); + e2e() + .get('input') + .should('be.visible') + .should('have.value', value); + }); + + e2e.components.Panels.Visualization.Text.container() + .should('be.visible') + .within(() => { + e2e() + .get('h1') + .should('be.visible') + .should('have.text', `variable: ${value}`); + }); +} + +function validateVariable(value: string) { + e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings') + .should('be.visible') + .click(); + + e2e.pages.Dashboard.Settings.General.sectionItems('Variables') + .should('be.visible') + .click(); + + e2e.pages.Dashboard.Settings.Variables.List.tableRowNameFields('text') + .should('be.visible') + .click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInput() + .should('be.visible') + .should('have.value', value); +} + +function changeTextBoxInput() { + e2e.pages.Dashboard.SubMenu.submenuItemLabels('text').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('be.visible') + .within(() => { + e2e() + .get('input') + .should('be.visible') + .should('have.value', 'default value') + .clear() + .type('changed value') + .type('{enter}'); + }); + + e2e() + .location() + .should(loc => { + expect(loc.search).to.contain('var-text=changed%20value'); + }); +} + +function changeQueryInput() { + e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings') + .should('be.visible') + .click(); + + e2e.pages.Dashboard.Settings.General.sectionItems('Variables') + .should('be.visible') + .click(); + + e2e.pages.Dashboard.Settings.Variables.List.tableRowNameFields('text') + .should('be.visible') + .click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInput() + .should('be.visible') + .clear() + .type('changed value') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption() + .should('have.length', 1) + .should('have.text', 'changed value'); +} diff --git a/go.mod b/go.mod index ae98cc7700b..72d78946c6c 100644 --- a/go.mod +++ b/go.mod @@ -62,9 +62,9 @@ require ( github.com/opentracing/opentracing-go v1.2.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 - github.com/prometheus/client_golang v1.7.1 + github.com/prometheus/client_golang v1.8.0 github.com/prometheus/client_model v0.2.0 - github.com/prometheus/common v0.10.0 + github.com/prometheus/common v0.14.0 github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 github.com/robfig/cron/v3 v3.0.0 @@ -77,6 +77,7 @@ require ( github.com/uber/jaeger-client-go v2.25.0+incompatible github.com/unknwon/com v1.0.1 github.com/urfave/cli/v2 v2.1.1 + github.com/weaveworks/common v0.0.0-20201119133501-0619918236ec github.com/xorcare/pointer v1.1.0 github.com/yudai/gojsondiff v1.0.0 golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a diff --git a/go.sum b/go.sum index c15d001c14e..f144259db92 100644 --- a/go.sum +++ b/go.sum @@ -329,6 +329,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fatih/structtag v1.1.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flosch/pongo2 v0.0.0-20190707114632-bbf5a6c351f4/go.mod h1:T9YF2M40nIgbVgp3rreNmTged+9HrbNTIQf1PsaIiTA= github.com/fluent/fluent-bit-go v0.0.0-20190925192703-ea13c021720c/go.mod h1:WQX+afhrekY9rGK+WT4xvKSlzmia9gDoLYu4GGYGASQ= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= @@ -489,6 +491,7 @@ github.com/gocql/gocql v0.0.0-20200121121104-95d072f1b5bb/go.mod h1:DL0ekTmBSTdl github.com/gocql/gocql v0.0.0-20200526081602-cd04bd7f22a7/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= github.com/godbus/dbus v0.0.0-20190402143921-271e53dc4968/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gogo/googleapis v1.1.0 h1:kFkMAZBNAn4j7K0GiZr8cRYzejq68VbheufiV3YuyFI= github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -499,6 +502,7 @@ github.com/gogo/protobuf v1.2.2-0.20190730201129-28a6bbf47e48/go.mod h1:SlYgWuQ5 github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/status v1.0.3 h1:WkVBY59mw7qUNTr/bLwO7J2vesJ0rQ2C3tMXrTd3w5M= github.com/gogo/status v1.0.3/go.mod h1:SavQ51ycCLnc7dGyJxp8YAmudx8xqiVrRf+6IXRsugc= github.com/golang-migrate/migrate/v4 v4.7.0/go.mod h1:Qvut3N4xKWjoH3sokBccML6WyHSnggXm/DvMMnTsQIc= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= @@ -610,6 +614,7 @@ github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de/go.mod h1:wJfORR github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= @@ -803,6 +808,8 @@ github.com/klauspost/pgzip v1.0.2-0.20170402124221-0bf5dcad4ada/go.mod h1:Ch1tH6 github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -980,6 +987,7 @@ github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zM github.com/opentracing-contrib/go-grpc v0.0.0-20180928155321-4b5a12d3ff02/go.mod h1:JNdpVEzCpXBgIiv4ds+TzhN1hrtxq6ClLrTlT9OQRSc= github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= github.com/opentracing-contrib/go-stdlib v0.0.0-20190519235532-cf7a6c988dc9/go.mod h1:PLldrQSroqzH70Xl+1DQcGnefIbqsKR7UDaiux3zV+w= +github.com/opentracing-contrib/go-stdlib v1.0.0 h1:TBS7YuVotp8myLon4Pv7BtCBzOTo1DeZCld0Z63mW2w= github.com/opentracing-contrib/go-stdlib v1.0.0/go.mod h1:qtI1ogk+2JhVPIXVc6q+NHziSmy2W5GbdQZFUHADCBU= github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= @@ -1039,6 +1047,8 @@ github.com/prometheus/client_golang v1.4.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3O github.com/prometheus/client_golang v1.6.0/go.mod h1:ZLOG9ck3JLRdB5MgO8f+lLTe83AXG6ro35rLTxvnIl4= github.com/prometheus/client_golang v1.7.1 h1:NTGy1Ja9pByO+xAeH/qiWnLrKtr3hJPNjaVUwnjpdpA= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.8.0 h1:zvJNkoCFAnYFNC24FV8nW4JdRJ3GIFcLbg65lL/JDcw= +github.com/prometheus/client_golang v1.8.0/go.mod h1:O9VU6huf47PktckDQfMTX0Y8tY0/7TSWwj+ITvv0TnM= github.com/prometheus/client_model v0.0.0-20170216185247-6f3806018612/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= @@ -1057,6 +1067,8 @@ github.com/prometheus/common v0.8.0/go.mod h1:PC/OgXc+UN7B4ALwvn1yzVZmVwvhXp5Jsb github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0 h1:RyRA7RzGXQZiW+tGMr7sxa85G1z0yOpM1qq5c8lNawc= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.14.0 h1:RHRyE8UocrbjU+6UvRzwi6HjiDfxrrBU91TtbKzkGp4= +github.com/prometheus/common v0.14.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= github.com/prometheus/node_exporter v1.0.0-rc.0.0.20200428091818-01054558c289/go.mod h1:FGbBv5OPKjch+jNUJmEQpMZytIdyW0NdBtWFcfSKusc= github.com/prometheus/procfs v0.0.0-20180612222113-7d6f385de8be/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= @@ -1071,6 +1083,8 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.1.3 h1:F0+tqvhOksq22sc6iCHF5WGlWjdwj92p0udFh1VFBS8= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.2.0 h1:wH4vA7pcjKuZzjF7lM8awk4fnuJO6idemZXoKnULUx4= +github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/prometheus v0.0.0-20180315085919-58e2a31db8de/go.mod h1:oAIUtOny2rjMX0OWN5vPR5/q/twIROJvdqnQKDdil/s= github.com/prometheus/prometheus v0.0.0-20190818123050-43acd0e2e93f/go.mod h1:rMTlmxGCvukf2KMu3fClMDKLLoJ5hl61MhcJ7xKakf0= github.com/prometheus/prometheus v1.8.2-0.20200107122003-4708915ac6ef/go.mod h1:7U90zPoLkWjEIQcy/rweQla82OCTUzxVHE51G3OhJbI= @@ -1135,7 +1149,10 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q= github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= +github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= @@ -1194,6 +1211,7 @@ github.com/ua-parser/uap-go v0.0.0-20190826212731-daf92ba38329 h1:VBsKFh4W1JEMz3 github.com/ua-parser/uap-go v0.0.0-20190826212731-daf92ba38329/go.mod h1:OBcG9bn7sHtXgarhUEb3OfCnNsgtGnkVf41ilSZ3K3E= github.com/uber/jaeger-client-go v2.15.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= github.com/uber/jaeger-client-go v2.20.1+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.22.1+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= github.com/uber/jaeger-client-go v2.23.1+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= github.com/uber/jaeger-client-go v2.24.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= github.com/uber/jaeger-client-go v2.25.0+incompatible h1:IxcNZ7WRY1Y3G4poYlx24szfsn/3LvK9QHCq9oQw8+U= @@ -1222,6 +1240,9 @@ github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= github.com/weaveworks/common v0.0.0-20200206153930-760e36ae819a/go.mod h1:6enWAqfQBFrE8X/XdJwZr8IKgh1chStuFR0mjU/UOUw= github.com/weaveworks/common v0.0.0-20200625145055-4b1847531bc9/go.mod h1:c98fKi5B9u8OsKGiWHLRKus6ToQ1Tubeow44ECO1uxY= +github.com/weaveworks/common v0.0.0-20201119133501-0619918236ec h1:5JmevdpzK10Z2ua0VDToj7Kg2+/t0FzdYBjsurYRE8k= +github.com/weaveworks/common v0.0.0-20201119133501-0619918236ec/go.mod h1:ykzWac1LtVfOxdCK+jD754at1Ws9dKCwFeUzkFBffPs= +github.com/weaveworks/promrus v1.2.0 h1:jOLf6pe6/vss4qGHjXmGz4oDJQA+AOCqEL3FvvZGz7M= github.com/weaveworks/promrus v1.2.0/go.mod h1:SaE82+OJ91yqjrE1rsvBWVzNZKcHYFtMUyS1+Ogs/KA= github.com/willf/bitset v1.1.3/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= @@ -1516,6 +1537,7 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201022201747-fb209a7c41cd h1:WgqgiQvkiZWz7XLhphjt2GI2GcGCTIZs9jqXMWmH+oc= golang.org/x/sys v0.0.0-20201022201747-fb209a7c41cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/package.json b/package.json index 49ee8089e02..b70519ef834 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,15 @@ "repository": "github:grafana/grafana", "scripts": { "api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js", - "build": "grunt build", + "build": "node ./node_modules/webpack/bin/webpack.js --config scripts/webpack/webpack.prod.js", "dev": "webpack --progress --colors --config scripts/webpack/webpack.dev.js", "e2e": "./e2e/start-and-run-suite", "e2e:debug": "./e2e/start-and-run-suite debug", "e2e:dev": "./e2e/start-and-run-suite dev", "test": "jest --notify --watch", - "lint": "eslint . --ext .js,.tsx,.ts --cache", + "lint": "yarn run lint:ts && yarn run lint:sass", + "lint:ts": "eslint . --ext .js,.tsx,.ts --cache", + "lint:sass": "yarn run sass-lint -c public/sass/.sass-lint.yml 'public/sass/**/*.scss, packages/**/*.scss' -v -i '**/node_modules/**/*.scss'", "test:ci": "mkdir -p reports/junit && export JEST_JUNIT_OUTPUT_DIR=reports/junit && jest --ci --reporters=default --reporters=jest-junit -w ${TEST_MAX_WORKERS:-100%}", "lint:fix": "yarn lint --fix", "packages:build": "lerna run clean && lerna run build --ignore @grafana-plugins/input-datasource", @@ -27,7 +29,7 @@ "packages:publishDev": "lerna publish from-package --contents dist --dist-tag dev --yes --registry http://grafana-npm.local:4873 --force-publish=*", "packages:typecheck": "lerna run typecheck", "packages:clean": "lerna run clean", - "precommit": "grafana-toolkit precommit", + "precommit": "yarn run lint-staged", "prettier:check": "prettier --list-different \"**/*.{ts,tsx,scss}\"", "prettier:write": "prettier --list-different \"**/*.{ts,tsx,scss}\" --write", "start": "grafana-toolkit core:start --watchTheme", @@ -38,10 +40,10 @@ "storybook": "yarn workspace @grafana/ui storybook --ci", "storybook:build": "yarn workspace @grafana/ui storybook:build", "themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && yarn run packages:typecheck", "plugins:build-bundled": "grafana-toolkit plugin:bundle-managed", "watch": "yarn start -d watch,start core:start --watchTheme", - "ci:test-frontend": "yarn run prettier:check && yarn run packages:typecheck && yarn run typecheck && yarn grunt no-only-tests && yarn run test:ci" + "ci:test-frontend": "yarn run prettier:check && yarn run typecheck && yarn run lint && yarn run test:ci && yarn grafana-toolkit node-version-check" }, "grafana": { "whatsNewUrl": "https://grafana.com/docs/grafana/latest/guides/whats-new-in-v7-3/", @@ -49,7 +51,7 @@ }, "husky": { "hooks": { - "pre-commit": "lint-staged && npm run precommit" + "pre-commit": "yarn run precommit" } }, "lint-staged": { @@ -72,7 +74,7 @@ "@babel/preset-react": "7.8.3", "@babel/preset-typescript": "7.8.3", "@emotion/core": "10.0.27", - "@grafana/api-documenter": "7.11.1", + "@grafana/api-documenter": "7.11.2", "@grafana/api-extractor": "7.10.1", "@grafana/eslint-config": "2.0.6", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", @@ -90,7 +92,7 @@ "@types/enzyme-adapter-react-16": "1.0.6", "@types/file-saver": "2.0.1", "@types/is-hotkey": "0.1.1", - "@types/jest": "26.0.0", + "@types/jest": "26.0.15", "@types/jquery": "3.3.38", "@types/lodash": "4.14.149", "@types/lru-cache": "^5.1.0", @@ -102,7 +104,7 @@ "@types/react": "16.9.9", "@types/react-beautiful-dnd": "12.1.2", "@types/react-dom": "16.9.9", - "@types/react-grid-layout": "0.16.7", + "@types/react-grid-layout": "1.1.1", "@types/react-redux": "7.1.7", "@types/react-select": "3.0.8", "@types/react-test-renderer": "16.9.2", @@ -124,7 +126,7 @@ "autoprefixer": "9.7.4", "axios": "0.19.2", "babel-core": "7.0.0-bridge.0", - "babel-jest": "26.0.1", + "babel-jest": "26.6.3", "babel-loader": "8.0.6", "babel-plugin-angularjs-annotate": "0.10.0", "clean-webpack-plugin": "3.0.0", @@ -134,9 +136,10 @@ "es-abstract": "1.18.0-next.1", "es6-promise": "4.2.8", "es6-shim": "0.35.5", - "eslint": "7.4.0", + "eslint": "^7.14.0", "eslint-config-prettier": "6.11.0", "eslint-plugin-jsdoc": "28.6.1", + "eslint-plugin-no-only-tests": "^2.4.0", "eslint-plugin-prettier": "3.1.4", "eslint-plugin-react": "7.21.5", "eslint-plugin-react-hooks": "4.1.2", @@ -146,24 +149,12 @@ "fork-ts-checker-webpack-plugin": "5.0.0", "gaze": "1.1.3", "glob": "7.1.6", - "grunt": "1.0.4", - "grunt-angular-templates": "1.1.0", - "grunt-cli": "1.3.2", - "grunt-contrib-clean": "2.0.0", - "grunt-contrib-copy": "1.0.0", - "grunt-exec": "3.0.0", - "grunt-newer": "1.3.0", - "grunt-notify": "0.4.5", - "grunt-postcss": "0.9.0", - "grunt-sass-lint": "0.2.4", - "grunt-usemin": "3.1.1", - "grunt-webpack": "3.1.3", "html-loader": "0.5.5", "html-webpack-harddisk-plugin": "1.0.1", "html-webpack-plugin": "3.2.0", "husky": "4.2.1", - "jest": "26.4.2", - "jest-canvas-mock": "2.2.0", + "jest": "26.6.3", + "jest-canvas-mock": "2.3.0", "jest-date-mock": "1.0.8", "jest-matcher-utils": "26.0.0", "lerna": "^3.20.2", @@ -193,10 +184,10 @@ "sinon": "8.1.1", "style-loader": "1.1.3", "terser-webpack-plugin": "2.3.5", - "ts-jest": "26.3.0", - "ts-node": "8.8.1", - "tslib": "2.0.1", - "typescript": "4.0.2", + "ts-jest": "26.4.4", + "ts-node": "9.0.0", + "tslib": "2.0.3", + "typescript": "4.1.2", "webpack": "4.41.5", "webpack-bundle-analyzer": "3.6.0", "webpack-cleanup-plugin": "0.5.1", @@ -267,7 +258,7 @@ "react": "17.0.1", "react-beautiful-dnd": "13.0.0", "react-dom": "17.0.1", - "react-grid-layout": "0.17.1", + "react-grid-layout": "1.2.0", "react-highlight-words": "0.16.0", "react-loadable": "5.5.0", "react-popper": "2.2.4", @@ -318,9 +309,9 @@ "puppeteer": "node_modules/puppeteer-core" }, "engines": { - "node": ">= 12" + "node": ">= 14" }, "volta": { - "node": "12.19.1" + "node": "14.15.1" } } diff --git a/packages/README.md b/packages/README.md index bbd8dfb6ec6..480716c4149 100644 --- a/packages/README.md +++ b/packages/README.md @@ -27,7 +27,29 @@ Every commit to master that has changes within the `packages` directory is a sub - ``` -Automatic prereleases are published under the `canary` dist tag. +Automatic prereleases are published under the `canary` dist tag to the [github package registry](https://docs.github.com/en/free-pro-team@latest/packages/publishing-and-managing-packages/about-github-packages). + +#### Consuming prereleases + +As mentioned above the `canary` releases are published to the Github package registry rather than the NPM registry. If you wish to make use of these prereleases please follow these steps: + +1. You must use a personal access token to install packages from Github. To create an access token [click here](https://github.com/settings/tokens) and create a token with the `read:packages` scope. Make a copy of the token. +2. Create / modify your `~/.npmrc` file with the following: + +``` +@grafana:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken={INSERT_GH_TOKEN_HERE} +``` + +3. Update the package.json of your project to use either the `canary` channel or a version of the `canary` channel + +```json +// plugin's package.json +{ + ... + "@grafana/data": "canary" +} +``` ### Manual release diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index c48d91ba395..0583d7281e6 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -37,7 +37,7 @@ "@rollup/plugin-json": "4.0.3", "@rollup/plugin-node-resolve": "7.1.1", "@types/braintree__sanitize-url": "4.0.0", - "@types/jest": "26.0.0", + "@types/jest": "26.0.15", "@types/jquery": "3.3.38", "@types/lodash": "4.14.123", "@types/marked": "1.1.0", @@ -53,6 +53,6 @@ "rollup-plugin-typescript2": "0.26.0", "rollup-plugin-visualizer": "3.3.1", "sinon": "8.1.1", - "typescript": "4.0.2" + "typescript": "4.1.2" } } diff --git a/packages/grafana-data/src/dataframe/ArrowDataFrame.ts b/packages/grafana-data/src/dataframe/ArrowDataFrame.ts index 03a48fa7471..c09b3f18b58 100644 --- a/packages/grafana-data/src/dataframe/ArrowDataFrame.ts +++ b/packages/grafana-data/src/dataframe/ArrowDataFrame.ts @@ -55,6 +55,9 @@ export function arrowTableToDataFrame(table: Table): ArrowDataFrame { case ArrowType.Decimal: case ArrowType.FloatingPoint: { type = FieldType.number; + if (col.nullCount) { + values = new WrappedColumn(col); + } break; } case ArrowType.Int: { @@ -64,6 +67,9 @@ export function arrowTableToDataFrame(table: Table): ArrowDataFrame { } case ArrowType.Bool: { type = FieldType.boolean; + if (col.nullCount) { + values = new WrappedColumn(col); + } break; } case ArrowType.Timestamp: { @@ -202,3 +208,20 @@ class NumberColumn extends FunctionalVector { return Number(v); } } + +// The `toArray()` arrow function will return a native ArrayBuffer -- this works fine +// if there are no null values, but the native arrays do not support nulls. This +// class simply wraps the vector so `toArray` creates a new array +class WrappedColumn extends FunctionalVector { + constructor(private col: Column) { + super(); + } + + get length() { + return this.col.length; + } + + get(index: number): number { + return this.col.get(index); + } +} diff --git a/packages/grafana-data/src/dataframe/__snapshots__/ArrowDataFrame.test.ts.snap b/packages/grafana-data/src/dataframe/__snapshots__/ArrowDataFrame.test.ts.snap index 89c69fe8459..4b7e781ba0d 100644 --- a/packages/grafana-data/src/dataframe/__snapshots__/ArrowDataFrame.test.ts.snap +++ b/packages/grafana-data/src/dataframe/__snapshots__/ArrowDataFrame.test.ts.snap @@ -265,7 +265,7 @@ Object { "labels": undefined, "name": "float32_values", "type": "number", - "values": Array [ + "values": Float32Array [ 1.401298464324817e-45, 1.401298464324817e-45, 1, diff --git a/packages/grafana-data/src/dataframe/processDataFrame.ts b/packages/grafana-data/src/dataframe/processDataFrame.ts index 69d57700769..30fcc725fc9 100644 --- a/packages/grafana-data/src/dataframe/processDataFrame.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.ts @@ -24,6 +24,7 @@ import { SortedVector } from '../vector/SortedVector'; import { ArrayDataFrame } from './ArrayDataFrame'; import { getFieldDisplayName } from '../field/fieldState'; import { fieldIndexComparer } from '../field/fieldComparers'; +import { vectorToArray } from '../vector/vectorToArray'; function convertTableToDataFrame(table: TableData): DataFrame { const fields = table.columns.map(c => { @@ -444,16 +445,10 @@ export function getDataFrameRow(data: DataFrame, row: number): any[] { export function toDataFrameDTO(data: DataFrame): DataFrameDTO { const fields: FieldDTO[] = data.fields.map(f => { let values = f.values.toArray(); - if (!Array.isArray(values)) { - // Apache arrow will pack objects into typed arrays - // Float64Array, etc - // TODO: Float64Array could be used directly - values = []; - for (let i = 0; i < f.values.length; i++) { - values.push(f.values.get(i)); - } + // The byte buffers serialize like objects + if (values instanceof Float64Array) { + values = vectorToArray(f.values); } - return { name: f.name, type: f.type, diff --git a/packages/grafana-data/src/field/fieldDisplay.ts b/packages/grafana-data/src/field/fieldDisplay.ts index 9fc5b325fc5..ec207cfd289 100644 --- a/packages/grafana-data/src/field/fieldDisplay.ts +++ b/packages/grafana-data/src/field/fieldDisplay.ts @@ -77,7 +77,6 @@ export interface GetFieldDisplayValuesOptions { replaceVariables: InterpolateFunction; sparkline?: boolean; // Calculate the sparkline theme: GrafanaTheme; - autoMinMax?: boolean; timeZone?: TimeZone; } @@ -122,7 +121,11 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi continue; } - const config = field.config; // already set by the prepare task + let config = field.config; // already set by the prepare task + if (field.state?.range) { + // Us the global min/max values + config = { ...config, ...field.state?.range }; + } const displayName = field.config.displayName ?? defaultDisplayName; const display = @@ -157,7 +160,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi }); values.push({ - name, + name: '', field: config, display: displayValue, view, diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index 6551b922acf..6dec551e88d 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -183,7 +183,6 @@ describe('applyFieldOverrides', () => { overrides: [], }, replaceVariables: (value: any) => value, - getDataSourceSettingsByUid: undefined as any, theme: getTestTheme(), fieldConfigRegistry: new FieldConfigOptionsRegistry(), }); @@ -245,7 +244,6 @@ describe('applyFieldOverrides', () => { overrides: [], }, fieldConfigRegistry: customFieldRegistry, - getDataSourceSettingsByUid: undefined as any, replaceVariables: v => v, theme: getTestTheme(), })[0]; @@ -263,7 +261,6 @@ describe('applyFieldOverrides', () => { data: [f0], // the frame fieldConfig: src as FieldConfigSource, // defaults + overrides replaceVariables: (undefined as any) as InterpolateFunction, - getDataSourceSettingsByUid: undefined as any, theme: getTestTheme(), fieldConfigRegistry: customFieldRegistry, })[0]; @@ -291,18 +288,16 @@ describe('applyFieldOverrides', () => { data: [f0], // the frame fieldConfig: src as FieldConfigSource, // defaults + overrides replaceVariables: (undefined as any) as InterpolateFunction, - getDataSourceSettingsByUid: undefined as any, theme: getTestTheme(), - autoMinMax: true, })[0]; const valueColumn = data.fields[1]; - const config = valueColumn.config; + const range = valueColumn.state!.range!; // Keep max from the original setting - expect(config.max).toEqual(0); + expect(range.max).toEqual(0); // Don't Automatically pick the min value - expect(config.min).toEqual(-20); + expect(range.min).toEqual(-20); }); it('getLinks should use applied field config', () => { @@ -315,9 +310,7 @@ describe('applyFieldOverrides', () => { replaceVariablesCalls.push(variables); return value; }) as InterpolateFunction, - getDataSourceSettingsByUid: undefined as any, theme: getTestTheme(), - autoMinMax: true, fieldConfigRegistry: customFieldRegistry, })[0]; @@ -567,7 +560,6 @@ describe('getLinksSupplier', () => { {}, replaceSpy, // this is used only for internal links so isn't needed here - () => ({} as any), { theme: getTestTheme(), } @@ -600,6 +592,7 @@ describe('getLinksSupplier', () => { title: '', internal: { datasourceUid: '0', + datasourceName: 'testDS', query: '12345', }, }, @@ -615,7 +608,6 @@ describe('getLinksSupplier', () => { {}, // We do not need to interpolate anything for this test (value, vars, format) => value, - uid => ({ name: 'testDS' } as any), { theme: getTestTheme() } ); const links = supplier({ valueRowIndex: 0 }); diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index 839aaa28aba..40e56d6f882 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -2,7 +2,6 @@ import { ApplyFieldOverrideOptions, DataFrame, DataLink, - DataSourceInstanceSettings, DynamicConfigValue, Field, FieldColorModeId, @@ -13,6 +12,7 @@ import { GrafanaTheme, InterpolateFunction, LinkModel, + NumericRange, ScopedVars, TimeZone, ValueLinkConfig, @@ -40,12 +40,7 @@ interface OverrideProps { properties: DynamicConfigValue[]; } -interface GlobalMinMax { - min?: number | null; - max?: number | null; -} - -export function findNumericFieldMinMax(data: DataFrame[]): GlobalMinMax { +export function findNumericFieldMinMax(data: DataFrame[]): NumericRange { let min: number | null = null; let max: number | null = null; @@ -69,7 +64,7 @@ export function findNumericFieldMinMax(data: DataFrame[]): GlobalMinMax { } } - return { min, max }; + return { min, max, delta: (max ?? 0) - (min ?? 0) }; } /** @@ -88,7 +83,7 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra const fieldConfigRegistry = options.fieldConfigRegistry ?? standardFieldConfigEditorRegistry; let seriesIndex = 0; - let range: GlobalMinMax | undefined = undefined; + let globalRange: NumericRange | undefined = undefined; // Prepare the Matchers const override: OverrideProps[] = []; @@ -134,7 +129,6 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra data: options.data!, dataFrameIndex: index, replaceVariables: options.replaceVariables, - getDataSourceSettingsByUid: options.getDataSourceSettingsByUid, fieldConfigRegistry: fieldConfigRegistry, }; @@ -178,18 +172,14 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra } // Set the Min/Max value automatically - if (options.autoMinMax && field.type === FieldType.number) { - if (!isNumber(config.min) || !isNumber(config.max)) { - if (!range) { - range = findNumericFieldMinMax(options.data!); // Global value - } - if (!isNumber(config.min)) { - config.min = range.min; - } - if (!isNumber(config.max)) { - config.max = range.max; - } + let range: NumericRange | undefined = undefined; + if (field.type === FieldType.number) { + if (!globalRange && (!isNumber(config.min) || !isNumber(config.max))) { + globalRange = findNumericFieldMinMax(options.data!); } + const min = config.min ?? globalRange!.min; + const max = config.max ?? globalRange!.max; + range = { min, max, delta: max! - min! }; } // Some color modes needs series index to assign field color so we count @@ -207,6 +197,7 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra ...field.state, displayName: null, seriesIndex, + range, }, }; @@ -218,17 +209,10 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra }); // Attach data links supplier - newField.getLinks = getLinksSupplier( - newFrame, - newField, - fieldScopedVars, - context.replaceVariables, - context.getDataSourceSettingsByUid, - { - theme: options.theme, - timeZone: options.timeZone, - } - ); + newField.getLinks = getLinksSupplier(newFrame, newField, fieldScopedVars, context.replaceVariables, { + theme: options.theme, + timeZone: options.timeZone, + }); return newField; }); @@ -343,7 +327,6 @@ export const getLinksSupplier = ( field: Field, fieldScopedVars: ScopedVars, replaceVariables: InterpolateFunction, - getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined, options: { theme: GrafanaTheme; timeZone?: TimeZone; @@ -408,9 +391,13 @@ export const getLinksSupplier = ( if (link.internal) { // For internal links at the moment only destination is Explore. - return mapInternalLinkToExplore(link, variables, {} as any, field, { + return mapInternalLinkToExplore({ + link, + internalLink: link.internal, + scopedVars: variables, + field, + range: {} as any, replaceVariables, - getDataSourceSettingsByUid, }); } else { let href = locationUtil.assureBaseUrl(link.url.replace(/\n/g, '')); diff --git a/packages/grafana-data/src/field/getFieldDisplayValuesProxy.test.tsx b/packages/grafana-data/src/field/getFieldDisplayValuesProxy.test.tsx index e1dc2a5aad9..334069ec2ea 100644 --- a/packages/grafana-data/src/field/getFieldDisplayValuesProxy.test.tsx +++ b/packages/grafana-data/src/field/getFieldDisplayValuesProxy.test.tsx @@ -29,10 +29,8 @@ describe('getFieldDisplayValuesProxy', () => { overrides: [], }, replaceVariables: (val: string) => val, - getDataSourceSettingsByUid: (val: string) => ({} as any), timeZone: 'utc', theme: getTestTheme(), - autoMinMax: true, })[0]; it('should define all display functions', () => { diff --git a/packages/grafana-data/src/field/scale.ts b/packages/grafana-data/src/field/scale.ts index 32933c5dc26..35d76d35095 100644 --- a/packages/grafana-data/src/field/scale.ts +++ b/packages/grafana-data/src/field/scale.ts @@ -1,6 +1,6 @@ import { isNumber } from 'lodash'; import { reduceField, ReducerID } from '../transformations/fieldReducer'; -import { Field, FieldType, GrafanaTheme, Threshold } from '../types'; +import { Field, FieldConfig, FieldType, GrafanaTheme, NumericRange, Threshold } from '../types'; import { getFieldColorModeForField } from './fieldColor'; import { getActiveThresholdForValue } from './thresholds'; @@ -15,7 +15,7 @@ export type ScaleCalculator = (value: number) => ScaledValue; export function getScaleCalculator(field: Field, theme: GrafanaTheme): ScaleCalculator { const mode = getFieldColorModeForField(field); const getColor = mode.getCalculator(field, theme); - const info = getMinMaxAndDelta(field); + const info = field.state?.range ?? getMinMaxAndDelta(field); return (value: number) => { let percent = 0; @@ -34,13 +34,7 @@ export function getScaleCalculator(field: Field, theme: GrafanaTheme): ScaleCalc }; } -interface FieldMinMaxInfo { - min?: number | null; - max?: number | null; - delta: number; -} - -function getMinMaxAndDelta(field: Field): FieldMinMaxInfo { +function getMinMaxAndDelta(field: Field): NumericRange { if (field.type !== FieldType.number) { return { min: 0, max: 100, delta: 100 }; } @@ -70,3 +64,15 @@ function getMinMaxAndDelta(field: Field): FieldMinMaxInfo { delta: max! - min!, }; } + +export function getFieldConfigWithMinMax(field: Field, local?: boolean): FieldConfig { + const { config } = field; + let { min, max } = config; + if (isNumber(min) && !isNumber(max)) { + return config; // noop + } + if (local || !field.state?.range) { + return { ...config, ...getMinMaxAndDelta(field) }; + } + return { ...config, ...field.state.range }; +} diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 741f9584f22..a7eaa94faf5 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -13,4 +13,9 @@ export * from './text'; export * from './valueFormats'; export * from './field'; export * from './events'; +export { + ValueMatcherOptions, + BasicValueMatcherOptions, + RangeValueMatcherOptions, +} from './transformations/matchers/valueMatchers/types'; export { PanelPlugin } from './panel/PanelPlugin'; diff --git a/packages/grafana-data/src/transformations/matchers.ts b/packages/grafana-data/src/transformations/matchers.ts index a2ab1596c67..aa5e6c7c8b2 100644 --- a/packages/grafana-data/src/transformations/matchers.ts +++ b/packages/grafana-data/src/transformations/matchers.ts @@ -9,9 +9,16 @@ import { FrameMatcherInfo, FieldMatcher, FrameMatcher, + ValueMatcherInfo, + ValueMatcher, } from '../types/transformations'; import { Registry } from '../utils/Registry'; +import { getNullValueMatchers } from './matchers/valueMatchers/nullMatchers'; +import { getNumericValueMatchers } from './matchers/valueMatchers/numericMatchers'; +import { getEqualValueMatchers } from './matchers/valueMatchers/equalMatchers'; +import { getRangeValueMatchers } from './matchers/valueMatchers/rangeMatchers'; import { getSimpleFieldMatchers } from './matchers/simpleFieldMatcher'; +import { getRegexValueMatcher } from './matchers/valueMatchers/regexMatchers'; /** * Registry that contains all of the built in field matchers. @@ -38,6 +45,20 @@ export const frameMatchers = new Registry(() => { ]; }); +/** + * Registry that contains all of the built in value matchers. + * @public + */ +export const valueMatchers = new Registry(() => { + return [ + ...getNullValueMatchers(), + ...getNumericValueMatchers(), + ...getEqualValueMatchers(), + ...getRangeValueMatchers(), + ...getRegexValueMatcher(), + ]; +}); + /** * Resolves a field matcher from the registry for given config. * Will throw an error if matcher can not be resolved. @@ -45,6 +66,9 @@ export const frameMatchers = new Registry(() => { */ export function getFieldMatcher(config: MatcherConfig): FieldMatcher { const info = fieldMatchers.get(config.id); + if (!info) { + throw new Error('Unknown field matcher: ' + config.id); + } return info.get(config.options); } @@ -55,5 +79,21 @@ export function getFieldMatcher(config: MatcherConfig): FieldMatcher { */ export function getFrameMatchers(config: MatcherConfig): FrameMatcher { const info = frameMatchers.get(config.id); + if (!info) { + throw new Error('Unknown frame matcher: ' + config.id); + } + return info.get(config.options); +} + +/** + * Resolves a value matcher from the registry for given config. + * Will throw an error if matcher can not be resolved. + * @public + */ +export function getValueMatcher(config: MatcherConfig): ValueMatcher { + const info = valueMatchers.get(config.id); + if (!info) { + throw new Error('Unknown value matcher: ' + config.id); + } return info.get(config.options); } diff --git a/packages/grafana-data/src/transformations/matchers/ids.ts b/packages/grafana-data/src/transformations/matchers/ids.ts index cd3976627a0..cd4c7888bd5 100644 --- a/packages/grafana-data/src/transformations/matchers/ids.ts +++ b/packages/grafana-data/src/transformations/matchers/ids.ts @@ -23,6 +23,7 @@ export enum FieldMatcherID { byNames = 'byNames', byRegexp = 'byRegexp', byRegexpOrNames = 'byRegexpOrNames', + byFrameRefID = 'byFrameRefID', // byIndex = 'byIndex', // byLabel = 'byLabel', } @@ -36,3 +37,19 @@ export enum FrameMatcherID { byIndex = 'byIndex', byLabel = 'byLabel', } + +/** + * @public + */ +export enum ValueMatcherID { + regex = 'regex', + isNull = 'isNull', + isNotNull = 'isNotNull', + greater = 'greater', + greaterOrEqual = 'greaterOrEqual', + lower = 'lower', + lowerOrEqual = 'lowerOrEqual', + equal = 'equal', + notEqual = 'notEqual', + between = 'between', +} diff --git a/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts index fefca6bd406..5459cd5fffc 100644 --- a/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts @@ -306,3 +306,31 @@ describe('Field Regexp or Names Matcher', () => { } }); }); + +describe('Fields returned by query with refId', () => { + it('Match all fields in frame with refId: A', () => { + const data = [ + toDataFrame({ + refId: 'A', + fields: [{ name: 'field_1' }, { name: 'field_2' }], + }), + toDataFrame({ + refId: 'B', + fields: [{ name: 'field_1' }, { name: 'field_2' }], + }), + ]; + + const matcher = getFieldMatcher({ + id: FieldMatcherID.byFrameRefID, + options: 'A', + }); + + const frameA = data[0]; + expect(matcher(frameA.fields[0], frameA, data)).toBe(true); + expect(matcher(frameA.fields[1], frameA, data)).toBe(true); + + const frameB = data[1]; + expect(matcher(frameB.fields[0], frameB, data)).toBe(false); + expect(matcher(frameB.fields[1], frameB, data)).toBe(false); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/nameMatcher.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts index 16534ba4638..efaa7d0a4d1 100644 --- a/packages/grafana-data/src/transformations/matchers/nameMatcher.ts +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts @@ -66,6 +66,28 @@ const regexpFieldNameMatcher: FieldMatcherInfo = { }, }; +/** + * Field matcher that will match all fields that exists in a + * data frame with configured refId. + * @public + */ +const fieldsInFrameMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byFrameRefID, + name: 'Fields by frame refId', + description: 'match all fields returned in data frame with refId.', + defaultOptions: '', + + get: (refId: string): FieldMatcher => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return frame.refId === refId; + }; + }, + + getOptionsDisplayText: (refId: string): string => { + return `Math all fields returned by query with reference ID: ${refId}`; + }, +}; + const regexpOrMultipleNamesMatcher: FieldMatcherInfo = { id: FieldMatcherID.byRegexpOrNames, name: 'Field Name by Regexp or Names', @@ -127,7 +149,13 @@ const frameNameMatcher: FrameMatcherInfo = { * Registry Initialization */ export function getFieldNameMatchers(): FieldMatcherInfo[] { - return [fieldNameMatcher, regexpFieldNameMatcher, multipleFieldNamesMatcher, regexpOrMultipleNamesMatcher]; + return [ + fieldNameMatcher, + regexpFieldNameMatcher, + multipleFieldNamesMatcher, + regexpOrMultipleNamesMatcher, + fieldsInFrameMatcher, + ]; } export function getFrameNameMatchers(): FrameMatcherInfo[] { diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts new file mode 100644 index 00000000000..2aba608f15d --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts @@ -0,0 +1,108 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value equals to matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10, 'asd', '23'], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.equal, + options: { + value: 23, + }, + }); + + it('should match when option value is same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match when option value is different', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match when option value is different type', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 3; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should match when option value is different type but same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); +}); + +describe('value not equals matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10, 'asd', '23'], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.notEqual, + options: { + value: 23, + }, + }); + + it('should not match when option value is same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should match when option value is different', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match when option value is different type', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 3; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match when option value is different type but same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts new file mode 100644 index 00000000000..7a84cbb4e18 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts @@ -0,0 +1,42 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const isEqualValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.equal, + name: 'Is equal', + description: 'Match where value for given field is equal to options value.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + // eslint-disable-next-line eqeqeq + return value == options.value; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '' }), +}; + +const isNotEqualValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.notEqual, + name: 'Is not equal', + description: 'Match where value for given field is not equal to options value.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + // eslint-disable-next-line eqeqeq + return value != options.value; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is not null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '' }), +}; + +export const getEqualValueMatchers = (): ValueMatcherInfo[] => [isEqualValueMatcher, isNotEqualValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts new file mode 100644 index 00000000000..91b1f06bf49 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts @@ -0,0 +1,72 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value null matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.isNull, + options: {}, + }); + + it('should match null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value not null matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.isNotNull, + options: {}, + }); + + it('should match not null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match non-null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts new file mode 100644 index 00000000000..42ce8b142bf --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts @@ -0,0 +1,40 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { ValueMatcherOptions } from './types'; + +const isNullValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.isNull, + name: 'Is null', + description: 'Match where value for given field is null.', + get: () => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return value === null; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({}), +}; + +const isNotNullValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.isNotNull, + name: 'Is not null', + description: 'Match where value for given field is not null.', + get: () => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return value !== null; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is not null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({}), +}; + +export const getNullValueMatchers = (): ValueMatcherInfo[] => [isNullValueMatcher, isNotNullValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts new file mode 100644 index 00000000000..7c20d917890 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts @@ -0,0 +1,180 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value greater than matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.greater, + options: { + value: 11, + }, + }); + + it('should match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values equlas to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value greater than or equal matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.greaterOrEqual, + options: { + value: 11, + }, + }); + + it('should match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match values equlas to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value lower than matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.lower, + options: { + value: 11, + }, + }); + + it('should match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values equal to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value lower than or equal matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.lowerOrEqual, + options: { + value: 11, + }, + }); + + it('should match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match values equal to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts new file mode 100644 index 00000000000..0a51d67d4eb --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts @@ -0,0 +1,91 @@ +import { Field, FieldType } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const isGreaterValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.greater, + name: 'Is greater', + description: 'Match when field value is greater than option.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value > options.value; + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is greater than: ${options.value}.`; + }, + isApplicable: field => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isGreaterOrEqualValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.greaterOrEqual, + name: 'Is greater or equal', + description: 'Match when field value is lower or greater than option.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value >= options.value; + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is lower or greater than: ${options.value}.`; + }, + isApplicable: field => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isLowerValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.lower, + name: 'Is lower', + description: 'Match when field value is lower than option.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value < options.value; + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is lower than: ${options.value}.`; + }, + isApplicable: field => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isLowerOrEqualValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.lowerOrEqual, + name: 'Is lower or equal', + description: 'Match when field value is lower or equal than option.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value <= options.value; + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is lower or equal than: ${options.value}.`; + }, + isApplicable: field => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +export const getNumericValueMatchers = (): ValueMatcherInfo[] => [ + isGreaterValueMatcher, + isGreaterOrEqualValueMatcher, + isLowerValueMatcher, + isLowerOrEqualValueMatcher, +]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts new file mode 100644 index 00000000000..710ed507721 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts @@ -0,0 +1,49 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value between matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10, 25], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.between, + options: { + from: 10, + to: 25, + }, + }); + + it('should match values greater than 10 but lower than 25', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values greater than 25', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts new file mode 100644 index 00000000000..adf3443a717 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts @@ -0,0 +1,26 @@ +import { Field, FieldType } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { RangeValueMatcherOptions } from './types'; + +const isBetweenValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.between, + name: 'Is between', + description: 'Match when field value is between given option values.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value > options.from && value < options.to; + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is between ${options.from} and ${options.to}.`; + }, + isApplicable: field => field.type === FieldType.number, + getDefaultOptions: () => ({ from: 0, to: 100 }), +}; + +export const getRangeValueMatchers = (): ValueMatcherInfo[] => [isBetweenValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts new file mode 100644 index 00000000000..379a6cf13a3 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts @@ -0,0 +1,85 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('regex value matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: ['.', 'asdf', 100, '25.5'], + }, + ], + }), + ]; + + describe('option with value .*', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '.*', + }, + }); + + it('should match all values', () => { + const frame = data[0]; + const field = frame.fields[0]; + + for (let i = 0; i < field.values.length; i++) { + expect(matcher(i, field, frame, data)).toBeTruthy(); + } + }); + }); + + describe('option with value \\w+', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '\\w+', + }, + }); + + it('should match wordy values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-wordy values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + }); + + describe('option with value \\d+', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '\\d+', + }, + }); + + it('should match numeric values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-numeric values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts new file mode 100644 index 00000000000..9872fe7c6e2 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts @@ -0,0 +1,25 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const regexValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.regex, + name: 'Regex', + description: 'Match when field value is matching regex.', + get: options => { + const regex = new RegExp(options.value); + + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return regex.test(value); + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is matching regex: ${options.value}`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '.*' }), +}; + +export const getRegexValueMatcher = (): ValueMatcherInfo[] => [regexValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts new file mode 100644 index 00000000000..f292129a375 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts @@ -0,0 +1,23 @@ +/** + * Describes a empty value matcher option. + * @public + */ +export interface ValueMatcherOptions {} + +/** + * Describes a basic value matcher option that has a single value. + * @public + */ +export interface BasicValueMatcherOptions extends ValueMatcherOptions { + value: T; +} + +/** + * Describes a range value matcher option that has a to and a from value to + * be able to match a range. + * @public + */ +export interface RangeValueMatcherOptions extends ValueMatcherOptions { + from: T; + to: T; +} diff --git a/packages/grafana-data/src/transformations/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts index 5c79375e321..d8f0b6edd16 100644 --- a/packages/grafana-data/src/transformations/transformers.ts +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -14,6 +14,7 @@ import { labelsToFieldsTransformer } from './transformers/labelsToFields'; import { ensureColumnsTransformer } from './transformers/ensureColumns'; import { groupByTransformer } from './transformers/groupBy'; import { mergeTransformer } from './transformers/merge'; +import { filterByValueTransformer } from './transformers/filterByValue'; export const standardTransformers = { noopTransformer, @@ -21,6 +22,7 @@ export const standardTransformers = { filterFieldsByNameTransformer, filterFramesTransformer, filterFramesByRefIdTransformer, + filterByValueTransformer, orderFieldsTransformer, organizeFieldsTransformer, reduceTransformer, diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts new file mode 100644 index 00000000000..c8287e03ff7 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts @@ -0,0 +1,219 @@ +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { DataTransformerConfig, FieldType, MatcherConfig } from '../../types'; +import { ArrayVector } from '../../vector'; +import { transformDataFrame } from '../transformDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { + FilterByValueMatch, + filterByValueTransformer, + FilterByValueTransformerOptions, + FilterByValueType, +} from './filterByValue'; +import { DataTransformerID } from './ids'; +import { ValueMatcherID } from '../matchers/ids'; +import { BasicValueMatcherOptions } from '../matchers/valueMatchers/types'; + +const seriesAWithSingleField = toDataFrame({ + name: 'A', + length: 7, + fields: [ + { name: 'time', type: FieldType.time, values: new ArrayVector([1000, 2000, 3000, 4000, 5000, 6000, 7000]) }, + { name: 'numbers', type: FieldType.number, values: new ArrayVector([1, 2, 3, 4, 5, 6, 7]) }, + ], +}); + +describe('FilterByValue transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([filterByValueTransformer]); + }); + + it('should exclude values', async () => { + const lower: MatcherConfig> = { + id: ValueMatcherID.lower, + options: { value: 6 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.exclude, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lower, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith(received => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([6000, 7000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([6, 7]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); + + it('should include values', async () => { + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 5 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith(received => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([1000, 2000, 3000, 4000, 5000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([1, 2, 3, 4, 5]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); + + it('should match any condition', async () => { + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 4 }, + }; + + const equal: MatcherConfig> = { + id: ValueMatcherID.equal, + options: { value: 7 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.any, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + { + fieldName: 'numbers', + config: equal, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith(received => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([1000, 2000, 3000, 4000, 7000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([1, 2, 3, 4, 7]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); + + it('should match all condition', async () => { + const greaterOrEqual: MatcherConfig> = { + id: ValueMatcherID.greaterOrEqual, + options: { value: 4 }, + }; + + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 5 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + { + fieldName: 'numbers', + config: greaterOrEqual, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith(received => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([4000, 5000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([4, 5]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.ts new file mode 100644 index 00000000000..3bb222c849c --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.ts @@ -0,0 +1,159 @@ +import { map } from 'rxjs/operators'; + +import { noopTransformer } from './noop'; +import { DataTransformerID } from './ids'; +import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; +import { DataFrame, Field } from '../../types/dataFrame'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { getValueMatcher } from '../matchers'; +import { ArrayVector } from '../../vector/ArrayVector'; + +export enum FilterByValueType { + exclude = 'exclude', + include = 'include', +} + +export enum FilterByValueMatch { + all = 'all', + any = 'any', +} + +export interface FilterByValueFilter { + fieldName: string; + config: MatcherConfig; +} + +export interface FilterByValueTransformerOptions { + filters: FilterByValueFilter[]; + type: FilterByValueType; + match: FilterByValueMatch; +} + +export const filterByValueTransformer: DataTransformerInfo = { + id: DataTransformerID.filterByValue, + name: 'Filter data by values', + description: 'select a subset of results based on values', + defaultOptions: { + filters: [], + type: FilterByValueType.include, + match: FilterByValueMatch.any, + }, + + operator: options => source => { + const filters = options.filters; + const matchAll = options.match === FilterByValueMatch.all; + const include = options.type === FilterByValueType.include; + + if (!Array.isArray(filters) || filters.length === 0) { + return source.pipe(noopTransformer.operator({})); + } + + return source.pipe( + map(data => { + if (!Array.isArray(data) || data.length === 0) { + return data; + } + + const rows = new Set(); + + for (const frame of data) { + const fieldIndexByName = groupFieldIndexByName(frame, data); + const matchers = createFilterValueMatchers(filters, fieldIndexByName); + + for (let index = 0; index < frame.length; index++) { + if (rows.has(index)) { + continue; + } + + let matching = true; + + for (const matcher of matchers) { + const match = matcher(index, frame, data); + + if (!matchAll && match) { + matching = true; + break; + } + + if (matchAll && !match) { + matching = false; + break; + } + + matching = match; + } + + if (matching) { + rows.add(index); + } + } + } + + const processed: DataFrame[] = []; + const frameLength = include ? rows.size : data[0].length - rows.size; + + for (const frame of data) { + const fields: Field[] = []; + + for (const field of frame.fields) { + const buffer = []; + + for (let index = 0; index < frame.length; index++) { + if (include && rows.has(index)) { + buffer.push(field.values.get(index)); + continue; + } + + if (!include && !rows.has(index)) { + buffer.push(field.values.get(index)); + continue; + } + } + + // TODO: what parts needs to be excluded from field. + fields.push({ + ...field, + values: new ArrayVector(buffer), + config: {}, + }); + } + + processed.push({ + ...frame, + fields: fields, + length: frameLength, + }); + } + + return processed; + }) + ); + }, +}; + +const createFilterValueMatchers = ( + filters: FilterByValueFilter[], + fieldIndexByName: Record +): Array<(index: number, frame: DataFrame, data: DataFrame[]) => boolean> => { + const noop = () => false; + + return filters.map(filter => { + const fieldIndex = fieldIndexByName[filter.fieldName] ?? -1; + + if (fieldIndex < 0) { + console.warn(`[FilterByValue] Could not find index for field name: ${filter.fieldName}`); + return noop; + } + + const matcher = getValueMatcher(filter.config); + return (index, frame, data) => matcher(index, frame.fields[fieldIndex], frame, data); + }); +}; + +const groupFieldIndexByName = (frame: DataFrame, data: DataFrame[]): Record => { + return frame.fields.reduce((all: Record, field, fieldIndex) => { + const fieldName = getFieldDisplayName(field, frame, data); + all[fieldName] = fieldIndex; + return all; + }, {}); +}; diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index 78523501e4a..ec1a30e064a 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -16,6 +16,7 @@ export enum DataTransformerID { filterFieldsByName = 'filterFieldsByName', filterFrames = 'filterFrames', filterByRefId = 'filterByRefId', + filterByValue = 'filterByValue', noop = 'noop', ensureColumns = 'ensureColumns', groupBy = 'groupBy', diff --git a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts index 291239469c3..6d2db5b1950 100644 --- a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts +++ b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts @@ -6,64 +6,25 @@ import { SelectFieldConfigSettings, StringFieldConfigSettings, } from '../field'; +import { OptionEditorConfig } from './options'; /** * Option editor registry item */ -export interface OptionsEditorItem extends RegistryItem { - /** - * Path of the options property to control. - * - * @example - * Given options object of a type: - * ```ts - * interface CustomOptions { - * a: { - * b: string; - * } - * } - * ``` - * - * path can be either 'a' or 'a.b'. - */ - path: (keyof TOptions & string) | string; +export interface OptionsEditorItem + extends RegistryItem, + OptionEditorConfig { /** * React component used to edit the options property */ editor: ComponentType; - /** - * Custom settings of the editor. - */ - settings?: TSettings; - /** - * Array of strings representing category of the options property. - */ - category?: string[]; - defaultValue?: TValue; - /** - * Function that enables configuration of when option editor should be shown based on current options properties. - * - * @param currentConfig Current options values - */ - showIf?: (currentConfig: TOptions) => boolean | undefined; - /** - * Function that returns number of items if given option represents a collection, i.e. array of items. + + /* * @param value */ getItemsCount?: (value?: TValue) => number; } -/** - * Configuration of option editor registry item - */ -interface OptionEditorConfig { - id: keyof TOptions & string; - name: string; - description?: string; - settings?: TSettings; - defaultValue?: TValue; -} - /** * Describes an API for option editors UI builder */ diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 9223477f83d..0f5e796eaf4 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -37,6 +37,7 @@ export interface FeatureToggles { ngalert: boolean; // Just for demo at the moment traceToLogs: boolean; + panelLibrary: boolean; /** * @remarks diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index 3fdd660a80b..d634ab11e6c 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -126,6 +126,13 @@ export interface FieldState { */ calcs?: FieldCalcs; + /** + * The numeric range for values in this field. This value will respect the min/max + * set in field config, or when set to `auto` this will have the min/max for all data + * in the response + */ + range?: NumericRange; + /** * Appropriate values for templating */ @@ -138,6 +145,12 @@ export interface FieldState { seriesIndex?: number; } +export interface NumericRange { + min?: number | null; + max?: number | null; + delta: number; +} + export interface DataFrame extends QueryResultBase { name?: string; fields: Field[]; // All fields of equal length diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index e27a65d0644..22bec524aea 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -36,10 +36,14 @@ export interface DataLink { // datas ource that we want to show to the user. Usually this results in a link to explore but can also lead to // more custom onClick behaviour if needed. // @internal and subject to change in future releases - internal?: { - query: T; - datasourceUid: string; - }; + internal?: InternalDataLink; +} + +/** @internal */ +export interface InternalDataLink { + query: T; + datasourceUid: string; + datasourceName: string; } export type LinkTarget = '_blank' | '_self' | undefined; diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index f6cfcdb874f..88ea58cb799 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -567,6 +567,7 @@ export interface DataSourceInstanceSettings extends Omit { - /** - * Path of the field config property to control. - * - * @example - * Given field config object of a type: - * ```ts - * interface CustomFieldConfig { - * a: { - * b: string; - * } - * } - * ``` - * - * path can be either 'a' or 'a.b'. - */ - path: (keyof TOptions & string) | string; - /** - * Name of the field config property. Will be displayed in the UI as form element label. - */ - name: string; - /** - * Description of the field config property. Will be displayed in the UI as form element description. - */ - description?: string; - /** - * Array of strings representing category of the field config property. First element in the array will make option render as collapsible section. - */ - category?: string[]; - /** - * Custom settings of the editor. - */ - settings?: TSettings; +export interface FieldConfigEditorConfig + extends OptionEditorConfig { /** * Function that allows specifying whether or not this field config should apply to a given field. * @param field */ shouldApply?: (field: Field) => boolean; - defaultValue?: TValue; - /** - * Function that enables configuration of when field config property editor should be shown based on current panel field config. - * - * @param currentConfig Current field config values - */ - showIf?: (currentConfig: TOptions) => boolean; } export interface FieldConfigPropertyItem @@ -114,10 +69,8 @@ export interface ApplyFieldOverrideOptions { data?: DataFrame[]; fieldConfig: FieldConfigSource; replaceVariables: InterpolateFunction; - getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined; theme: GrafanaTheme; timeZone?: TimeZone; - autoMinMax?: boolean; fieldConfigRegistry?: FieldConfigOptionsRegistry; } diff --git a/packages/grafana-data/src/types/options.ts b/packages/grafana-data/src/types/options.ts new file mode 100644 index 00000000000..d44879afefa --- /dev/null +++ b/packages/grafana-data/src/types/options.ts @@ -0,0 +1,53 @@ +/** + * Base class for editor builders + * + * @beta + */ +export interface OptionEditorConfig { + /** + * Path of the option property to control. + * + * @example + * Given options object of a type: + * ```ts + * interface Options { + * a: { + * b: string; + * } + * } + * ``` + * + * path can be either 'a' or 'a.b'. + */ + path: (keyof TOptions & string) | string; + + /** + * Name of the option. Will be displayed in the UI as form element label. + */ + name: string; + + /** + * Description of the option. Will be displayed in the UI as form element description. + */ + description?: string; + + /** + * Custom settings of the editor. + */ + settings?: TSettings; + + /** + * Array of strings representing category of the option. First element in the array will make option render as collapsible section. + */ + category?: string[]; + + /** + * Set this value if undefined + */ + defaultValue?: TValue; + + /** + * Function that enables configuration of when option editor should be shown based on current panel option properties. + */ + showIf?: (currentOptions: TOptions) => boolean | undefined; +} diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index a666b841641..5a6d33364b6 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -9,6 +9,7 @@ import { FieldConfigSource } from './fieldOverrides'; import { Registry } from '../utils'; import { StandardEditorProps } from '../field'; import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; +import { OptionEditorConfig } from './options'; export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string; @@ -140,47 +141,8 @@ export interface PanelOptionsEditorProps extends StandardEditorProps extends OptionsEditorItem, TValue> {} -export interface PanelOptionsEditorConfig { - /** - * Path of the option property to control. - * - * @example - * Given options object of a type: - * ```ts - * interface Options { - * a: { - * b: string; - * } - * } - * ``` - * - * path can be either 'a' or 'a.b'. - */ - path: (keyof TOptions & string) | string; - /** - * Name of the option. Will be displayed in the UI as form element label. - */ - name: string; - /** - * Description of the option. Will be displayed in the UI as form element description. - */ - description?: string; - /**al - * Custom settings of the editor. - */ - settings?: TSettings; - /** - * Array of strings representing category of the option. First element in the array will make option render as collapsible section. - */ - category?: string[]; - defaultValue?: TValue; - /** - * Function that enables configuration of when option editor should be shown based on current panel option properties. - * - * @param currentConfig Current panel options - */ - showIf?: (currentConfig: TOptions) => boolean | undefined; -} +export interface PanelOptionsEditorConfig + extends OptionEditorConfig {} /** * @internal diff --git a/packages/grafana-data/src/types/transformations.ts b/packages/grafana-data/src/types/transformations.ts index 386ae810619..eac4f448506 100644 --- a/packages/grafana-data/src/types/transformations.ts +++ b/packages/grafana-data/src/types/transformations.ts @@ -25,8 +25,14 @@ export interface DataTransformerConfig { options: TOptions; } -export type FieldMatcher = (field: Field, frame: DataFrame, allFrames: DataFrame[]) => boolean; export type FrameMatcher = (frame: DataFrame) => boolean; +export type FieldMatcher = (field: Field, frame: DataFrame, allFrames: DataFrame[]) => boolean; + +/** + * Value matcher type to describe the matcher function + * @public + */ +export type ValueMatcher = (valueIndex: number, field: Field, frame: DataFrame, allFrames: DataFrame[]) => boolean; export interface FieldMatcherInfo extends RegistryItemWithOptions { get: (options: TOptions) => FieldMatcher; @@ -36,6 +42,16 @@ export interface FrameMatcherInfo extends RegistryItemWithOption get: (options: TOptions) => FrameMatcher; } +/** + * Registry item to represent all the different valu matchers supported + * in the Grafana platform. + * @public + */ +export interface ValueMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => ValueMatcher; + isApplicable: (field: Field) => boolean; + getDefaultOptions: (field: Field) => TOptions; +} export interface MatcherConfig { id: string; options?: TOptions; diff --git a/packages/grafana-data/src/utils/dataLinks.test.ts b/packages/grafana-data/src/utils/dataLinks.test.ts index 087c2be4bd6..eaa21dd684d 100644 --- a/packages/grafana-data/src/utils/dataLinks.test.ts +++ b/packages/grafana-data/src/utils/dataLinks.test.ts @@ -4,33 +4,34 @@ import { ArrayVector } from '../vector'; describe('mapInternalLinkToExplore', () => { it('creates internal link', () => { - const link = mapInternalLinkToExplore( - { - url: '', - title: '', - internal: { - datasourceUid: 'uid', - query: { query: '12344' }, - }, + const dataLink = { + url: '', + title: '', + internal: { + datasourceUid: 'uid', + datasourceName: 'dsName', + query: { query: '12344' }, }, - {}, - {} as any, - { + }; + + const link = mapInternalLinkToExplore({ + link: dataLink, + internalLink: dataLink.internal, + scopedVars: {}, + range: {} as any, + field: { name: 'test', type: FieldType.number, config: {}, values: new ArrayVector([2]), }, - { - replaceVariables: val => val, - getDataSourceSettingsByUid: uid => ({ name: 'testDS' } as any), - } - ); + replaceVariables: val => val, + }); expect(link).toEqual( expect.objectContaining({ - title: 'testDS', - href: '/explore?left={"datasource":"testDS","queries":[{"query":"12344"}]}', + title: 'dsName', + href: '/explore?left={"datasource":"dsName","queries":[{"query":"12344"}]}', onClick: undefined, }) ); diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts index 0783f59966d..957907a092b 100644 --- a/packages/grafana-data/src/utils/dataLinks.ts +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -1,8 +1,8 @@ import { DataLink, DataQuery, - DataSourceInstanceSettings, Field, + InternalDataLink, InterpolateFunction, LinkModel, ScopedVars, @@ -27,41 +27,31 @@ export const DataLinkBuiltInVars = { }; // We inject these because we cannot import them directly as they reside inside grafana main package. -type Options = { +export type LinkToExploreOptions = { + link: DataLink; + scopedVars: ScopedVars; + range: TimeRange; + field: Field; + internalLink: InternalDataLink; onClickFn?: (options: { datasourceUid: string; query: any; range?: TimeRange }) => void; replaceVariables: InterpolateFunction; - getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined; }; -export function mapInternalLinkToExplore( - link: DataLink, - scopedVars: ScopedVars, - range: TimeRange, - field: Field, - options: Options -): LinkModel { - if (!link.internal) { - throw new Error('Trying to map external link as internal'); - } - const { onClickFn, replaceVariables, getDataSourceSettingsByUid } = options; +export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkModel { + const { onClickFn, replaceVariables, link, scopedVars, range, field, internalLink } = options; const interpolatedQuery = interpolateQuery(link, scopedVars, replaceVariables); - return { - title: link.title - ? replaceVariables(link.title || '', scopedVars) - : getDataSourceSettingsByUid(link.internal.datasourceUid)?.name || 'Unknown datasource', + const title = link.title ? link.title : internalLink.datasourceName; + return { + title: replaceVariables(title), // In this case this is meant to be internal link (opens split view by default) the href will also points // to explore but this way you can open it in new tab. - href: generateInternalHref( - getDataSourceSettingsByUid(link.internal.datasourceUid)?.name || 'unknown', - interpolatedQuery, - range - ), + href: generateInternalHref(internalLink.datasourceName, interpolatedQuery, range), onClick: onClickFn ? () => { - onClickFn?.({ - datasourceUid: link.internal!.datasourceUid, + onClickFn({ + datasourceUid: internalLink.datasourceUid, query: interpolatedQuery, range, }); diff --git a/packages/grafana-data/src/vector/vectorToArray.ts b/packages/grafana-data/src/vector/vectorToArray.ts index 34212651ab8..a0a17d71cb9 100644 --- a/packages/grafana-data/src/vector/vectorToArray.ts +++ b/packages/grafana-data/src/vector/vectorToArray.ts @@ -1,7 +1,7 @@ import { Vector } from '../types/vector'; export function vectorToArray(v: Vector): T[] { - const arr: T[] = []; + const arr: T[] = Array(v.length); for (let i = 0; i < v.length; i++) { arr[i] = v.get(i); } diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index fe57a9a3799..5f2c017127a 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -36,14 +36,14 @@ "rollup-plugin-typescript2": "0.26.0", "rollup-plugin-visualizer": "3.3.1", "ts-loader": "6.2.1", - "ts-node": "8.8.1" + "ts-node": "9.0.0" }, "types": "src/index.ts", "dependencies": { "@grafana/tsconfig": "^1.0.0-rc1", "commander": "5.0.0", "execa": "4.0.0", - "typescript": "4.0.2", + "typescript": "4.1.2", "yaml": "^1.8.3" } } diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index f7a827923ac..ac57809b073 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -1,5 +1,3 @@ -import { Pages } from './pages'; - export const Components = { DataSource: { TestData: { @@ -37,6 +35,9 @@ export const Components = { BarGauge: { value: 'Bar gauge value', }, + Text: { + container: () => '.markdown-html', + }, }, }, Drawer: { @@ -54,8 +55,8 @@ export const Components = { }, OptionsPane: { content: 'Panel editor option pane content', - close: Pages.Dashboard.Toolbar.toolbarItems('Close options pane'), - open: Pages.Dashboard.Toolbar.toolbarItems('Open options pane'), + close: 'Dashboard navigation bar button Close options pane', + open: 'Dashboard navigation bar button Open options pane', select: 'Panel editor option pane select', tab: (title: string) => `Panel editor option pane tab ${title}`, }, @@ -109,6 +110,7 @@ export const Components = { transformationEditorDebugger: (name: string) => `Transformation editor debugger ${name}`, }, Transforms: { + card: (name: string) => `New transform ${name}`, Reduce: { modeLabel: 'Transform mode label', calculationsLabel: 'Transform calculations label', diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 4f7b571f4fc..1a238aff590 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -1,3 +1,5 @@ +import { Components } from './components'; + export const Pages = { Login: { url: '/login', @@ -14,7 +16,6 @@ export const Pages = { delete: 'Data source settings page Delete button', saveAndTest: 'Data source settings page Save and Test button', alert: 'Data source settings page Alert', - alertMessage: 'Data source settings page Alert message', }, DataSources: { url: '/datasources', @@ -88,7 +89,7 @@ export const Pages = { submitButton: 'Variable editor Submit button', }, QueryVariable: { - queryOptionsDataSourceSelect: 'Variable editor Form Query DataSource select', + queryOptionsDataSourceSelect: Components.DataSourcePicker.container, queryOptionsRefreshSelect: 'Variable editor Form Query Refresh select', queryOptionsRegExInput: 'Variable editor Form Query RegEx field', queryOptionsSortSelect: 'Variable editor Form Query Sort select', @@ -100,6 +101,9 @@ export const Pages = { ConstantVariable: { constantOptionsQueryInput: 'Variable editor Form Constant Query field', }, + TextBoxVariable: { + textBoxOptionsQueryInput: 'Variable editor Form TextBox Query field', + }, }, }, }, diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index 5fe5fc38a2c..60468634930 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -54,7 +54,7 @@ "execa": "4.0.0", "resolve-as-bin": "2.1.0", "ts-loader": "6.2.1", - "typescript": "4.0.2", + "typescript": "4.1.2", "yaml": "^1.8.3" } } diff --git a/packages/grafana-e2e/src/flows/addDataSource.ts b/packages/grafana-e2e/src/flows/addDataSource.ts index d6b2353e820..cd1189f84b3 100644 --- a/packages/grafana-e2e/src/flows/addDataSource.ts +++ b/packages/grafana-e2e/src/flows/addDataSource.ts @@ -85,8 +85,10 @@ export const addDataSource = (config?: Partial) => { form(); e2e.pages.DataSource.saveAndTest().click(); - e2e.pages.DataSource.alert().should('exist'); - e2e.pages.DataSource.alertMessage().contains(expectedAlertMessage); // assertion + e2e.pages.DataSource.alert() + .should('exist') + .contains(expectedAlertMessage); // assertion + e2e().logToConsole('Added data source with name:', name); return e2e() diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index cd441ceb0ce..6c276ba0f37 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -31,7 +31,7 @@ "@grafana/tsconfig": "^1.0.0-rc1", "@rollup/plugin-commonjs": "11.0.2", "@rollup/plugin-node-resolve": "7.1.1", - "@types/jest": "26.0.0", + "@types/jest": "26.0.15", "@types/rollup-plugin-visualizer": "2.6.0", "@types/systemjs": "^0.20.6", "lodash": "4.17.19", @@ -41,7 +41,7 @@ "rollup-plugin-terser": "5.3.0", "rollup-plugin-typescript2": "0.26.0", "rollup-plugin-visualizer": "3.3.1", - "typescript": "4.0.2" + "typescript": "4.1.2" }, "types": "src/index.ts" } diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index bcb53c23dc8..64295fd97e5 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -57,6 +57,7 @@ export class GrafanaBootConfig implements GrafanaConfig { meta: false, ngalert: false, traceToLogs: false, + panelLibrary: false, }; licenseInfo: LicenseInfo = {} as LicenseInfo; rendererAvailable = false; diff --git a/packages/grafana-runtime/src/services/dataSourceSrv.ts b/packages/grafana-runtime/src/services/dataSourceSrv.ts index 0abf84952cc..1edc5b6f3b2 100644 --- a/packages/grafana-runtime/src/services/dataSourceSrv.ts +++ b/packages/grafana-runtime/src/services/dataSourceSrv.ts @@ -16,19 +16,25 @@ export interface DataSourceSrv { get(name?: string | null, scopedVars?: ScopedVars): Promise; /** - * Returns metadata based on UID. + * Get a list of data sources */ - getDataSourceSettingsByUid(uid: string): DataSourceInstanceSettings | undefined; + getList(filters?: GetDataSourceListFilters): DataSourceInstanceSettings[]; /** - * Get all data sources + * Get settings and plugin metadata by name or uid */ - getAll(): DataSourceInstanceSettings[]; + getInstanceSettings(nameOrUid: string | null | undefined): DataSourceInstanceSettings | undefined; +} - /** - * Get all data sources except for internal ones that usually should not be listed like mixed data source. - */ - getExternal(): DataSourceInstanceSettings[]; +/** @public */ +export interface GetDataSourceListFilters { + mixed?: boolean; + metrics?: boolean; + tracing?: boolean; + annotations?: boolean; + dashboard?: boolean; + variables?: boolean; + pluginId?: string; } let singletonInstance: DataSourceSrv; diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts index 622fcd4bd93..9cc83b45edb 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts @@ -19,19 +19,10 @@ const backendSrv = ({ jest.mock('../services', () => ({ getBackendSrv: () => backendSrv, -})); -jest.mock('..', () => ({ - config: { - bootData: { - user: { - orgId: 77, - }, - }, - datasources: { - sample: { - id: 8674, - }, - }, + getDataSourceSrv: () => { + return { + getInstanceSettings: () => ({ id: 8674 }), + }; }, })); @@ -46,6 +37,7 @@ describe('DataSourceWithBackend', () => { mockDatasourceRequest.mockReset(); mockDatasourceRequest.mockReturnValue(Promise.resolve({})); const ds = new MyDataSource(settings); + ds.query({ maxDataPoints: 10, intervalMs: 5000, @@ -64,7 +56,6 @@ describe('DataSourceWithBackend', () => { "datasourceId": 1234, "intervalMs": 5000, "maxDataPoints": 10, - "orgId": 77, "refId": "A", }, Object { @@ -72,7 +63,6 @@ describe('DataSourceWithBackend', () => { "datasourceId": 8674, "intervalMs": 5000, "maxDataPoints": 10, - "orgId": 77, "refId": "B", }, ], diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index 4c8e719a8f7..ebe0b12774d 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -9,8 +9,7 @@ import { } from '@grafana/data'; import { Observable, of } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; -import { config } from '..'; -import { getBackendSrv } from '../services'; +import { getBackendSrv, getDataSourceSrv } from '../services'; import { toDataQueryResponse } from './queryResponse'; const ExpressionDatasourceID = '__expr__'; @@ -57,34 +56,37 @@ export class DataSourceWithBackend< */ query(request: DataQueryRequest): Observable { const { intervalMs, maxDataPoints, range, requestId } = request; - const orgId = config.bootData.user.orgId; let targets = request.targets; + if (this.filterQuery) { targets = targets.filter(q => this.filterQuery!(q)); } + const queries = targets.map(q => { let datasourceId = this.id; + if (q.datasource === ExpressionDatasourceID) { return { ...q, datasourceId, - orgId, }; } + if (q.datasource) { - const dsName = q.datasource === 'default' ? config.defaultDatasource : q.datasource; - const ds = config.datasources[dsName]; + const ds = getDataSourceSrv().getInstanceSettings(q.datasource); + if (!ds) { throw new Error('Unknown Datasource: ' + q.datasource); } + datasourceId = ds.id; } + return { ...this.applyTemplateVariables(q, request.scopedVars), datasourceId, intervalMs, maxDataPoints, - orgId, }; }); @@ -93,9 +95,8 @@ export class DataSourceWithBackend< return of({ data: [] }); } - const body: any = { - queries, - }; + const body: any = { queries }; + if (range) { body.range = range; body.from = range.from.valueOf().toString(); diff --git a/packages/grafana-runtime/src/utils/queryResponse.test.ts b/packages/grafana-runtime/src/utils/queryResponse.test.ts index 25169669a0a..96d970c57cf 100644 --- a/packages/grafana-runtime/src/utils/queryResponse.test.ts +++ b/packages/grafana-runtime/src/utils/queryResponse.test.ts @@ -37,7 +37,7 @@ const emptyResults = { /* eslint-enable */ -describe('GEL Utils', () => { +describe('Query Response parser', () => { test('should parse output with dataframe', () => { const res = toDataQueryResponse(resp); const frames = res.data; diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json index 05cee5f7d5f..e8557dd9136 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -37,7 +37,7 @@ "@types/expect-puppeteer": "3.3.1", "@types/fs-extra": "^8.1.0", "@types/inquirer": "^6.0.3", - "@types/jest": "26.0.0", + "@types/jest": "26.0.15", "@types/node": "^12.0.4", "@types/prettier": "^1.16.4", "@types/puppeteer-core": "1.9.0", @@ -49,7 +49,7 @@ "@typescript-eslint/eslint-plugin": "4.0.1", "@typescript-eslint/parser": "4.0.1", "axios": "0.19.2", - "babel-jest": "24.8.0", + "babel-jest": "26.6.3", "babel-loader": "8.1.0", "babel-plugin-angularjs-annotate": "0.10.0", "chalk": "^2.4.2", @@ -72,8 +72,8 @@ "html-loader": "0.5.5", "html-webpack-plugin": "^3.2.0", "inquirer": "^6.3.1", - "jest": "^25", - "jest-canvas-mock": "2.1.2", + "jest": "26.6.3", + "jest-canvas-mock": "2.3.0", "jest-coverage-badges": "^1.1.2", "jest-environment-jsdom-fifteen": "^1.0.2", "jest-junit": "^6.4.0", @@ -101,11 +101,11 @@ "simple-git": "^1.132.0", "style-loader": "1.1.3", "terser-webpack-plugin": "2.3.5", - "ts-jest": "26.3.0", + "ts-jest": "26.4.4", "ts-loader": "6.2.1", - "ts-node": "8.8.1", - "tslib": "2.0.1", - "typescript": "4.0.2", + "ts-node": "9.0.0", + "tslib": "2.0.3", + "typescript": "4.1.2", "url-loader": "^2.0.1", "webpack": "4.41.5" }, diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts index d68e1d2da61..ecfc313b79c 100644 --- a/packages/grafana-toolkit/src/cli/index.ts +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -5,7 +5,6 @@ import chalk from 'chalk'; import { startTask } from './tasks/core.start'; import { changelogTask } from './tasks/changelog'; import { cherryPickTask } from './tasks/cherrypick'; -import { precommitTask } from './tasks/precommit'; import { templateTask } from './tasks/template'; import { pluginBuildTask } from './tasks/plugin.build'; import { toolkitBuildTask } from './tasks/toolkit.build'; @@ -21,6 +20,7 @@ import { pluginCreateTask } from './tasks/plugin.create'; import { pluginSignTask } from './tasks/plugin.sign'; import { bundleManagedTask } from './tasks/plugin/bundle.managed'; import { componentCreateTask } from './tasks/component.create'; +import { nodeVersionCheckerTask } from './tasks/nodeVersionChecker'; export const run = (includeInternalScripts = false) => { if (includeInternalScripts) { @@ -74,10 +74,10 @@ export const run = (includeInternalScripts = false) => { }); program - .command('precommit') - .description('Executes checks') + .command('node-version-check') + .description('Verify node version') .action(async cmd => { - await execTask(precommitTask)({}); + await execTask(nodeVersionCheckerTask)({}); }); program diff --git a/packages/grafana-toolkit/src/cli/tasks/nodeVersionChecker.ts b/packages/grafana-toolkit/src/cli/tasks/nodeVersionChecker.ts index 0d33a487498..3e9942b08b5 100644 --- a/packages/grafana-toolkit/src/cli/tasks/nodeVersionChecker.ts +++ b/packages/grafana-toolkit/src/cli/tasks/nodeVersionChecker.ts @@ -15,7 +15,7 @@ const packageJsonFile = 'package.json'; const failures: FailedVersionCheck[] = []; -export const nodeVersionFiles = [packageJsonFile, 'Dockerfile', '.circleci/config.yml']; +export const nodeVersionFiles = [packageJsonFile, 'Dockerfile']; const nodeVersionCheckerRunner: TaskRunner = async () => { // Read version from package json and treat that as the expected version in all other locations diff --git a/packages/grafana-toolkit/src/cli/tasks/precommit.ts b/packages/grafana-toolkit/src/cli/tasks/precommit.ts deleted file mode 100644 index 2fea0df41d2..00000000000 --- a/packages/grafana-toolkit/src/cli/tasks/precommit.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Task, TaskRunner } from './task'; -import chalk from 'chalk'; -// @ts-ignore -import get from 'lodash/get'; -// @ts-ignore -import flatten from 'lodash/flatten'; -import execa = require('execa'); -import { nodeVersionCheckerTask, nodeVersionFiles } from './nodeVersionChecker'; -import { execTask } from '../utils/execTask'; -const simpleGit = require('simple-git/promise')(process.cwd()); - -interface PrecommitOptions {} - -interface GitStatus { - files: GitFile[]; -} - -interface GitFile { - path: string; -} - -const precommitRunner: TaskRunner = async () => { - const status: GitStatus = await simpleGit.status(); - const sassFiles = status.files.filter( - file => file.path.match(/^[a-zA-Z0-9\_\-\/]+(\.scss)$/g) || file.path.indexOf('.sass-lint.yml') > -1 - ); - - const testFiles = status.files.filter(file => file.path.match(/^[a-zA-Z0-9\_\-\/]+(\.test.(ts|tsx))$/g)); - const goTestFiles = status.files.filter(file => file.path.match(/^[a-zA-Z0-9\_\-\/]+(\_test.go)$/g)); - const affectedNodeVersionFiles = status.files - .filter(file => nodeVersionFiles.indexOf(file.path) !== -1) - .map(f => f.path); - - const gruntTasks = []; - - if (affectedNodeVersionFiles.length > 0) { - await execTask(nodeVersionCheckerTask)({}); - } - - if (sassFiles.length > 0) { - gruntTasks.push('sasslint'); - } - - if (testFiles.length) { - gruntTasks.push('no-only-tests'); - } - - if (goTestFiles.length) { - gruntTasks.push('no-focus-convey-tests'); - } - - if (gruntTasks.length > 0) { - console.log(chalk.yellow(`Precommit checks: ${gruntTasks.join(', ')}`)); - const task = execa('grunt', gruntTasks); - // @ts-ignore - const stream = task.stdout; - if (stream) { - stream.pipe(process.stdout); - } - return task; - } - - console.log(chalk.yellow('Skipping precommit checks, no front-end changes detected')); - return undefined; -}; - -export const precommitTask = new Task('Precommit task', precommitRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts b/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts index aac41cf1392..4bfd09e6fe4 100644 --- a/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts +++ b/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts @@ -22,7 +22,7 @@ const compile = () => const savePackage = ({ path, pkg }: { path: string; pkg: {} }) => useSpinner('Updating package.json', async () => { - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { fs.writeFile(path, JSON.stringify(pkg, null, 2), err => { if (err) { reject(err); @@ -61,7 +61,7 @@ const copyFiles = () => { return useSpinner(`Moving ${files.join(', ')} files`, async () => { const promises = files.map(file => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const basedir = path.dirname(`${distDir}/${file}`); if (!fs.existsSync(basedir)) { fs.mkdirSync(basedir, { recursive: true }); @@ -85,7 +85,7 @@ const copySassFiles = () => { return useSpinner(`Copy scss files ${files.join(', ')} files`, async () => { const sassDir = path.resolve(cwd, '../../public/sass/'); const promises = files.map(file => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const name = file.replace('.generated', ''); fs.copyFile(`${sassDir}/${file}`, `${distDir}/sass/${name}`, err => { if (err) { diff --git a/packages/grafana-ui/.storybook/main.ts b/packages/grafana-ui/.storybook/main.ts index e32a86bfa26..6a46779233e 100644 --- a/packages/grafana-ui/.storybook/main.ts +++ b/packages/grafana-ui/.storybook/main.ts @@ -26,7 +26,9 @@ module.exports = { check: true, reactDocgen: 'react-docgen-typescript', reactDocgenTypescriptOptions: { + tsconfigPath: path.resolve(__dirname, 'tsconfig.json'), shouldExtractLiteralValuesFromEnum: true, + shouldRemoveUndefinedFromOptional: true, propFilter: (prop: any) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true), }, }, @@ -44,21 +46,6 @@ module.exports = { configFile: path.resolve(__dirname, 'tsconfig.json'), }, }, - { - loader: require.resolve('react-docgen-typescript-loader'), - options: { - tsconfigPath: path.resolve(__dirname, 'tsconfig.json'), - // https://github.com/styleguidist/react-docgen-typescript#parseroptions - // @ts-ignore - propFilter: prop => { - if (prop.parent) { - return !prop.parent.fileName.includes('node_modules/@types/react/'); - } - - return true; - }, - }, - }, ], }, { diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index fc805f88650..b4aea6280d7 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -71,24 +71,25 @@ "react-transition-group": "4.4.1", "slate": "0.47.8", "tinycolor2": "1.4.1", - "uplot": "1.4.4" + "uplot": "1.4.7" }, "devDependencies": { - "@rollup/plugin-commonjs": "11.0.2", - "@rollup/plugin-image": "2.0.4", - "@rollup/plugin-node-resolve": "7.1.1", - "@storybook/addon-essentials": "6.1.2", - "@storybook/addon-controls": "6.1.2", - "@storybook/addon-knobs": "6.1.2", - "@storybook/addon-storysource": "6.1.2", - "@storybook/react": "6.1.2", - "@storybook/theming": "6.1.2", + "@rollup/plugin-commonjs": "16.0.0", + "@rollup/plugin-image": "2.0.5", + "@rollup/plugin-node-resolve": "10.0.0", + "@storybook/addon-essentials": "6.1.9", + "@storybook/addon-controls": "6.1.9", + "@storybook/addon-knobs": "6.1.9", + "@storybook/addon-storysource": "6.1.9", + "@storybook/react": "6.1.9", + "@storybook/theming": "6.1.9", "@types/classnames": "2.2.7", "@types/common-tags": "^1.8.0", "@types/d3": "5.7.2", - "@types/jest": "26.0.0", + "@types/jest": "26.0.15", "@types/jquery": "3.3.38", "@types/lodash": "4.14.123", + "@types/mock-raf": "1.0.2", "@types/node": "10.14.1", "@types/papaparse": "5.2.0", "@types/react": "16.9.9", @@ -98,18 +99,19 @@ "@types/rollup-plugin-visualizer": "2.6.0", "@types/tinycolor2": "1.4.1", "common-tags": "^1.8.0", + "mock-raf": "1.0.1", "pretty-format": "25.1.0", "react-docgen-typescript-loader": "3.7.2", "react-test-renderer": "16.13.1", "react-is": "16.8.0", - "rollup": "2.0.6", - "rollup-plugin-sourcemaps": "0.5.0", - "rollup-plugin-terser": "5.3.0", - "rollup-plugin-typescript2": "0.26.0", - "rollup-plugin-visualizer": "3.3.1", + "rollup": "2.33.3", + "rollup-plugin-sourcemaps": "0.6.3", + "rollup-plugin-terser": "7.0.2", + "rollup-plugin-typescript2": "0.29.0", + "rollup-plugin-visualizer": "4.2.0", "storybook-dark-mode": "1.0.3", "ts-loader": "8.0.11", - "typescript": "4.0.2", + "typescript": "4.1.2", "webpack-filter-warnings-plugin": "1.2.1" }, "types": "src/index.ts" diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts index bbe55cb54c5..eabac2ba334 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/packages/grafana-ui/rollup.config.ts @@ -1,7 +1,6 @@ import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import image from '@rollup/plugin-image'; -// import sourceMaps from 'rollup-plugin-sourcemaps'; import { terser } from 'rollup-plugin-terser'; const pkg = require('./package.json'); @@ -42,51 +41,8 @@ const buildCjsPackage = ({ env }) => { include: /node_modules/, // When 'rollup-plugin-commonjs' fails to properly convert the CommonJS modules to ES6 one has to manually name the exports // https://github.com/rollup/rollup-plugin-commonjs#custom-named-exports - namedExports: { - 'node_modules/lodash/lodash.js': [ - 'flatten', - 'find', - 'upperFirst', - 'debounce', - 'isNil', - 'isNumber', - 'flattenDeep', - 'map', - 'chunk', - 'sortBy', - 'uniqueId', - 'zip', - 'omit', - 'isString', - 'isEmpty', - 'toLower', - ], - '../../node_modules/react-color/lib/components/common': ['Saturation', 'Hue', 'Alpha'], - '../../node_modules/immutable/dist/immutable.js': [ - 'Record', - 'Set', - 'Map', - 'List', - 'OrderedSet', - 'is', - 'Stack', - ], - 'node_modules/immutable/dist/immutable.js': ['Record', 'Set', 'Map', 'List', 'OrderedSet', 'is', 'Stack'], - '../../node_modules/esrever/esrever.js': ['reverse'], - '../../node_modules/react-table/index.js': [ - 'useTable', - 'useSortBy', - 'useBlockLayout', - 'Cell', - 'useResizeColumns', - 'useAbsoluteLayout', - 'useFilters', - ], - '../../node_modules/react-is/index.js': ['isMemo'], - }, }), resolve(), - // sourceMaps(), image(), env === 'production' && terser(), ], diff --git a/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx index c4543d43abb..451197091d9 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.tsx @@ -1,4 +1,4 @@ -import React, { FC, ReactNode } from 'react'; +import React, { FC, HTMLAttributes, ReactNode } from 'react'; import { css } from 'emotion'; import { GrafanaTheme } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -9,7 +9,7 @@ import { getColorsFromSeverity } from '../../utils/colors'; export type AlertVariant = 'success' | 'warning' | 'error' | 'info'; -export interface Props { +export interface Props extends HTMLAttributes { title: string; /** On click handler for alert button, mostly used for dismissing the alert */ onRemove?: (event: React.MouseEvent) => void; @@ -39,40 +39,36 @@ function getIconFromSeverity(severity: AlertVariant): string { } } -export const Alert: FC = ({ - title, - buttonText, - onButtonClick, - onRemove, - children, - buttonContent, - severity = 'error', -}) => { - const theme = useTheme(); - const styles = getStyles(theme, severity, !!buttonContent); +export const Alert: FC = React.forwardRef( + ({ title, buttonText, onButtonClick, onRemove, children, buttonContent, severity = 'error', ...restProps }, ref) => { + const theme = useTheme(); + const styles = getStyles(theme, severity, !!buttonContent); - return ( -
-
- + return ( +
+
+ +
+
+
{title}
+ {children &&
{children}
} +
+ {/* If onRemove is specified, giving preference to onRemove */} + {onRemove ? ( + + ) : onButtonClick ? ( + + ) : null}
-
-
{title}
- {children &&
{children}
} -
- {/* If onRemove is specified, giving preference to onRemove */} - {onRemove ? ( - - ) : onButtonClick ? ( - - ) : null} -
- ); -}; + ); + } +); + +Alert.displayName = 'Alert'; const getStyles = (theme: GrafanaTheme, severity: AlertVariant, outline: boolean) => { const { white } = theme.palette; @@ -107,6 +103,8 @@ const getStyles = (theme: GrafanaTheme, severity: AlertVariant, outline: boolean body: css` flex-grow: 1; margin: 0 ${theme.spacing.md} 0 0; + overflow-wrap: break-word; + word-break: break-word; a { color: ${white}; diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index f051c6e862d..d67f110b53d 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -14,6 +14,7 @@ import { getFieldColorMode, getColorForTheme, FALLBACK_COLOR, + TextDisplayOptions, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -42,6 +43,7 @@ export interface Props extends Themeable { display?: DisplayProcessor; value: DisplayValue; orientation: VizOrientation; + text?: TextDisplayOptions; itemSpacing?: number; lcdCellWidth?: number; displayMode: BarGaugeDisplayMode; @@ -172,7 +174,7 @@ export class BarGauge extends PureComponent { } renderRetroBars(): ReactNode { - const { field, value, itemSpacing, alignmentFactors, orientation, lcdCellWidth } = this.props; + const { field, value, itemSpacing, alignmentFactors, orientation, lcdCellWidth, text } = this.props; const { valueHeight, valueWidth, @@ -193,7 +195,7 @@ export class BarGauge extends PureComponent { const valueColor = getValueColor(this.props); const valueToBaseSizeOn = alignmentFactors ? alignmentFactors : value; - const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation); + const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation, text); const containerStyles: CSSProperties = { width: `${wrapperWidth}px`, @@ -270,7 +272,7 @@ function isVertical(orientation: VizOrientation) { } function calculateTitleDimensions(props: Props): TitleDimensions { - const { height, width, alignmentFactors, orientation } = props; + const { height, width, alignmentFactors, orientation, text } = props; const title = alignmentFactors ? alignmentFactors.title : props.value.title; if (!title) { @@ -278,16 +280,26 @@ function calculateTitleDimensions(props: Props): TitleDimensions { } if (isVertical(orientation)) { + const fontSize = text?.titleSize ?? 14; return { - fontSize: 14, + fontSize: fontSize, width: width, - height: 14 * TITLE_LINE_HEIGHT, + height: fontSize * TITLE_LINE_HEIGHT, placement: 'below', }; } // if height above 40 put text to above bar if (height > 40) { + if (text?.titleSize) { + return { + fontSize: text?.titleSize, + width: 0, + height: text.titleSize * TITLE_LINE_HEIGHT, + placement: 'above', + }; + } + const maxTitleHeightRatio = 0.45; const titleHeight = Math.max(Math.min(height * maxTitleHeightRatio, MAX_VALUE_HEIGHT), 17); @@ -306,7 +318,7 @@ function calculateTitleDimensions(props: Props): TitleDimensions { const textSize = measureText(title, titleFontSize); return { - fontSize: titleFontSize, + fontSize: text?.titleSize ?? titleFontSize, height: 0, width: textSize.width + 15, placement: 'left', @@ -370,7 +382,7 @@ interface BarAndValueDimensions { } function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions { - const { height, width, orientation } = props; + const { height, width, orientation, text } = props; const titleDim = calculateTitleDimensions(props); let maxBarHeight = 0; @@ -381,14 +393,23 @@ function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions { let wrapperHeight = 0; if (isVertical(orientation)) { - valueHeight = Math.min(Math.max(height * 0.1, MIN_VALUE_HEIGHT), MAX_VALUE_HEIGHT); + if (text?.valueSize) { + valueHeight = text.valueSize * VALUE_LINE_HEIGHT; + } else { + valueHeight = Math.min(Math.max(height * 0.1, MIN_VALUE_HEIGHT), MAX_VALUE_HEIGHT); + } valueWidth = width; maxBarHeight = height - (titleDim.height + valueHeight); maxBarWidth = width; wrapperWidth = width; wrapperHeight = height - titleDim.height; } else { - valueHeight = height - titleDim.height; + if (text?.valueSize) { + valueHeight = text.valueSize * VALUE_LINE_HEIGHT; + } else { + valueHeight = height - titleDim.height; + } + valueWidth = Math.max(Math.min(width * 0.2, MAX_VALUE_WIDTH), MIN_VALUE_WIDTH); maxBarHeight = height - titleDim.height; maxBarWidth = width - valueWidth - titleDim.width; @@ -420,14 +441,14 @@ export function getValuePercent(value: number, minValue: number, maxValue: numbe * Only exported to for unit test */ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles { - const { displayMode, field, value, alignmentFactors, orientation, theme } = props; + const { displayMode, field, value, alignmentFactors, orientation, theme, text } = props; const { valueWidth, valueHeight, maxBarHeight, maxBarWidth } = calculateBarAndValueDimensions(props); const valuePercent = getValuePercent(value.numeric, field.min!, field.max!); const valueColor = getValueColor(props); const valueToBaseSizeOn = alignmentFactors ? alignmentFactors : value; - const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation); + const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation, text); const isBasic = displayMode === 'basic'; const wrapperStyles: CSSProperties = { @@ -581,7 +602,8 @@ function getValueStyles( color: string, width: number, height: number, - orientation: VizOrientation + orientation: VizOrientation, + text?: TextDisplayOptions ): CSSProperties { const styles: CSSProperties = { color, @@ -597,15 +619,12 @@ function getValueStyles( const formattedValueString = formattedValueToString(value); if (isVertical(orientation)) { - styles.fontSize = calculateFontSize(formattedValueString, textWidth, height, VALUE_LINE_HEIGHT); + styles.fontSize = text?.valueSize ?? calculateFontSize(formattedValueString, textWidth, height, VALUE_LINE_HEIGHT); styles.justifyContent = `center`; } else { - styles.fontSize = calculateFontSize( - formattedValueString, - textWidth - VALUE_LEFT_PADDING * 2, - height, - VALUE_LINE_HEIGHT - ); + styles.fontSize = + text?.valueSize ?? + calculateFontSize(formattedValueString, textWidth - VALUE_LEFT_PADDING * 2, height, VALUE_LINE_HEIGHT); styles.justifyContent = `flex-end`; styles.paddingLeft = `${VALUE_LEFT_PADDING}px`; styles.paddingRight = `${VALUE_LEFT_PADDING}px`; diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.tsx index 41fdc774a3b..ade30ba069c 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValue.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValue.tsx @@ -1,6 +1,6 @@ // Library import React, { PureComponent } from 'react'; -import { DisplayValue, GraphSeriesValue, DisplayValueAlignmentFactors } from '@grafana/data'; +import { DisplayValue, GraphSeriesValue, DisplayValueAlignmentFactors, TextDisplayOptions } from '@grafana/data'; // Types import { Themeable } from '../../types'; @@ -64,6 +64,8 @@ export interface Props extends Themeable { justifyMode?: BigValueJustifyMode; /** Factors that should influence the positioning of the text */ alignmentFactors?: DisplayValueAlignmentFactors; + /** Explicit font size control */ + text?: TextDisplayOptions; /** Specify which text should be visible in the BigValue */ textMode?: BigValueTextMode; diff --git a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx index 8ec540bee55..7e1e7bbbf42 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx @@ -29,7 +29,7 @@ export abstract class BigValueLayout { textValues: BigValueTextValues; constructor(private props: Props) { - const { width, height, value, theme } = props; + const { width, height, value, theme, text } = props; this.valueColor = getColorForTheme(value.color || 'green', theme); this.panelPadding = height > 100 ? 12 : 8; @@ -43,6 +43,18 @@ export abstract class BigValueLayout { this.chartWidth = 0; this.maxTextWidth = width - this.panelPadding * 2; this.maxTextHeight = height - this.panelPadding * 2; + + // Explicit font sizing + if (text) { + if (text.titleSize) { + this.titleFontSize = text.titleSize; + this.titleToAlignTo = undefined; + } + if (text.valueSize) { + this.valueFontSize = text.valueSize; + this.valueToAlignTo = ''; + } + } } getTitleStyles(): CSSProperties { @@ -235,9 +247,9 @@ export class WideNoChartLayout extends BigValueLayout { constructor(props: Props) { super(props); - const valueWidthPercent = 0.3; + const valueWidthPercent = this.titleToAlignTo?.length ? 0.3 : 1.0; - if (this.titleToAlignTo && this.titleToAlignTo.length > 0) { + if (this.valueToAlignTo.length) { // initial value size this.valueFontSize = calculateFontSize( this.valueToAlignTo, @@ -245,7 +257,9 @@ export class WideNoChartLayout extends BigValueLayout { this.maxTextHeight, LINE_HEIGHT ); + } + if (this.titleToAlignTo?.length) { // How big can we make the title and still have it fit this.titleFontSize = calculateFontSize( this.titleToAlignTo, @@ -257,9 +271,6 @@ export class WideNoChartLayout extends BigValueLayout { // make sure it's a bit smaller than valueFontSize this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize); - } else { - // if no title wide - this.valueFontSize = calculateFontSize(this.valueToAlignTo, this.maxTextWidth, this.maxTextHeight, LINE_HEIGHT); } } @@ -292,6 +303,7 @@ export class WideWithChartLayout extends BigValueLayout { super(props); const { width, height } = props; + const chartHeightPercent = 0.5; const titleWidthPercent = 0.6; const valueWidthPercent = 1 - titleWidthPercent; @@ -300,7 +312,7 @@ export class WideWithChartLayout extends BigValueLayout { this.chartWidth = width; this.chartHeight = height * chartHeightPercent; - if (this.titleToAlignTo && this.titleToAlignTo.length > 0) { + if (this.titleToAlignTo?.length) { this.titleFontSize = calculateFontSize( this.titleToAlignTo, this.maxTextWidth * titleWidthPercent, @@ -310,12 +322,14 @@ export class WideWithChartLayout extends BigValueLayout { ); } - this.valueFontSize = calculateFontSize( - this.valueToAlignTo, - this.maxTextWidth * valueWidthPercent, - this.maxTextHeight * chartHeightPercent, - LINE_HEIGHT - ); + if (this.valueToAlignTo.length) { + this.valueFontSize = calculateFontSize( + this.valueToAlignTo, + this.maxTextWidth * valueWidthPercent, + this.maxTextHeight * chartHeightPercent, + LINE_HEIGHT + ); + } } getValueAndTitleContainerStyles() { @@ -350,7 +364,7 @@ export class StackedWithChartLayout extends BigValueLayout { this.chartHeight = height * chartHeightPercent; this.chartWidth = width; - if (this.titleToAlignTo && this.titleToAlignTo.length > 0) { + if (this.titleToAlignTo?.length) { this.titleFontSize = calculateFontSize( this.titleToAlignTo, this.maxTextWidth, @@ -358,19 +372,22 @@ export class StackedWithChartLayout extends BigValueLayout { LINE_HEIGHT, MAX_TITLE_SIZE ); + } + titleHeight = this.titleFontSize * LINE_HEIGHT; - titleHeight = this.titleFontSize * LINE_HEIGHT; + if (this.valueToAlignTo.length) { + this.valueFontSize = calculateFontSize( + this.valueToAlignTo, + this.maxTextWidth, + this.maxTextHeight - this.chartHeight - titleHeight, + LINE_HEIGHT + ); } - this.valueFontSize = calculateFontSize( - this.valueToAlignTo, - this.maxTextWidth, - this.maxTextHeight - this.chartHeight - titleHeight, - LINE_HEIGHT - ); - // make title fontsize it's a bit smaller than valueFontSize - this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize); + if (this.titleToAlignTo?.length) { + this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize); + } // make chart take up unused space this.chartHeight = height - this.titleFontSize * LINE_HEIGHT - this.valueFontSize * LINE_HEIGHT; @@ -398,7 +415,7 @@ export class StackedWithNoChartLayout extends BigValueLayout { const titleHeightPercent = 0.15; let titleHeight = 0; - if (this.titleToAlignTo && this.titleToAlignTo.length > 0) { + if (this.titleToAlignTo?.length) { this.titleFontSize = calculateFontSize( this.titleToAlignTo, this.maxTextWidth, @@ -410,12 +427,14 @@ export class StackedWithNoChartLayout extends BigValueLayout { titleHeight = this.titleFontSize * LINE_HEIGHT; } - this.valueFontSize = calculateFontSize( - this.valueToAlignTo, - this.maxTextWidth, - this.maxTextHeight - titleHeight, - LINE_HEIGHT - ); + if (this.valueToAlignTo.length) { + this.valueFontSize = calculateFontSize( + this.valueToAlignTo, + this.maxTextWidth, + this.maxTextHeight - titleHeight, + LINE_HEIGHT + ); + } // make title fontsize it's a bit smaller than valueFontSize this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize); diff --git a/packages/grafana-ui/src/components/Button/Button.story.tsx b/packages/grafana-ui/src/components/Button/Button.story.tsx index 16612418b99..c73414e90a5 100644 --- a/packages/grafana-ui/src/components/Button/Button.story.tsx +++ b/packages/grafana-ui/src/components/Button/Button.story.tsx @@ -20,17 +20,13 @@ export default { docs: { page: mdx, }, + knobs: { + disabled: true, + }, }, }; -export const Simple: Story = ({ disabled, icon, children, size, variant }) => { - return ( - - ); -}; - +export const Simple: Story = ({ children, ...args }) => ; Simple.args = { variant: 'primary', size: 'md', diff --git a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx index 227058a4530..e976629d1c6 100644 --- a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx +++ b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx @@ -1,5 +1,4 @@ -import { PureComponent } from 'react'; -import ReactDOM from 'react-dom'; +import React, { PureComponent, createRef } from 'react'; export interface Props { /** @@ -28,6 +27,7 @@ export class ClickOutsideWrapper extends PureComponent { parent: window, useCapture: false, }; + myRef = createRef(); state = { hasEventListener: false, }; @@ -48,7 +48,7 @@ export class ClickOutsideWrapper extends PureComponent { } onOutsideClick = (event: any) => { - const domNode = ReactDOM.findDOMNode(this) as Element; + const domNode = this.myRef.current; if (!domNode || !domNode.contains(event.target)) { this.props.onClick(); @@ -56,6 +56,6 @@ export class ClickOutsideWrapper extends PureComponent { }; render() { - return this.props.children; + return
{this.props.children}
; } } diff --git a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx index 91c59ffb417..26e3f64fdee 100644 --- a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx @@ -19,20 +19,22 @@ export const SeriesColorPickerPopover: FunctionComponent ( - { - if (onToggleAxis) { - onToggleAxis(); - } - }} - /> - ), + tabComponent() { + return ( + { + if (onToggleAxis) { + onToggleAxis(); + } + }} + /> + ); + }, }, }} /> diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx index bd22d22b6ad..cc76477cd66 100644 --- a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx @@ -13,6 +13,7 @@ export interface SpectrumPaletteProps extends Themeable { onChange: (color: string) => void; } +// eslint-disable-next-line react/display-name const renderPointer = (theme: GrafanaTheme) => (props: SpectrumPalettePointerProps) => ( ); diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx index 6079ac5d3a6..7a584f08f1e 100644 --- a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx @@ -191,6 +191,7 @@ const ContextMenuItemComponent: React.FC = React.memo( ); } ); +ContextMenuItemComponent.displayName = 'ContextMenuItemComponent'; interface ContextMenuGroupProps { group: ContextMenuGroup; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx index 821cde4c7ce..fa696622a1e 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -6,7 +6,7 @@ import { SelectionReference } from './SelectionReference'; import { Portal, getFormStyles } from '../index'; // @ts-ignore -import Prism from 'prismjs'; +import Prism, { Grammar, LanguageMap } from 'prismjs'; import { Editor } from '@grafana/slate-react'; import { Value } from 'slate'; import Plain from 'slate-plain-serializer'; @@ -27,11 +27,20 @@ interface DataLinkInputProps { placeholder?: string; } +const datalinksSyntax: Grammar = { + builtInVariable: { + pattern: /(\${\S+?})/, + }, +}; + const plugins = [ - SlatePrism({ - onlyIn: (node: any) => node.type === 'code_block', - getSyntax: () => 'links', - }), + SlatePrism( + { + onlyIn: (node: any) => node.type === 'code_block', + getSyntax: () => 'links', + }, + { ...(Prism.languages as LanguageMap), links: datalinksSyntax } + ), ]; const getStyles = stylesFactory((theme: GrafanaTheme) => ({ @@ -56,19 +65,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({ `, })); -export const enableDatalinksPrismSyntax = () => { - Prism.languages['links'] = { - builtInVariable: { - pattern: /(\${\S+?})/, - }, - }; -}; - // This memoised also because rerendering the slate editor grabs focus which created problem in some cases this // was used and changes to different state were propagated here. export const DataLinkInput: React.FC = memo( ({ value, onChange, suggestions, placeholder = 'http://your-grafana.com/d/000000010/annotations' }) => { - enableDatalinksPrismSyntax(); const editorRef = useRef() as RefObject; const theme = useContext(ThemeContext); const styles = getStyles(theme); diff --git a/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap b/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap index 049d927b50e..0d5632f1989 100644 --- a/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap +++ b/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap @@ -4,11 +4,11 @@ exports[`FormField should render component with custom inputEl 1`] = `
- Test - + Input @@ -22,11 +22,11 @@ exports[`FormField should render component with default inputEl 1`] = `
- Test - + { - const CONTAINER_GROUP = 'Container options'; - // --- - const containerWidth = number( - 'Container width', - 300, - { - range: true, - min: 100, - max: 500, - step: 10, - }, - CONTAINER_GROUP - ); +export const Simple: Story = args => ( +
+ + + +
+); - const BEHAVIOUR_GROUP = 'Behaviour props'; - const disabled = boolean('Disabled', false, BEHAVIOUR_GROUP); - const invalid = boolean('Invalid', false, BEHAVIOUR_GROUP); - const loading = boolean('Loading', false, BEHAVIOUR_GROUP); - const error = text('Error message', '', BEHAVIOUR_GROUP); - - return { containerWidth, disabled, invalid, loading, error }; +Simple.args = { + label: 'Graphite API key', + description: 'Your Graphite instance API key', + disabled: false, + invalid: false, + loading: false, + error: 'Not valid input', + horizontal: false, }; -export const Simple = () => { - const { containerWidth, ...otherProps } = getKnobs(); - return ( -
- - - -
- ); -}; - -export const HorizontalLayout = () => { +export const HorizontalLayout: Story = args => { const [checked, setChecked] = useState(false); const onChange = useCallback(e => setChecked(e.currentTarget.checked), [setChecked]); - const { containerWidth, ...otherProps } = getKnobs(); return ( -
- +
+
); }; + +HorizontalLayout.args = { + label: 'Show labels', + description: 'Display threshold labels', + disabled: false, + invalid: false, + loading: false, + error: 'Not valid input', + horizontal: true, +}; diff --git a/packages/grafana-ui/src/components/Forms/Form.story.tsx b/packages/grafana-ui/src/components/Forms/Form.story.tsx index ded2eb74857..4e76f241696 100644 --- a/packages/grafana-ui/src/components/Forms/Form.story.tsx +++ b/packages/grafana-ui/src/components/Forms/Form.story.tsx @@ -197,7 +197,7 @@ export const AsyncValidation = () => { const validateAsync = (shouldPass: boolean) => async () => { try { - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { setTimeout(() => { if (shouldPass) { resolve(); diff --git a/packages/grafana-ui/src/components/Forms/Legacy/Select/ButtonSelect.tsx b/packages/grafana-ui/src/components/Forms/Legacy/Select/ButtonSelect.tsx index 21e0427b5b2..8cceb55a4c4 100644 --- a/packages/grafana-ui/src/components/Forms/Legacy/Select/ButtonSelect.tsx +++ b/packages/grafana-ui/src/components/Forms/Legacy/Select/ButtonSelect.tsx @@ -11,6 +11,7 @@ interface ButtonComponentProps { iconClass?: string; } +// eslint-disable-next-line react/display-name const ButtonComponent = (buttonProps: ButtonComponentProps) => (props: any) => { const { label, className, iconClass } = buttonProps; diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx index 5ceccd9e7ce..624e93e20e6 100644 --- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx +++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx @@ -80,9 +80,8 @@ const getRadioButtonStyles = stylesFactory((theme: GrafanaTheme, size: RadioButt background: ${bg}; cursor: pointer; z-index: 1; - flex-grow: ${fullWidth ? 1 : 0}; + flex: ${fullWidth ? `1 0 0` : 'none'}; text-align: center; - user-select: none; &:hover { diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx index d3c720c9715..aed2703e34f 100644 --- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx +++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef } from 'react'; -import { css } from 'emotion'; +import { css, cx } from 'emotion'; import uniqueId from 'lodash/uniqueId'; import { SelectableValue } from '@grafana/data'; import { RadioButtonSize, RadioButton } from './RadioButton'; @@ -44,6 +44,7 @@ interface RadioButtonGroupProps { onChange?: (value?: T) => void; size?: RadioButtonSize; fullWidth?: boolean; + className?: string; } export function RadioButtonGroup({ @@ -53,6 +54,7 @@ export function RadioButtonGroup({ disabled, disabledOptions, size = 'md', + className, fullWidth = false, }: RadioButtonGroupProps) { const handleOnChange = useCallback( @@ -70,7 +72,7 @@ export function RadioButtonGroup({ const styles = getRadioButtonGroupStyles(); return ( -
+
{options.map((o, i) => { const isItemDisabled = disabledOptions && o.value && disabledOptions.includes(o.value); return ( diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index 2e98f8f7cd0..0372c5d9ae5 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -10,6 +10,7 @@ import { getColorForTheme, FieldColorModeId, FALLBACK_COLOR, + TextDisplayOptions, } from '@grafana/data'; import { Themeable } from '../../types'; import { calculateFontSize } from '../../utils/measureText'; @@ -21,6 +22,7 @@ export interface Props extends Themeable { showThresholdLabels: boolean; width: number; value: DisplayValue; + text?: TextDisplayOptions; onClick?: React.MouseEventHandler; className?: string; } @@ -108,7 +110,7 @@ export class Gauge extends PureComponent { // remove gauge & marker width (on left and right side) // and 10px is some padding that flot adds to the outer canvas const valueWidth = valueWidthBase - ((gaugeWidth + (showThresholdMarkers ? thresholdMarkersWidth : 0)) * 2 + 10); - const fontSize = calculateFontSize(text, valueWidth, dimension, 1, gaugeWidth * 1.7); + const fontSize = this.props.text?.valueSize ?? calculateFontSize(text, valueWidth, dimension, 1, gaugeWidth * 1.7); const thresholdLabelFontSize = fontSize / 2.5; let min = field.min!; @@ -180,7 +182,7 @@ export class Gauge extends PureComponent { } renderVisualization = () => { - const { width, value, height, onClick } = this.props; + const { width, value, height, onClick, text } = this.props; const autoProps = calculateGaugeAutoProps(width, height, value.title); return ( @@ -194,7 +196,7 @@ export class Gauge extends PureComponent {
{ - const data = new MutableDataFrame(); - - data.addField({ - type: FieldType.time, - name: 'Time', - values: new ArrayVector([1602630000000, 1602633600000, 1602637200000]), - config: {}, - }); - - data.addField({ - type: FieldType.number, - name: 'Value', - values: new ArrayVector([10, 20, 5]), - config: { - custom: { - mode: GraphMode.Line, - }, - } as FieldConfig, - }); - - const timeRange = { - from: dateTime(1602673200000), - to: dateTime(1602680400000), - raw: { from: '1602673200000', to: '1602680400000' }, - }; - return { data, timeRange }; -}; - -// const defaultLegendOptions: LegendOptions = { -// displayMode: LegendDisplayMode.List, -// placement: 'bottom', -// }; - -describe('GraphNG', () => { - // describe('data update', () => { - // it('does not re-initialise uPlot when there are no field config changes', () => { - // const { data, timeRange } = mockData(); - // const onDataUpdateSpy = jest.fn(); - // const onPlotInitSpy = jest.fn(); - // - // const { rerender } = render( - // - // ); - // - // data.fields[1].values.set(0, 1); - // - // rerender( - // - // ); - // - // expect(onPlotInitSpy).toBeCalledTimes(1); - // expect(onDataUpdateSpy).toHaveBeenLastCalledWith([ - // [1602630000, 1602633600, 1602637200], - // [1, 20, 5], - // ]); - // }); - // }); - - describe('config update', () => { - it('should skip plot intialization for width and height equal 0', () => { - const { data, timeRange } = mockData(); - - const { queryAllByTestId } = render( - - ); - - expect(queryAllByTestId('uplot-main-div')).toHaveLength(1); - }); - - // it('reinitializes plot when number of series change', () => { - // const { data, timeRange } = mockData(); - // const onPlotInitSpy = jest.fn(); - // - // const { rerender } = render( - // - // ); - // - // data.addField({ - // name: 'Value1', - // type: FieldType.number, - // values: new ArrayVector([1, 2, 3]), - // config: { - // custom: { - // line: { show: true }, - // }, - // } as FieldConfig, - // }); - // - // rerender( - // - // ); - // - // expect(onPlotInitSpy).toBeCalledTimes(2); - // }); - // - // it('reinitializes plot when series field config changes', () => { - // const { data, timeRange } = mockData(); - // const onPlotInitSpy = jest.fn(); - // - // const { rerender } = render( - // - // ); - // expect(onPlotInitSpy).toBeCalledTimes(1); - // - // data.fields[1].config.custom.line.width = 5; - // - // rerender( - // - // ); - // - // expect(onPlotInitSpy).toBeCalledTimes(2); - // }); - }); -}); diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 4d4268d688f..5320f371f19 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import { compareDataFrameStructures, DataFrame, @@ -8,11 +8,12 @@ import { formattedValueToString, getFieldColorModeForField, getFieldDisplayName, + TimeRange, } from '@grafana/data'; import { alignDataFrames } from './utils'; import { UPlotChart } from '../uPlot/Plot'; import { PlotProps } from '../uPlot/types'; -import { AxisPlacement, GraphFieldConfig, GraphMode, PointMode } from '../uPlot/config'; +import { AxisPlacement, GraphFieldConfig, DrawStyle, PointVisibility } from '../uPlot/config'; import { useTheme } from '../../themes'; import { VizLayout } from '../VizLayout/VizLayout'; import { LegendDisplayMode, LegendItem, LegendOptions } from '../Legend/Legend'; @@ -34,8 +35,8 @@ export interface GraphNGProps extends Omit { } const defaultConfig: GraphFieldConfig = { - mode: GraphMode.Line, - points: PointMode.Auto, + drawStyle: DrawStyle.Line, + showPoints: PointVisibility.Auto, axisPlacement: AxisPlacement.Auto, }; @@ -51,34 +52,43 @@ export const GraphNG: React.FC = ({ ...plotProps }) => { const alignedFrameWithGapTest = useMemo(() => alignDataFrames(data, fields), [data, fields]); - - if (alignedFrameWithGapTest == null) { - return ( -
-

No data found in response

-
- ); - } - const theme = useTheme(); const legendItemsRef = useRef([]); const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden); - const alignedFrame = alignedFrameWithGapTest.frame; - const compareFrames = useCallback( - (a: DataFrame, b: DataFrame) => compareDataFrameStructures(a, b, ['min', 'max']), - [] - ); + const alignedFrame = alignedFrameWithGapTest?.frame; + + const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => { + if (a && b) { + return compareDataFrameStructures(a, b); + } + return false; + }, []); + + // reference change will not triger re-render + const currentTimeRange = useRef(timeRange); + useLayoutEffect(() => { + currentTimeRange.current = timeRange; + }, [timeRange]); + const configRev = useRevision(alignedFrame, compareFrames); const configBuilder = useMemo(() => { const builder = new UPlotConfigBuilder(); + if (!alignedFrame) { + return builder; + } + // X is the first field in the alligned frame const xField = alignedFrame.fields[0]; if (xField.type === FieldType.time) { builder.addScale({ scaleKey: 'x', isTime: true, + range: () => { + const r = currentTimeRange.current!; + return [r.from.valueOf(), r.to.valueOf()]; + }, }); builder.addAxis({ scaleKey: 'x', @@ -105,21 +115,25 @@ export const GraphNG: React.FC = ({ for (let i = 0; i < alignedFrame.fields.length; i++) { const field = alignedFrame.fields[i]; const config = field.config as FieldConfig; - const customConfig = config.custom || defaultConfig; + const customConfig: GraphFieldConfig = { + ...defaultConfig, + ...config.custom, + }; if (field === xField || field.type !== FieldType.number) { continue; } const fmt = field.display ?? defaultFormatter; - const scale = config.unit || '__fixed'; - const isNewScale = !builder.hasScale(scale); + const scaleKey = config.unit || '__fixed'; - if (isNewScale && customConfig.axisPlacement !== AxisPlacement.Hidden) { - builder.addScale({ scaleKey: scale, min: field.config.min, max: field.config.max }); + if (customConfig.axisPlacement !== AxisPlacement.Hidden) { + // The builder will manage unique scaleKeys and combine where appropriate + builder.addScale({ scaleKey, min: field.config.min, max: field.config.max }); builder.addAxis({ - scaleKey: scale, + scaleKey, label: customConfig.axisLabel, + size: customConfig.axisWidth, placement: customConfig.axisPlacement ?? AxisPlacement.Auto, formatValue: v => formattedValueToString(fmt(v)), theme, @@ -131,23 +145,24 @@ export const GraphNG: React.FC = ({ const colorMode = getFieldColorModeForField(field); const seriesColor = colorMode.getCalculator(field, theme)(0, 0); - const pointsMode = customConfig.mode === GraphMode.Points ? PointMode.Always : customConfig.points; + const showPoints = customConfig.drawStyle === DrawStyle.Points ? PointVisibility.Always : customConfig.showPoints; builder.addSeries({ - scaleKey: scale, - line: (customConfig.mode ?? GraphMode.Line) === GraphMode.Line, + scaleKey, + drawStyle: customConfig.drawStyle!, lineColor: seriesColor, lineWidth: customConfig.lineWidth, - points: pointsMode, - pointSize: customConfig.pointRadius, + lineInterpolation: customConfig.lineInterpolation, + showPoints, + pointSize: customConfig.pointSize, pointColor: seriesColor, - fill: customConfig.fillAlpha !== undefined, - fillOpacity: customConfig.fillAlpha, + fillOpacity: customConfig.fillOpacity, fillColor: seriesColor, + spanNulls: customConfig.spanNulls || false, }); if (hasLegend.current) { - const axisPlacement = builder.getAxisPlacement(scale); + const axisPlacement = builder.getAxisPlacement(scaleKey); legendItems.push({ color: seriesColor, @@ -161,7 +176,15 @@ export const GraphNG: React.FC = ({ legendItemsRef.current = legendItems; return builder; - }, [configRev]); + }, [configRev, timeZone]); + + if (alignedFrameWithGapTest == null) { + return ( +
+

No data found in response

+
+ ); + } let legendElement: React.ReactElement | undefined; diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/components/GraphNG/utils.ts index b865a33f1e6..2d6376708f6 100755 --- a/packages/grafana-ui/src/components/GraphNG/utils.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.ts @@ -43,6 +43,7 @@ export function mapDimesions(match: XYFieldMatchers, frame: DataFrame, frames?: export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): AlignedFrameWithGapTest | null { const valuesFromFrames: AlignedData[] = []; const sourceFields: Field[] = []; + const skipGaps: boolean[][] = []; // Default to timeseries config if (!fields) { @@ -54,6 +55,7 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): for (const frame of frames) { const dims = mapDimesions(fields, frame, frames); + if (!(dims.x.length && dims.y.length)) { continue; // both x and y matched something! } @@ -62,9 +64,12 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): throw new Error('Only a single x field is supported'); } + let skipGapsFrame: boolean[] = []; + // Add the first X axis if (!sourceFields.length) { sourceFields.push(dims.x[0]); + skipGapsFrame.push(true); } const alignedData: AlignedData = [ @@ -74,10 +79,15 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): // Add the Y values for (const field of dims.y) { let values = field.values.toArray(); + let spanNulls = field.config.custom.spanNulls || false; + if (field.config.nullValueMode === NullValueMode.AsZero) { values = values.map(v => (v === null ? 0 : v)); + spanNulls = true; } + alignedData.push(values); + skipGapsFrame.push(spanNulls); // This will cache an appropriate field name in the field state getFieldDisplayName(field, frame, frames); @@ -85,6 +95,7 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): } valuesFromFrames.push(alignedData); + skipGaps.push(skipGapsFrame); } if (valuesFromFrames.length === 0) { @@ -92,7 +103,7 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): } // do the actual alignment (outerJoin on the first arrays) - const { data: alignedData, isGap } = outerJoinValues(valuesFromFrames); + let { data: alignedData, isGap } = outerJoinValues(valuesFromFrames, skipGaps); if (alignedData!.length !== sourceFields.length) { throw new Error('outerJoinValues lost a field?'); @@ -111,18 +122,20 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): }; } -export function outerJoinValues(tables: AlignedData[]): AlignedDataWithGapTest { +// skipGaps is a tables-matched bool array indicating which series can skip storing indices of original nulls +export function outerJoinValues(tables: AlignedData[], skipGaps?: boolean[][]): AlignedDataWithGapTest { if (tables.length === 1) { return { data: tables[0], - isGap: () => true, + isGap: skipGaps ? (u: uPlot, seriesIdx: number, dataIdx: number) => !skipGaps[0][seriesIdx] : () => true, }; } let xVals: Set = new Set(); let xNulls: Array> = [new Set()]; - for (const t of tables) { + for (let ti = 0; ti < tables.length; ti++) { + let t = tables[ti]; let xs = t[0]; let len = xs.length; let nulls: Set = new Set(); @@ -132,11 +145,13 @@ export function outerJoinValues(tables: AlignedData[]): AlignedDataWithGapTest { } for (let j = 1; j < t.length; j++) { - let ys = t[j]; + if (skipGaps == null || !skipGaps[ti][j]) { + let ys = t[j]; - for (let i = 0; i < len; i++) { - if (ys[i] == null) { - nulls.add(xs[i]); + for (let i = 0; i < len; i++) { + if (ys[i] == null) { + nulls.add(xs[i]); + } } } } diff --git a/packages/grafana-ui/src/components/Icon/assets/Import.tsx b/packages/grafana-ui/src/components/Icon/assets/Import.tsx index 4659a307c0a..2eb978da784 100644 --- a/packages/grafana-ui/src/components/Icon/assets/Import.tsx +++ b/packages/grafana-ui/src/components/Icon/assets/Import.tsx @@ -11,7 +11,7 @@ export const Import: FunctionComponent = ({ size, ...rest }) => { height={size} {...rest} > - + ; }) ); +FeatureInfoBox.displayName = 'FeatureInfoBox'; const getFeatureInfoBoxStyles = stylesFactory((theme: GrafanaTheme) => { return { diff --git a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx index e635a459127..1646f9fde51 100644 --- a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx +++ b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx @@ -55,6 +55,7 @@ export const InfoBox = React.memo( } ) ); +InfoBox.displayName = 'InfoBox'; const getInfoBoxStyles = stylesFactory((theme: GrafanaTheme, severity: AlertVariant) => ({ wrapper: css` diff --git a/packages/grafana-ui/src/components/Layout/Layout.tsx b/packages/grafana-ui/src/components/Layout/Layout.tsx index ec2d6b36ff7..daebb434733 100644 --- a/packages/grafana-ui/src/components/Layout/Layout.tsx +++ b/packages/grafana-ui/src/components/Layout/Layout.tsx @@ -117,9 +117,11 @@ const getStyles = stylesFactory( ? 0 : `-${finalSpacing}`; + const label = orientation === Orientation.Vertical ? 'vertical-group' : 'horizontal-group'; + return { layout: css` - label: HorizontalGroup; + label: ${label}; display: flex; flex-direction: ${orientation === Orientation.Vertical ? 'column' : 'row'}; flex-wrap: ${wrap ? 'wrap' : 'nowrap'}; diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldNameByRegexMatcherEditor.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldNameByRegexMatcherEditor.tsx index ecd9f111c28..5192c21ba71 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldNameByRegexMatcherEditor.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldNameByRegexMatcherEditor.tsx @@ -15,6 +15,7 @@ export const FieldNameByRegexMatcherEditor = memo>(props return ; }); +FieldNameByRegexMatcherEditor.displayName = 'FieldNameByRegexMatcherEditor'; export const fieldNameByRegexMatcherItem: FieldMatcherUIRegistryItem = { id: FieldMatcherID.byRegexp, diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldNameMatcherEditor.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldNameMatcherEditor.tsx index 1131777d1ad..6fb2d33463f 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldNameMatcherEditor.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldNameMatcherEditor.tsx @@ -21,6 +21,7 @@ export const FieldNameMatcherEditor = memo>(props => { const selectedOption = selectOptions.find(v => v.value === options); return ; }); +FieldNameMatcherEditor.displayName = 'FieldNameMatcherEditor'; export const fieldNameMatcherItem: FieldMatcherUIRegistryItem = { id: FieldMatcherID.byName, diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldTypeMatcherEditor.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldTypeMatcherEditor.tsx index cb2fc2798f8..9e85ad2beaa 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldTypeMatcherEditor.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldTypeMatcherEditor.tsx @@ -18,6 +18,7 @@ export const FieldTypeMatcherEditor = memo>(props => { const selectedOption = selectOptions.find(v => v.value === options); return ; }); +FieldTypeMatcherEditor.displayName = 'FieldTypeMatcherEditor'; const allTypes: Array> = [ { value: FieldType.number, label: 'Numeric' }, diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx new file mode 100644 index 00000000000..01d9e3658ea --- /dev/null +++ b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx @@ -0,0 +1,65 @@ +import React, { memo, useMemo, useCallback } from 'react'; +import { MatcherUIProps, FieldMatcherUIRegistryItem } from './types'; +import { FieldMatcherID, fieldMatchers, SelectableValue, DataFrame } from '@grafana/data'; +import { Select } from '../Select/Select'; + +/** + * UI to configure "fields by frame refId"-matcher. + * @public + */ +export const FieldsByFrameRefIdMatcher = memo>(props => { + const { data, options, onChange: onChangeFromProps } = props; + const referenceIDs = useFrameRefIds(data); + const selectOptions = useSelectOptions(referenceIDs); + + const onChange = useCallback( + (selection: SelectableValue) => { + if (!selection.value || !referenceIDs.has(selection.value)) { + return; + } + return onChangeFromProps(selection.value); + }, + [referenceIDs, onChangeFromProps] + ); + + const selectedOption = selectOptions.find(v => v.value === options); + return ; +}); + +FieldsByFrameRefIdMatcher.displayName = 'FieldsByFrameRefIdMatcher'; + +/** + * Registry item for UI to configure "fields by frame refId"-matcher. + * @public + */ +export const fieldsByFrameRefIdItem: FieldMatcherUIRegistryItem = { + id: FieldMatcherID.byFrameRefID, + component: FieldsByFrameRefIdMatcher, + matcher: fieldMatchers.get(FieldMatcherID.byFrameRefID), + name: 'Fields returned by query', + description: 'Set properties for fields from a specific query', + optionsToLabel: options => options, +}; + +const useFrameRefIds = (data: DataFrame[]): Set => { + return useMemo(() => { + const refIds: Set = new Set(); + + for (const frame of data) { + if (frame.refId) { + refIds.add(frame.refId); + } + } + + return refIds; + }, [data]); +}; + +const useSelectOptions = (displayNames: Set): Array> => { + return useMemo(() => { + return Array.from(displayNames).map(n => ({ + value: n, + label: n, + })); + }, [displayNames]); +}; diff --git a/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts b/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts index b50124ac40e..525dde086d1 100644 --- a/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts +++ b/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts @@ -3,7 +3,8 @@ import { FieldMatcherUIRegistryItem } from './types'; import { fieldNameMatcherItem } from './FieldNameMatcherEditor'; import { fieldNameByRegexMatcherItem } from './FieldNameByRegexMatcherEditor'; import { fieldTypeMatcherItem } from './FieldTypeMatcherEditor'; +import { fieldsByFrameRefIdItem } from './FieldsByFrameRefIdMatcher'; export const fieldMatchersUI = new Registry>(() => { - return [fieldNameMatcherItem, fieldNameByRegexMatcherItem, fieldTypeMatcherItem]; + return [fieldNameMatcherItem, fieldNameByRegexMatcherItem, fieldTypeMatcherItem, fieldsByFrameRefIdItem]; }); diff --git a/packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx b/packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx index 5a59163a07d..04d01714d12 100644 --- a/packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx +++ b/packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx @@ -36,7 +36,9 @@ export const FieldColorEditor: React.FC , + component() { + return ; + }, }; }); diff --git a/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx b/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx index e17cd4613ff..fe3f963412e 100644 --- a/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx +++ b/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx @@ -14,7 +14,6 @@ export const useExpandableLabel = ( const Label: React.FC = ({ Component, onClick }) => (
{ setExpanded(true); diff --git a/packages/grafana-ui/src/components/Select/ButtonSelect.tsx b/packages/grafana-ui/src/components/Select/ButtonSelect.tsx index 95e95de2070..a20e0a8c6f8 100644 --- a/packages/grafana-ui/src/components/Select/ButtonSelect.tsx +++ b/packages/grafana-ui/src/components/Select/ButtonSelect.tsx @@ -54,6 +54,7 @@ const SelectButton = React.forwardRef( ); } ); +SelectButton.displayName = 'SelectButton'; export function ButtonSelect({ placeholder, @@ -75,6 +76,7 @@ export function ButtonSelect({ return ( >(({ onBlur, onClick, value, isOpen }, ref) => { return ( diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index cae9329441a..1170dfd9d89 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -237,26 +237,28 @@ export function SelectBase({ MenuList: SelectMenu, Group: SelectOptionGroup, ValueContainer, - Placeholder: (props: any) => ( -
- {props.children} -
- ), - IndicatorsContainer: (props: any) => { + Placeholder(props: any) { + return ( +
+ {props.children} +
+ ); + }, + IndicatorsContainer(props: any) { const { selectProps } = props; const { value, showAllSelectedWhenOpen, maxVisibleValues, menuIsOpen } = selectProps; @@ -278,10 +280,12 @@ export function SelectBase({ return ; }, - IndicatorSeparator: () => <>, + IndicatorSeparator() { + return <>; + }, Control: CustomControl, Option: SelectMenuOptions, - ClearIndicator: (props: any) => { + ClearIndicator(props: any) { const { clearValue } = props; return ( ({ /> ); }, - LoadingIndicator: (props: any) => { + LoadingIndicator(props: any) { return ; }, - LoadingMessage: (props: any) => { + LoadingMessage(props: any) { return
{loadingMessage}
; }, - NoOptionsMessage: (props: any) => { + NoOptionsMessage(props: any) { return (
{noOptionsMessage}
); }, - DropdownIndicator: (props: any) => , + DropdownIndicator(props: any) { + return ; + }, SingleValue: SingleValue, MultiValueContainer: MultiValueContainer, MultiValueRemove: MultiValueRemove, diff --git a/packages/grafana-ui/src/components/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Select/SelectMenu.tsx index be5d70c05ec..a0117b55824 100644 --- a/packages/grafana-ui/src/components/Select/SelectMenu.tsx +++ b/packages/grafana-ui/src/components/Select/SelectMenu.tsx @@ -65,3 +65,5 @@ export const SelectMenuOptions = React.forwardRef { return ( -
+
{data.imgUrl ? ( ) : ( diff --git a/packages/grafana-ui/src/components/Select/utils.ts b/packages/grafana-ui/src/components/Select/utils.ts index 39bb33baf71..daa255722ec 100644 --- a/packages/grafana-ui/src/components/Select/utils.ts +++ b/packages/grafana-ui/src/components/Select/utils.ts @@ -1,10 +1,13 @@ import { SelectableValue } from '@grafana/data'; -import { SelectOptions } from './types'; +import { SelectableOptGroup } from './types'; /** * Normalize the value format to SelectableValue[] | []. Only used for single select */ -export const cleanValue = (value: any, options: SelectOptions): SelectableValue[] | [] => { +export const cleanValue = ( + value: any, + options: Array +): SelectableValue[] | [] => { if (Array.isArray(value)) { return value.filter(Boolean); } @@ -23,7 +26,10 @@ export const cleanValue = (value: any, options: SelectOptions): SelectableValue[ /** * Find the label for a string|number value inside array of options or optgroups */ -export const findSelectedValue = (value: string | number, options: SelectOptions): SelectableValue | null => { +export const findSelectedValue = ( + value: string | number, + options: Array +): SelectableValue | null => { for (const option of options) { if ('options' in option) { let found = findSelectedValue(value, option.options); diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts index 2b6cf375882..70afa3d81a1 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts @@ -16,11 +16,13 @@ import { ThresholdsConfig, validateFieldConfig, FieldColorModeId, + TextDisplayOptions, } from '@grafana/data'; export interface SingleStatBaseOptions { reduceOptions: ReduceDataOptions; orientation: VizOrientation; + text?: TextDisplayOptions; } const optionsToKeep = ['reduceOptions', 'orientation']; diff --git a/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx b/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx index 99ba69f3759..e4fd2f2491f 100644 --- a/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx +++ b/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx @@ -2,6 +2,7 @@ import React, { FC } from 'react'; import { ThresholdsConfig, ThresholdsMode, VizOrientation } from '@grafana/data'; import { BarGauge, BarGaugeDisplayMode } from '../BarGauge/BarGauge'; import { TableCellProps, TableCellDisplayMode } from './types'; +import { getFieldConfigWithMinMax } from '@grafana/data/src/field/scale'; const defaultScale: ThresholdsConfig = { mode: ThresholdsMode.Absolute, @@ -20,7 +21,7 @@ const defaultScale: ThresholdsConfig = { export const BarGaugeCell: FC = props => { const { field, column, tableStyles, cell, cellProps } = props; - let { config } = field; + let config = getFieldConfigWithMinMax(field, false); if (!config.thresholds) { config = { ...config, diff --git a/packages/grafana-ui/src/components/Tabs/TabsBar.tsx b/packages/grafana-ui/src/components/Tabs/TabsBar.tsx index 1820e2078cb..aafc1aa1204 100644 --- a/packages/grafana-ui/src/components/Tabs/TabsBar.tsx +++ b/packages/grafana-ui/src/components/Tabs/TabsBar.tsx @@ -40,3 +40,5 @@ export const TabsBar = React.forwardRef(({ children, clas
); }); + +TabsBar.displayName = 'TabsBar'; diff --git a/packages/grafana-ui/src/components/Tags/Tag.tsx b/packages/grafana-ui/src/components/Tags/Tag.tsx index 9d18a88565a..106683fbe0e 100644 --- a/packages/grafana-ui/src/components/Tags/Tag.tsx +++ b/packages/grafana-ui/src/components/Tags/Tag.tsx @@ -40,6 +40,8 @@ export const Tag = forwardRef(({ name, onClick, className, c ); }); +Tag.displayName = 'Tag'; + const getTagStyles = (theme: GrafanaTheme, name: string, colorIndex?: number) => { let colors; if (colorIndex === undefined) { diff --git a/packages/grafana-ui/src/components/Tags/TagList.tsx b/packages/grafana-ui/src/components/Tags/TagList.tsx index cb3ca0b486d..c4da2fd6424 100644 --- a/packages/grafana-ui/src/components/Tags/TagList.tsx +++ b/packages/grafana-ui/src/components/Tags/TagList.tsx @@ -21,6 +21,8 @@ export const TagList: FC = memo(({ tags, onClick, className }) => { ); }); +TagList.displayName = 'TagList'; + const getStyles = () => { return { wrapper: css` diff --git a/packages/grafana-ui/src/components/ThemeColors/ThemeColors.tsx b/packages/grafana-ui/src/components/ThemeColors/ThemeColors.tsx index 19ccfe239a6..6f8f332c7f9 100644 --- a/packages/grafana-ui/src/components/ThemeColors/ThemeColors.tsx +++ b/packages/grafana-ui/src/components/ThemeColors/ThemeColors.tsx @@ -13,7 +13,7 @@ interface DemoBoxProps { const DemoBox: FC = ({ bg, border, children }) => { const style = cx( css` - padding: 16px 32px; + padding: 32px 32px 16px 32px; background: ${bg}; width: 100%; `, @@ -24,18 +24,7 @@ const DemoBox: FC = ({ bg, border, children }) => { : null ); - return ( -
-
- {name} -
- {children} -
- ); + return
{children}
; }; const DemoText: FC<{ color?: string; bold?: boolean; size?: number }> = ({ color, bold, size, children }) => { diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker.tsx b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker.tsx index becbd5f2e09..fb0e2991fd7 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker.tsx @@ -210,6 +210,8 @@ export const TimePickerButtonLabel = memo(({ hideText, value, timeZo ); }); +TimePickerButtonLabel.displayName = 'TimePickerButtonLabel'; + const formattedRange = (value: TimeRange, timeZone?: TimeZone) => { const adjustedTimeRange = { to: dateMath.isMathString(value.raw.to) ? value.raw.to : value.to, diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerCalendar.tsx b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerCalendar.tsx index 0462d6d1c72..796636a6064 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerCalendar.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerCalendar.tsx @@ -231,6 +231,8 @@ export const TimePickerCalendar = memo(props => { ); }); +TimePickerCalendar.displayName = 'TimePickerCalendar'; + const Header = memo(({ onClose }) => { const theme = useTheme(); const styles = getHeaderStyles(theme); @@ -243,6 +245,8 @@ const Header = memo(({ onClose }) => { ); }); +Header.displayName = 'Header'; + const Body = memo(({ onChange, from, to, timeZone }) => { const value = inputToValue(from, to); const theme = useTheme(); @@ -265,6 +269,8 @@ const Body = memo(({ onChange, from, to, timeZone }) => { ); }); +Body.displayName = 'Body'; + const Footer = memo(({ onClose, onApply }) => { const theme = useTheme(); const styles = getFooterStyles(theme); @@ -281,6 +287,8 @@ const Footer = memo(({ onClose, onApply }) => { ); }); +Footer.displayName = 'Footer'; + export function inputToValue(from: DateTime, to: DateTime, invalidDateDefault: Date = new Date()): Date[] { const fromAsDate = from.toDate(); const toAsDate = to.toDate(); diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerContent.tsx b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerContent.tsx index 4d2d7404584..665c6472ae6 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerContent.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerContent.tsx @@ -283,8 +283,8 @@ const EmptyRecentList = memo(() => {
- It looks like you haven't used this timer picker before. As soon as you enter some time intervals, recently - used intervals will appear here. + It looks like you haven't used this timer picker before. As soon as you enter some time intervals, + recently used intervals will appear here.
@@ -307,3 +307,5 @@ function mapToHistoryOptions(ranges?: TimeRange[], timeZone?: TimeZone): TimeOpt } return ranges.slice(ranges.length - 4).map(range => mapRangeToTimeOption(range, timeZone)); } + +EmptyRecentList.displayName = 'EmptyRecentList'; diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerTitle.tsx b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerTitle.tsx index 2e37a19f2bd..dd89babda94 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerTitle.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerTitle.tsx @@ -19,3 +19,5 @@ export const TimePickerTitle = memo>(({ children }) => { return {children}; }); + +TimePickerTitle.displayName = 'TimePickerTitle'; diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeOption.tsx b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeOption.tsx index 2523e7baff9..726d6dca7ad 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeOption.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeOption.tsx @@ -53,3 +53,5 @@ export const TimeRangeOption = memo(({ value, onSelect, selected = false
); }); + +TimeRangeOption.displayName = 'TimeRangeOption'; diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/__snapshots__/TimePickerContent.test.tsx.snap b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/__snapshots__/TimePickerContent.test.tsx.snap index db1ee65bf98..7201deedbfd 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/__snapshots__/TimePickerContent.test.tsx.snap +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/__snapshots__/TimePickerContent.test.tsx.snap @@ -57,7 +57,7 @@ exports[`TimePickerContent renders correctly in full screen 1`] = ` } visible={false} /> - -
- @@ -158,7 +158,7 @@ exports[`TimePickerContent renders correctly in narrow screen 1`] = ` } visible={true} /> - - - -
- diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index 9679f43e35f..3dd1753b1d4 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -53,3 +53,5 @@ export const Tooltip: FC = React.memo(({ children, theme, ...contr ); }); + +Tooltip.displayName = 'Tooltip'; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx new file mode 100644 index 00000000000..ea0a819853f --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { UPlotChart } from './Plot'; +import { act, render } from '@testing-library/react'; +import { ArrayVector, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data'; +import { GraphFieldConfig, DrawStyle } from '../uPlot/config'; +import uPlot from 'uplot'; +import createMockRaf from 'mock-raf'; +import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; + +const mockRaf = createMockRaf(); +const setDataMock = jest.fn(); +const setSizeMock = jest.fn(); +const initializeMock = jest.fn(); +const destroyMock = jest.fn(); + +jest.mock('uplot', () => { + return jest.fn().mockImplementation(() => { + return { + setData: setDataMock, + setSize: setSizeMock, + initialize: initializeMock, + destroy: destroyMock, + }; + }); +}); + +const mockData = () => { + const data = new MutableDataFrame(); + + data.addField({ + type: FieldType.time, + name: 'Time', + values: new ArrayVector([1602630000000, 1602633600000, 1602637200000]), + config: {}, + }); + + data.addField({ + type: FieldType.number, + name: 'Value', + values: new ArrayVector([10, 20, 5]), + config: { + custom: { + drawStyle: DrawStyle.Line, + }, + } as FieldConfig, + }); + + const timeRange = { + from: dateTime(1602673200000), + to: dateTime(1602680400000), + raw: { from: '1602673200000', to: '1602680400000' }, + }; + + return { data, timeRange, config: new UPlotConfigBuilder() }; +}; + +describe('UPlotChart', () => { + beforeEach(() => { + // @ts-ignore + uPlot.mockClear(); + setDataMock.mockClear(); + setSizeMock.mockClear(); + initializeMock.mockClear(); + destroyMock.mockClear(); + + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(mockRaf.raf); + }); + + it('destroys uPlot instance when component unmounts', () => { + const { data, timeRange, config } = mockData(); + const uPlotData = { frame: data, isGap: () => false }; + + const { unmount } = render( + + ); + + // we wait 1 frame for plugins initialisation logic to finish + act(() => { + mockRaf.step({ count: 1 }); + }); + + expect(uPlot).toBeCalledTimes(1); + unmount(); + expect(destroyMock).toBeCalledTimes(1); + }); + + describe('data update', () => { + it('skips uPlot reinitialization when there are no field config changes', () => { + const { data, timeRange, config } = mockData(); + const uPlotData = { frame: data, isGap: () => false }; + + const { rerender } = render( + + ); + + // we wait 1 frame for plugins initialisation logic to finish + act(() => { + mockRaf.step({ count: 1 }); + }); + + expect(uPlot).toBeCalledTimes(1); + + data.fields[1].values.set(0, 1); + uPlotData.frame = data; + + rerender( + + ); + + expect(setDataMock).toBeCalledTimes(1); + }); + }); + + describe('config update', () => { + it('skips uPlot intialization for width and height equal 0', async () => { + const { data, timeRange, config } = mockData(); + const uPlotData = { frame: data, isGap: () => false }; + + const { queryAllByTestId } = render( + + ); + + expect(queryAllByTestId('uplot-main-div')).toHaveLength(1); + expect(uPlot).not.toBeCalled(); + }); + + it('reinitializes uPlot when config changes', () => { + const { data, timeRange, config } = mockData(); + const uPlotData = { frame: data, isGap: () => false }; + + const { rerender } = render( + + ); + + // we wait 1 frame for plugins initialisation logic to finish + act(() => { + mockRaf.step({ count: 1 }); + }); + + expect(uPlot).toBeCalledTimes(1); + + rerender( + + ); + + expect(destroyMock).toBeCalledTimes(1); + expect(uPlot).toBeCalledTimes(2); + }); + + it('skips uPlot reinitialization when only dimensions change', () => { + const { data, timeRange, config } = mockData(); + const uPlotData = { frame: data, isGap: () => false }; + + const { rerender } = render( + + ); + + // we wait 1 frame for plugins initialisation logic to finish + act(() => { + mockRaf.step({ count: 1 }); + }); + + rerender( + + ); + + expect(destroyMock).toBeCalledTimes(0); + expect(uPlot).toBeCalledTimes(1); + expect(setSizeMock).toBeCalledTimes(1); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx index 40749114d02..cf924042db7 100755 --- a/packages/grafana-ui/src/components/uPlot/Plot.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -1,127 +1,108 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import uPlot, { AlignedData, AlignedDataWithGapTest } from 'uplot'; +import uPlot, { AlignedData, AlignedDataWithGapTest, Options } from 'uplot'; import { buildPlotContext, PlotContext } from './context'; import { pluginLog } from './utils'; import { usePlotConfig } from './hooks'; -import { PlotProps } from './types'; -import { usePrevious } from 'react-use'; -import { DataFrame, FieldType } from '@grafana/data'; -import isNumber from 'lodash/isNumber'; +import { AlignedFrameWithGapTest, PlotProps } from './types'; +import { DataFrame } from '@grafana/data'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; +import usePrevious from 'react-use/lib/usePrevious'; // uPlot abstraction responsible for plot initialisation, setup and refresh // Receives a data frame that is x-axis aligned, as of https://github.com/leeoniya/uPlot/tree/master/docs#data-format // Exposes contexts for plugins registration and uPlot instance access export const UPlotChart: React.FC = props => { const canvasRef = useRef(null); - const [plotInstance, setPlotInstance] = useState(); - const plotData = useRef(); - const previousConfig = usePrevious(props.config); + const plotInstance = useRef(); + const [isPlotReady, setIsPlotReady] = useState(false); + const prevProps = usePrevious(props); + const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig( + props.width, + props.height, + props.timeZone, + props.config + ); + const getPlotInstance = useCallback(() => { + return plotInstance.current; + }, []); - // uPlot config API - const { currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.timeZone, props.config); - - const initializePlot = useCallback(() => { - if (!currentConfig || !plotData) { + // Effect responsible for uPlot updates/initialization logic. It's performed whenever component's props have changed + useLayoutEffect(() => { + // 0. Exit early if the component is not ready to initialize uPlot + if (!currentConfig.current || !canvasRef.current || props.width === 0 || props.height === 0) { return; } - if (!canvasRef.current) { - throw new Error('Missing Canvas component as a child of the plot.'); + + // 1. When config is ready and there is no uPlot instance, create new uPlot and return + if (isConfigReady && !plotInstance.current) { + plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current); + setIsPlotReady(true); + return; } - pluginLog('UPlotChart: init uPlot', false, 'initialized with', plotData.current, currentConfig); - const instance = new uPlot(currentConfig, plotData.current, canvasRef.current); - - setPlotInstance(instance); - }, [setPlotInstance, currentConfig]); - - const getPlotInstance = useCallback(() => { - if (!plotInstance) { - throw new Error("Plot hasn't initialised yet"); - } - - return plotInstance; - }, [plotInstance]); - - useLayoutEffect(() => { - plotData.current = { - data: props.data.frame.fields.map(f => f.values.toArray()) as AlignedData, - isGap: props.data.isGap, - }; - - if (plotInstance && previousConfig === props.config) { - updateData(props.data.frame, props.config, plotInstance, plotData.current.data); - } - }, [props.data, props.config]); - - useLayoutEffect(() => { - initializePlot(); - }, [currentConfig]); - - useEffect(() => { - const currentInstance = plotInstance; - return () => { - currentInstance?.destroy(); - }; - }, [plotInstance]); - - // When size props changed update plot size synchronously - useLayoutEffect(() => { - if (plotInstance) { - plotInstance.setSize({ - width: props.width, - height: props.height, + // 2. When dimensions have changed, update uPlot size and return + if (currentConfig.current.width !== prevProps?.width || currentConfig.current.height !== prevProps?.height) { + pluginLog('uPlot core', false, 'updating size'); + plotInstance.current!.setSize({ + width: currentConfig.current.width, + height: currentConfig.current?.height, }); + return; } - }, [plotInstance, props.width, props.height]); + + // 3. When config or timezone has changed, re-initialize plot + if (isConfigReady && (props.config !== prevProps.config || props.timeZone !== prevProps.timeZone)) { + if (plotInstance.current) { + pluginLog('uPlot core', false, 'destroying instance'); + plotInstance.current.destroy(); + } + plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current); + return; + } + + // 4. Otherwise, assume only data has changed and update uPlot data + updateData(props.data.frame, props.config, plotInstance.current, prepareData(props.data)); + }, [props, isConfigReady]); + + // When component unmounts, clean the existing uPlot instance + useEffect(() => () => plotInstance.current?.destroy(), []); // Memoize plot context const plotCtx = useMemo(() => { - return buildPlotContext(Boolean(plotInstance), canvasRef, props.data, registerPlugin, getPlotInstance); - }, [plotInstance, canvasRef, props.data, registerPlugin, getPlotInstance]); + return buildPlotContext(isPlotReady, canvasRef, props.data, registerPlugin, getPlotInstance); + }, [plotInstance, canvasRef, props.data, registerPlugin, getPlotInstance, isPlotReady]); return ( -
- {props.children} +
+
+ {props.children} +
); }; -// Callback executed when there was no change in plot config -function updateData(frame: DataFrame, config: UPlotConfigBuilder, plotInstance?: uPlot, data?: AlignedData | null) { +function prepareData(data: AlignedFrameWithGapTest) { + return { + data: data.frame.fields.map(f => f.values.toArray()) as AlignedData, + isGap: data.isGap, + }; +} + +function initializePlot(data: AlignedDataWithGapTest, config: Options, el: HTMLDivElement) { + pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config); + return new uPlot(config, data, el); +} + +function updateData( + frame: DataFrame, + config: UPlotConfigBuilder, + plotInstance?: uPlot, + data?: AlignedDataWithGapTest | null +) { if (!plotInstance || !data) { return; } pluginLog('uPlot core', false, 'updating plot data(throttled log!)', data); - updateScales(frame, config, plotInstance); - // If config hasn't changed just update uPlot's data plotInstance.setData(data); } - -function updateScales(frame: DataFrame, config: UPlotConfigBuilder, plotInstance: uPlot) { - let yRange: [number, number] | undefined = undefined; - - for (let i = 0; i < frame.fields.length; i++) { - if (frame.fields[i].type !== FieldType.number) { - continue; - } - if (isNumber(frame.fields[i].config.min) && isNumber(frame.fields[i].config.max)) { - yRange = [frame.fields[i].config.min!, frame.fields[i].config.max!]; - break; - } - } - - const scalesConfig = config.getConfig().scales; - - if (scalesConfig && yRange) { - for (const scale in scalesConfig) { - if (!scalesConfig.hasOwnProperty(scale)) { - continue; - } - if (scale !== 'x') { - plotInstance.setScale(scale, { min: yRange[0], max: yRange[1] }); - } - } - } -} diff --git a/packages/grafana-ui/src/components/uPlot/config.ts b/packages/grafana-ui/src/components/uPlot/config.ts index 00ee5e2850c..a091d67e221 100644 --- a/packages/grafana-ui/src/components/uPlot/config.ts +++ b/packages/grafana-ui/src/components/uPlot/config.ts @@ -9,59 +9,74 @@ export enum AxisPlacement { Hidden = 'hidden', } -export enum PointMode { +export enum PointVisibility { Auto = 'auto', // will show points when the density is low or line is hidden - Always = 'always', Never = 'never', + Always = 'always', } -export enum GraphMode { +export enum DrawStyle { Line = 'line', // default - Bar = 'bar', // will also have a gap percent + Bars = 'bars', // will also have a gap percent Points = 'points', // Only show points } export enum LineInterpolation { Linear = 'linear', - Staircase = 'staircase', // https://leeoniya.github.io/uPlot/demos/line-stepped.html - Smooth = 'smooth', // https://leeoniya.github.io/uPlot/demos/line-smoothing.html + Smooth = 'smooth', + StepBefore = 'stepBefore', + StepAfter = 'stepAfter', } -export interface GraphFieldConfig { - mode: GraphMode; +export interface LineConfig { + lineColor?: string; + lineWidth?: number; + lineInterpolation?: LineInterpolation; + spanNulls?: boolean; +} - lineMode?: LineInterpolation; - lineWidth?: number; // pixels - fillAlpha?: number; // 0-1 +export interface AreaConfig { + fillColor?: string; + fillOpacity?: number; +} - points?: PointMode; - pointRadius?: number; // pixels - symbol?: string; // eventually dot,star, etc +export interface PointsConfig { + showPoints?: PointVisibility; + pointSize?: number; + pointColor?: string; + pointSymbol?: string; // eventually dot,star, etc +} - // Axis is actually unique based on the unit... not each field! +// Axis is actually unique based on the unit... not each field! +export interface AxisConfig { axisPlacement?: AxisPlacement; axisLabel?: string; axisWidth?: number; // pixels ideally auto? } -export const graphFieldOptions = { - mode: [ - { label: 'Lines', value: GraphMode.Line }, - { label: 'Bars', value: GraphMode.Bar }, - { label: 'Points', value: GraphMode.Points }, - ] as Array>, +export interface GraphFieldConfig extends LineConfig, AreaConfig, PointsConfig, AxisConfig { + drawStyle?: DrawStyle; +} - lineMode: [ +export const graphFieldOptions = { + drawStyle: [ + { label: 'Lines', value: DrawStyle.Line }, + { label: 'Bars', value: DrawStyle.Bars }, + { label: 'Points', value: DrawStyle.Points }, + ] as Array>, + + lineInterpolation: [ { label: 'Linear', value: LineInterpolation.Linear }, - { label: 'Staircase', value: LineInterpolation.Staircase }, { label: 'Smooth', value: LineInterpolation.Smooth }, + { label: 'Step Before', value: LineInterpolation.StepBefore }, + { label: 'Step After', value: LineInterpolation.StepAfter }, ] as Array>, - points: [ - { label: 'Auto', value: PointMode.Auto, description: 'Show points when the density is low' }, - { label: 'Always', value: PointMode.Always }, - { label: 'Never', value: PointMode.Never }, - ] as Array>, + showPoints: [ + { label: 'Auto', value: PointVisibility.Auto, description: 'Show points when the density is low' }, + { label: 'Always', value: PointVisibility.Always }, + { label: 'Never', value: PointVisibility.Never }, + ] as Array>, axisPlacement: [ { label: 'Auto', value: AxisPlacement.Auto, description: 'First field on the left, everything else on the right' }, diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts index 8386284503f..4c33ae7546c 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts @@ -3,13 +3,14 @@ import uPlot, { Axis } from 'uplot'; import { PlotConfigBuilder } from '../types'; import { measureText } from '../../../utils/measureText'; import { AxisPlacement } from '../config'; +import { optMinMax } from './UPlotScaleBuilder'; export interface AxisProps { scaleKey: string; theme: GrafanaTheme; label?: string; show?: boolean; - size?: number; + size?: number | null; placement?: AxisPlacement; grid?: boolean; formatValue?: (v: any) => string; @@ -19,6 +20,16 @@ export interface AxisProps { } export class UPlotAxisBuilder extends PlotConfigBuilder { + merge(props: AxisProps) { + this.props.size = optMinMax('max', this.props.size, props.size); + if (!this.props.label) { + this.props.label = props.label; + } + if (this.props.placement === AxisPlacement.Auto) { + this.props.placement = props.placement; + } + } + getConfig(): Axis { const { scaleKey, @@ -32,6 +43,7 @@ export class UPlotAxisBuilder extends PlotConfigBuilder { timeZone, theme, } = this.props; + const gridColor = theme.isDark ? theme.palette.gray25 : theme.palette.gray90; let config: Axis = { @@ -41,7 +53,7 @@ export class UPlotAxisBuilder extends PlotConfigBuilder { side: getUPlotSideFromAxis(placement), font: `12px 'Roboto'`, labelFont: `12px 'Roboto'`, - size: calculateAxisSize, + size: this.props.size ?? calculateAxisSize, grid: { show: grid, stroke: gridColor, @@ -82,7 +94,7 @@ function calculateSpace(self: uPlot, axisIdx: number, scaleMin: number, scaleMax // For x-axis (bottom) we need bigger spacing between labels if (axis.side === 2) { - return 60; + return 55; } return 30; @@ -106,19 +118,19 @@ function calculateAxisSize(self: uPlot, values: string[], axisIdx: number) { } } - let axisWidth = measureText(maxLength, 12).width + 18; - return axisWidth; + return measureText(maxLength, 12).width + 18; } /** Format time axis ticks */ function formatTime(self: uPlot, splits: number[], axisIdx: number, foundSpace: number, foundIncr: number): string[] { const timeZone = (self.axes[axisIdx] as any).timeZone; const scale = self.scales.x; - const range = (scale?.max ?? 0) - (scale?.min ?? 0) / 1000; + const range = ((scale?.max ?? 0) - (scale?.min ?? 0)) / 1e3; const oneDay = 86400; const oneYear = 31536000; - foundIncr = foundIncr / 1000; + foundIncr /= 1e3; + let format = systemDateFormats.interval.minute; if (foundIncr < 1) { diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index a02d84d6a3f..0b72f6e0eb8 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -3,7 +3,7 @@ import { UPlotConfigBuilder } from './UPlotConfigBuilder'; import { GrafanaTheme } from '@grafana/data'; import { expect } from '../../../../../../public/test/lib/common'; -import { AxisPlacement, PointMode } from '../config'; +import { AxisPlacement, DrawStyle, PointVisibility } from '../config'; describe('UPlotConfigBuilder', () => { describe('scales config', () => { @@ -22,11 +22,11 @@ describe('UPlotConfigBuilder', () => { "axes": Array [], "scales": Object { "scale-x": Object { - "range": undefined, + "range": [Function], "time": true, }, "scale-y": Object { - "range": undefined, + "range": [Function], "time": false, }, }, @@ -63,7 +63,6 @@ describe('UPlotConfigBuilder', () => { formatValue: () => 'test value', grid: false, show: true, - size: 1, theme: { isDark: true, palette: { gray25: '#ffffff' }, colors: { text: 'gray' } } as GrafanaTheme, values: [], }); @@ -124,16 +123,16 @@ describe('UPlotConfigBuilder', () => { it('allows series configuration', () => { const builder = new UPlotConfigBuilder(); builder.addSeries({ + drawStyle: DrawStyle.Line, scaleKey: 'scale-x', - fill: true, fillColor: '#ff0000', fillOpacity: 0.5, - points: PointMode.Auto, + showPoints: PointVisibility.Auto, pointSize: 5, pointColor: '#00ff00', - line: true, lineColor: '#0000ff', lineWidth: 1, + spanNulls: false, }); expect(builder.getConfig()).toMatchInlineSnapshot(` @@ -144,12 +143,14 @@ describe('UPlotConfigBuilder', () => { Object {}, Object { "fill": "rgba(255, 0, 0, 0.5)", + "paths": [Function], "points": Object { "fill": "#00ff00", "size": 5, "stroke": "#00ff00", }, "scale": "scale-x", + "spanGaps": false, "stroke": "#0000ff", "width": 1, }, diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index e12232bfc5f..3896fa97bce 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -8,13 +8,17 @@ export class UPlotConfigBuilder { private series: UPlotSeriesBuilder[] = []; private axes: Record = {}; private scales: UPlotScaleBuilder[] = []; - private registeredScales: string[] = []; hasLeftAxis = false; addAxis(props: AxisProps) { props.placement = props.placement ?? AxisPlacement.Auto; + if (this.axes[props.scaleKey]) { + this.axes[props.scaleKey].merge(props); + return; + } + // Handle auto placement logic if (props.placement === AxisPlacement.Auto) { props.placement = this.hasLeftAxis ? AxisPlacement.Right : AxisPlacement.Left; @@ -36,15 +40,16 @@ export class UPlotConfigBuilder { this.series.push(new UPlotSeriesBuilder(props)); } + /** Add or update the scale with the scale key */ addScale(props: ScaleProps) { - this.registeredScales.push(props.scaleKey); + const current = this.scales.find(v => v.props.scaleKey === props.scaleKey); + if (current) { + current.merge(props); + return; + } this.scales.push(new UPlotScaleBuilder(props)); } - hasScale(scaleKey: string) { - return this.registeredScales.indexOf(scaleKey) > -1; - } - getConfig() { const config: PlotSeriesConfig = { series: [{}] }; config.axes = Object.values(this.axes).map(a => a.getConfig()); diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.test.ts new file mode 100644 index 00000000000..82576389c82 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.test.ts @@ -0,0 +1,21 @@ +import { optMinMax } from './UPlotScaleBuilder'; + +describe('UPlotScaleBuilder', () => { + it('opt min max', () => { + expect(7).toEqual(optMinMax('min', null, 7)); + expect(7).toEqual(optMinMax('min', undefined, 7)); + expect(7).toEqual(optMinMax('min', 20, 7)); + + expect(7).toEqual(optMinMax('min', 7, null)); + expect(7).toEqual(optMinMax('min', 7, undefined)); + expect(7).toEqual(optMinMax('min', 7, 20)); + + expect(7).toEqual(optMinMax('max', null, 7)); + expect(7).toEqual(optMinMax('max', undefined, 7)); + expect(7).toEqual(optMinMax('max', 5, 7)); + + expect(7).toEqual(optMinMax('max', 7, null)); + expect(7).toEqual(optMinMax('max', 7, undefined)); + expect(7).toEqual(optMinMax('max', 7, 5)); + }); +}); diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts index 796f7c432aa..13709f605c3 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts @@ -1,5 +1,4 @@ -import isNumber from 'lodash/isNumber'; -import { Scale } from 'uplot'; +import uPlot, { Scale } from 'uplot'; import { PlotConfigBuilder } from '../types'; export interface ScaleProps { @@ -7,17 +6,44 @@ export interface ScaleProps { isTime?: boolean; min?: number | null; max?: number | null; + range?: () => number[]; // min/max } export class UPlotScaleBuilder extends PlotConfigBuilder { + merge(props: ScaleProps) { + this.props.min = optMinMax('min', this.props.min, props.min); + this.props.max = optMinMax('max', this.props.max, props.max); + } + + // uPlot range function + range = (u: uPlot, dataMin: number, dataMax: number) => { + const { min, max } = this.props; + const [smin, smax] = uPlot.rangeNum(min ?? dataMin, max ?? dataMax, 0.1 as any, true); + return [min ?? smin, max ?? smax]; + }; + getConfig() { - const { isTime, scaleKey, min, max } = this.props; - const range = isNumber(min) && isNumber(max) ? [min, max] : undefined; + const { isTime, scaleKey, range } = this.props; return { [scaleKey]: { - time: !!isTime, - range, + time: isTime, + range: range ?? this.range, }, }; } } + +export function optMinMax(minmax: 'min' | 'max', a?: number | null, b?: number | null): undefined | number | null { + const hasA = !(a === undefined || a === null); + const hasB = !(b === undefined || b === null); + if (hasA) { + if (!hasB) { + return a; + } + if (minmax === 'min') { + return a! < b! ? a : b; + } + return a! > b! ? a : b; + } + return b; +} diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts index 09efd4ac57a..0d665c065f7 100755 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts @@ -1,31 +1,62 @@ import tinycolor from 'tinycolor2'; -import { Series } from 'uplot'; -import { PointMode } from '../config'; +import uPlot, { Series } from 'uplot'; +import { DrawStyle, LineConfig, AreaConfig, PointsConfig, PointVisibility, LineInterpolation } from '../config'; +import { barsBuilder, smoothBuilder, stepBeforeBuilder, stepAfterBuilder } from '../paths'; import { PlotConfigBuilder } from '../types'; -export interface SeriesProps { +export interface SeriesProps extends LineConfig, AreaConfig, PointsConfig { + drawStyle: DrawStyle; scaleKey: string; - line?: boolean; - lineColor?: string; - lineWidth?: number; - points?: PointMode; - pointSize?: number; - pointColor?: string; - fill?: boolean; - fillOpacity?: number; - fillColor?: string; } export class UPlotSeriesBuilder extends PlotConfigBuilder { getConfig() { - const { line, lineColor, lineWidth, points, pointColor, pointSize, fillColor, fillOpacity, scaleKey } = this.props; + const { + drawStyle, + lineInterpolation, + lineColor, + lineWidth, + showPoints, + pointColor, + pointSize, + fillColor, + fillOpacity, + scaleKey, + spanNulls, + } = this.props; - const lineConfig = line - ? { - stroke: lineColor, - width: lineWidth, + let lineConfig: Partial = {}; + + if (drawStyle === DrawStyle.Points) { + lineConfig.paths = () => null; + } else { + lineConfig.stroke = lineColor; + lineConfig.width = lineWidth; + lineConfig.paths = ( + self: uPlot, + seriesIdx: number, + idx0: number, + idx1: number, + extendGap: Series.ExtendGap, + buildClip: Series.BuildClip + ) => { + let pathsBuilder = self.paths; + + if (drawStyle === DrawStyle.Bars) { + pathsBuilder = barsBuilder; + } else if (drawStyle === DrawStyle.Line) { + if (lineInterpolation === LineInterpolation.StepBefore) { + pathsBuilder = stepBeforeBuilder; + } else if (lineInterpolation === LineInterpolation.StepAfter) { + pathsBuilder = stepAfterBuilder; + } else if (lineInterpolation === LineInterpolation.Smooth) { + pathsBuilder = smoothBuilder; + } } - : {}; + + return pathsBuilder(self, seriesIdx, idx0, idx1, extendGap, buildClip); + }; + } const pointsConfig: Partial = { points: { @@ -36,9 +67,13 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder { }; // we cannot set points.show property above (even to undefined) as that will clear uPlot's default auto behavior - if (points === PointMode.Never) { + if (showPoints === PointVisibility.Auto) { + if (drawStyle === DrawStyle.Bars) { + pointsConfig.points!.show = false; + } + } else if (showPoints === PointVisibility.Never) { pointsConfig.points!.show = false; - } else if (points === PointMode.Always) { + } else if (showPoints === PointVisibility.Always) { pointsConfig.points!.show = true; } @@ -53,6 +88,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder { return { scale: scaleKey, + spanGaps: spanNulls, ...lineConfig, ...pointsConfig, ...areaConfig, diff --git a/packages/grafana-ui/src/components/uPlot/context.ts b/packages/grafana-ui/src/components/uPlot/context.ts index cc6d4806b4f..d36b08825de 100644 --- a/packages/grafana-ui/src/components/uPlot/context.ts +++ b/packages/grafana-ui/src/components/uPlot/context.ts @@ -22,7 +22,7 @@ interface PlotPluginsContextType { interface PlotContextType extends PlotPluginsContextType { isPlotReady: boolean; - getPlotInstance: () => uPlot; + getPlotInstance: () => uPlot | undefined; getSeries: () => Series[]; getCanvas: () => PlotCanvasContextType; canvasRef: any; @@ -131,7 +131,7 @@ export const buildPlotContext = ( canvasRef: any, data: AlignedFrameWithGapTest, registerPlugin: any, - getPlotInstance: () => uPlot + getPlotInstance: () => uPlot | undefined ): PlotContextType => { return { isPlotReady, @@ -139,16 +139,21 @@ export const buildPlotContext = ( data, registerPlugin, getPlotInstance, - getSeries: () => getPlotInstance().series, - getCanvas: () => ({ - width: getPlotInstance().width, - height: getPlotInstance().height, - plot: { - width: getPlotInstance().bbox.width / window.devicePixelRatio, - height: getPlotInstance().bbox.height / window.devicePixelRatio, - top: getPlotInstance().bbox.top / window.devicePixelRatio, - left: getPlotInstance().bbox.left / window.devicePixelRatio, - }, - }), + getSeries: () => getPlotInstance()!.series, + getCanvas: () => { + const plotInstance = getPlotInstance()!; + const bbox = plotInstance.bbox; + const pxRatio = window.devicePixelRatio; + return { + width: plotInstance.width, + height: plotInstance.height, + plot: { + width: bbox.width / pxRatio, + height: bbox.height / pxRatio, + top: bbox.top / pxRatio, + left: bbox.left / pxRatio, + }, + }; + }, }; }; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx b/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx index 0c5c8ac6262..cc247362a5a 100644 --- a/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx +++ b/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx @@ -18,8 +18,7 @@ export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoo const eventMarkers = useMemo(() => { const markers: React.ReactNode[] = []; - - if (!plotCtx.isPlotReady || events.length === 0) { + if (!plotCtx.getPlotInstance() || events.length === 0) { return markers; } @@ -41,9 +40,9 @@ export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoo } return <>{markers}; - }, [events, renderEventMarker, renderToken, plotCtx.isPlotReady]); + }, [events, renderEventMarker, renderToken, plotCtx]); - if (!plotCtx.isPlotReady) { + if (!plotCtx.getPlotInstance()) { return null; } diff --git a/packages/grafana-ui/src/components/uPlot/geometries/XYCanvas.tsx b/packages/grafana-ui/src/components/uPlot/geometries/XYCanvas.tsx index 0ded2517c74..352d34cb0df 100644 --- a/packages/grafana-ui/src/components/uPlot/geometries/XYCanvas.tsx +++ b/packages/grafana-ui/src/components/uPlot/geometries/XYCanvas.tsx @@ -10,8 +10,9 @@ interface XYCanvasProps {} */ export const XYCanvas: React.FC = ({ children }) => { const plotContext = usePlotContext(); + const plotInstance = plotContext.getPlotInstance(); - if (!plotContext.isPlotReady) { + if (!plotInstance) { return null; } @@ -20,8 +21,8 @@ export const XYCanvas: React.FC = ({ children }) => { className={css` position: absolute; overflow: visible; - left: ${plotContext.getPlotInstance().bbox.left / window.devicePixelRatio}px; - top: ${plotContext.getPlotInstance().bbox.top / window.devicePixelRatio}px; + left: ${plotInstance.bbox.left / window.devicePixelRatio}px; + top: ${plotInstance.bbox.top / window.devicePixelRatio}px; `} > {children} diff --git a/packages/grafana-ui/src/components/uPlot/hooks.ts b/packages/grafana-ui/src/components/uPlot/hooks.ts index a4df275efbd..afa9a363534 100644 --- a/packages/grafana-ui/src/components/uPlot/hooks.ts +++ b/packages/grafana-ui/src/components/uPlot/hooks.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { PlotPlugin } from './types'; import { pluginLog } from './utils'; import uPlot, { Options } from 'uplot'; @@ -105,40 +105,44 @@ export const DEFAULT_PLOT_CONFIG = { hooks: {}, }; -//pass plain confsig object,memoize! export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, configBuilder: UPlotConfigBuilder) => { const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins(); - const [currentConfig, setCurrentConfig] = useState(); + const [isConfigReady, setIsConfigReady] = useState(false); + const currentConfig = useRef(); const tzDate = useMemo(() => { let fmt = undefined; const tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName; if (tz) { - fmt = (ts: number) => uPlot.tzDate(new Date(ts * 1e3), tz); + fmt = (ts: number) => uPlot.tzDate(new Date(ts), tz); } return fmt; }, [timeZone]); - useEffect(() => { + useLayoutEffect(() => { if (!arePluginsReady) { return; } - setCurrentConfig({ + currentConfig.current = { ...DEFAULT_PLOT_CONFIG, width, height, + ms: 1, plugins: Object.entries(plugins).map(p => ({ hooks: p[1].hooks, })), tzDate, ...configBuilder.getConfig(), - }); + }; + + setIsConfigReady(true); }, [arePluginsReady, plugins, width, height, tzDate, configBuilder]); return { + isConfigReady, registerPlugin, currentConfig, }; @@ -173,13 +177,14 @@ export const useRefreshAfterGraphRendered = (pluginId: string) => { return renderToken; }; -export function useRevision(dep: T, cmp: (prev: T, next: T) => boolean) { +export function useRevision(dep?: T | null, cmp?: (prev?: T | null, next?: T | null) => boolean) { const [rev, setRev] = useState(0); const prevDep = usePrevious(dep); + const comparator = cmp ? cmp : (a?: T | null, b?: T | null) => a === b; - useEffect(() => { - const hasConfigChanged = prevDep ? !cmp(prevDep, dep) : true; - if (hasConfigChanged) { + useLayoutEffect(() => { + const hasChange = !comparator(prevDep, dep); + if (hasChange) { setRev(r => r + 1); } }, [dep]); diff --git a/packages/grafana-ui/src/components/uPlot/paths.ts b/packages/grafana-ui/src/components/uPlot/paths.ts new file mode 100644 index 00000000000..3173dcbf86e --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/paths.ts @@ -0,0 +1,370 @@ +import uPlot, { Series } from 'uplot'; + +export const barsBuilder: Series.PathBuilder = ( + u: uPlot, + seriesIdx: number, + idx0: number, + idx1: number, + extendGap: Series.ExtendGap, + buildClip: Series.BuildClip +) => { + const series = u.series[seriesIdx]; + const xdata = u.data[0]; + const ydata = u.data[seriesIdx]; + const scaleX = u.series[0].scale as string; + const scaleY = series.scale as string; + + const gapFactor = 0.25; + + let gap = (u.width * gapFactor) / (idx1 - idx0); + let maxWidth = Infinity; + + //@ts-ignore + let fillTo = series.fillTo(u, seriesIdx, series.min, series.max); + + let y0Pos = u.valToPos(fillTo, scaleY, true); + let colWid = u.bbox.width / (idx1 - idx0); + + let strokeWidth = Math.round(series.width! * devicePixelRatio); + + let barWid = Math.round(Math.min(maxWidth, colWid - gap) - strokeWidth); + + let stroke = new Path2D(); + + for (let i = idx0; i <= idx1; i++) { + let yVal = ydata[i]; + + if (yVal == null) { + continue; + } + + let xVal = u.scales.x.distr === 2 ? i : xdata[i]; + + // TODO: all xPos can be pre-computed once for all series in aligned set + let xPos = u.valToPos(xVal, scaleX, true); + let yPos = u.valToPos(yVal, scaleY, true); + + let lft = Math.round(xPos - barWid / 2); + let btm = Math.round(Math.max(yPos, y0Pos)); + let top = Math.round(Math.min(yPos, y0Pos)); + let barHgt = btm - top; + + stroke.rect(lft, top, barWid, barHgt); + } + + let fill = series.fill != null ? new Path2D(stroke) : undefined; + + return { + stroke, + fill, + }; +}; + +/* +const enum StepSide { + Before, + After, +} +*/ + +export const stepBeforeBuilder = stepBuilderFactory(false); +export const stepAfterBuilder = stepBuilderFactory(true); + +// babel does not support inlined const enums, so this uses a boolean flag for perf +// possible workaround: https://github.com/dosentmatter/babel-plugin-const-enum +function stepBuilderFactory(after: boolean): Series.PathBuilder { + return ( + u: uPlot, + seriesIdx: number, + idx0: number, + idx1: number, + extendGap: Series.ExtendGap, + buildClip: Series.BuildClip + ) => { + const series = u.series[seriesIdx]; + const xdata = u.data[0]; + const ydata = u.data[seriesIdx]; + const scaleX = u.series[0].scale as string; + const scaleY = series.scale as string; + const halfStroke = series.width! / 2; + + const stroke = new Path2D(); + + // find first non-null dataPt + while (ydata[idx0] == null) { + idx0++; + } + + // find last-null dataPt + while (ydata[idx1] == null) { + idx1--; + } + + let gaps: Series.Gaps = []; + let inGap = false; + let prevYPos = Math.round(u.valToPos(ydata[idx0]!, scaleY, true)); + let firstXPos = Math.round(u.valToPos(xdata[idx0], scaleX, true)); + let prevXPos = firstXPos; + + stroke.moveTo(firstXPos, prevYPos); + + for (let i = idx0 + 1; i <= idx1; i++) { + let yVal1 = ydata[i]; + + let x1 = Math.round(u.valToPos(xdata[i], scaleX, true)); + + if (yVal1 == null) { + //@ts-ignore + if (series.isGap(u, seriesIdx, i)) { + extendGap(gaps, prevXPos, x1); + inGap = true; + } + + continue; + } + + let y1 = Math.round(u.valToPos(yVal1, scaleY, true)); + + if (inGap) { + extendGap(gaps, prevXPos, x1); + + // don't clip vertical extenders + if (prevYPos !== y1) { + let lastGap = gaps[gaps.length - 1]; + lastGap[0] += halfStroke; + lastGap[1] -= halfStroke; + } + + inGap = false; + } + + if (after) { + stroke.lineTo(x1, prevYPos); + } else { + stroke.lineTo(prevXPos, y1); + } + + stroke.lineTo(x1, y1); + + prevYPos = y1; + prevXPos = x1; + } + + const fill = new Path2D(stroke); + + //@ts-ignore + let fillTo = series.fillTo(u, seriesIdx, series.min, series.max); + + let minY = Math.round(u.valToPos(fillTo, scaleY, true)); + + fill.lineTo(prevXPos, minY); + fill.lineTo(firstXPos, minY); + + let clip = !series.spanGaps ? buildClip(gaps) : null; + + return { + stroke, + fill, + clip, + }; + }; +} + +// adapted from https://gist.github.com/nicholaswmin/c2661eb11cad5671d816 (MIT) +/** + * Interpolates a Catmull-Rom Spline through a series of x/y points + * Converts the CR Spline to Cubic Beziers for use with SVG items + * + * If 'alpha' is 0.5 then the 'Centripetal' variant is used + * If 'alpha' is 1 then the 'Chordal' variant is used + * + * + * @param {Array} data - Array of points, each point in object literal holding x/y values + * @return {String} d - SVG string with cubic bezier curves representing the Catmull-Rom Spline + */ +function catmullRomFitting(xCoords: number[], yCoords: number[], alpha: number) { + const path = new Path2D(); + + const dataLen = xCoords.length; + + let p0x, + p0y, + p1x, + p1y, + p2x, + p2y, + p3x, + p3y, + bp1x, + bp1y, + bp2x, + bp2y, + d1, + d2, + d3, + A, + B, + N, + M, + d3powA, + d2powA, + d3pow2A, + d2pow2A, + d1pow2A, + d1powA; + + path.moveTo(Math.round(xCoords[0]), Math.round(yCoords[0])); + + for (let i = 0; i < dataLen - 1; i++) { + let p0i = i === 0 ? 0 : i - 1; + + p0x = xCoords[p0i]; + p0y = yCoords[p0i]; + + p1x = xCoords[i]; + p1y = yCoords[i]; + + p2x = xCoords[i + 1]; + p2y = yCoords[i + 1]; + + if (i + 2 < dataLen) { + p3x = xCoords[i + 2]; + p3y = yCoords[i + 2]; + } else { + p3x = p2x; + p3y = p2y; + } + + d1 = Math.sqrt(Math.pow(p0x - p1x, 2) + Math.pow(p0y - p1y, 2)); + d2 = Math.sqrt(Math.pow(p1x - p2x, 2) + Math.pow(p1y - p2y, 2)); + d3 = Math.sqrt(Math.pow(p2x - p3x, 2) + Math.pow(p2y - p3y, 2)); + + // Catmull-Rom to Cubic Bezier conversion matrix + + // A = 2d1^2a + 3d1^a * d2^a + d3^2a + // B = 2d3^2a + 3d3^a * d2^a + d2^2a + + // [ 0 1 0 0 ] + // [ -d2^2a /N A/N d1^2a /N 0 ] + // [ 0 d3^2a /M B/M -d2^2a /M ] + // [ 0 0 1 0 ] + + d3powA = Math.pow(d3, alpha); + d3pow2A = Math.pow(d3, 2 * alpha); + d2powA = Math.pow(d2, alpha); + d2pow2A = Math.pow(d2, 2 * alpha); + d1powA = Math.pow(d1, alpha); + d1pow2A = Math.pow(d1, 2 * alpha); + + A = 2 * d1pow2A + 3 * d1powA * d2powA + d2pow2A; + B = 2 * d3pow2A + 3 * d3powA * d2powA + d2pow2A; + N = 3 * d1powA * (d1powA + d2powA); + + if (N > 0) { + N = 1 / N; + } + + M = 3 * d3powA * (d3powA + d2powA); + + if (M > 0) { + M = 1 / M; + } + + bp1x = (-d2pow2A * p0x + A * p1x + d1pow2A * p2x) * N; + bp1y = (-d2pow2A * p0y + A * p1y + d1pow2A * p2y) * N; + + bp2x = (d3pow2A * p1x + B * p2x - d2pow2A * p3x) * M; + bp2y = (d3pow2A * p1y + B * p2y - d2pow2A * p3y) * M; + + if (bp1x === 0 && bp1y === 0) { + bp1x = p1x; + bp1y = p1y; + } + + if (bp2x === 0 && bp2y === 0) { + bp2x = p2x; + bp2y = p2y; + } + + path.bezierCurveTo(bp1x, bp1y, bp2x, bp2y, p2x, p2y); + } + + return path; +} + +export const smoothBuilder: Series.PathBuilder = ( + u: uPlot, + seriesIdx: number, + idx0: number, + idx1: number, + extendGap: Series.ExtendGap, + buildClip: Series.BuildClip +) => { + const series = u.series[seriesIdx]; + const xdata = u.data[0]; + const ydata = u.data[seriesIdx]; + const scaleX = u.series[0].scale as string; + const scaleY = series.scale as string; + + // find first non-null dataPt + while (ydata[idx0] == null) { + idx0++; + } + + // find last-null dataPt + while (ydata[idx1] == null) { + idx1--; + } + + let gaps: Series.Gaps = []; + let inGap = false; + let firstXPos = Math.round(u.valToPos(xdata[idx0], scaleX, true)); + let prevXPos = firstXPos; + + let xCoords = []; + let yCoords = []; + + for (let i = idx0; i <= idx1; i++) { + let yVal = ydata[i]; + let xVal = xdata[i]; + let xPos = u.valToPos(xVal, scaleX, true); + + if (yVal == null) { + //@ts-ignore + if (series.isGap(u, seriesIdx, i)) { + extendGap(gaps, prevXPos + 1, xPos); + inGap = true; + } + + continue; + } else { + if (inGap) { + extendGap(gaps, prevXPos + 1, xPos + 1); + inGap = false; + } + + xCoords.push((prevXPos = xPos)); + yCoords.push(u.valToPos(ydata[i]!, scaleY, true)); + } + } + + const stroke = catmullRomFitting(xCoords, yCoords, 0.5); + + const fill = new Path2D(stroke); + + //@ts-ignore + let fillTo = series.fillTo(u, seriesIdx, series.min, series.max); + + let minY = Math.round(u.valToPos(fillTo, scaleY, true)); + + fill.lineTo(prevXPos, minY); + fill.lineTo(firstXPos, minY); + + let clip = !series.spanGaps ? buildClip(gaps) : null; + + return { + stroke, + fill, + clip, + }; +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx index af351aa18a2..eff8b96a341 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx @@ -75,7 +75,7 @@ export const SelectionPlugin: React.FC = ({ onSelect, onDi }; }, []); - if (!plotCtx.isPlotReady || !children || !selection) { + if (!plotCtx.getPlotInstance() || !children || !selection) { return null; } diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx index be3cbb9f0d2..feed5e7fd4d 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx @@ -25,7 +25,7 @@ export const TooltipPlugin: React.FC = ({ mode = 'single', t return ( {({ focusedSeriesIdx, focusedPointIdx, coords }) => { - if (!plotContext.isPlotReady) { + if (!plotContext.getPlotInstance()) { return null; } diff --git a/packages/grafana-ui/src/slate-plugins/slate-prism/index.ts b/packages/grafana-ui/src/slate-plugins/slate-prism/index.ts index c54b1cefd70..41cf75bdba9 100644 --- a/packages/grafana-ui/src/slate-plugins/slate-prism/index.ts +++ b/packages/grafana-ui/src/slate-plugins/slate-prism/index.ts @@ -1,4 +1,4 @@ -import Prism from 'prismjs'; +import Prism, { LanguageMap } from 'prismjs'; import { Block, Text, Decoration } from 'slate'; import { Plugin } from '@grafana/slate-react'; import Options, { OptionsFormat } from './options'; @@ -19,7 +19,7 @@ export interface Token { /** * A Slate plugin to highlight code syntax. */ -export function SlatePrism(optsParam: OptionsFormat = {}): Plugin { +export function SlatePrism(optsParam: OptionsFormat = {}, prismLanguages = Prism.languages as LanguageMap): Plugin { const opts: Options = new Options(optsParam); return { @@ -30,7 +30,7 @@ export function SlatePrism(optsParam: OptionsFormat = {}): Plugin { const block = Block.create(node as Block); const grammarName = opts.getSyntax(block); - const grammar = Prism.languages[grammarName]; + const grammar = prismLanguages[grammarName]; if (!grammar) { // Grammar not loaded diff --git a/packages/grafana-ui/src/slate-plugins/suggestions.tsx b/packages/grafana-ui/src/slate-plugins/suggestions.tsx index 21d18bf4e70..564b2781559 100644 --- a/packages/grafana-ui/src/slate-plugins/suggestions.tsx +++ b/packages/grafana-ui/src/slate-plugins/suggestions.tsx @@ -189,7 +189,7 @@ export function SuggestionsPlugin({ }, }, - renderEditor: (props, editor, next) => { + renderEditor(props, editor, next) { if (editor.value.selection.isExpanded) { return next(); } diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index 34483ff3459..801a8b13705 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -34,6 +34,7 @@ export type IconName = | 'exclamation-triangle' | 'times' | 'signin' + | 'signout' | 'cloud-upload' | 'step-backward' | 'square-shape' @@ -79,6 +80,7 @@ export type IconName = | 'compass' | 'sliders-v-alt' | 'bell' + | 'bell-slash' | 'database' | 'user' | 'camera' @@ -197,6 +199,7 @@ export const getAvailableIcons = (): IconName[] => [ 'compass', 'sliders-v-alt', 'bell', + 'bell-slash', 'database', 'user', 'camera', diff --git a/packages/grafana-ui/src/utils/standardEditors.tsx b/packages/grafana-ui/src/utils/standardEditors.tsx index 85a2ee12a8a..3288aef7cdb 100644 --- a/packages/grafana-ui/src/utils/standardEditors.tsx +++ b/packages/grafana-ui/src/utils/standardEditors.tsx @@ -259,7 +259,9 @@ export const getStandardOptionEditors = () => { id: 'boolean', name: 'Boolean', description: 'Allows boolean values input', - editor: props => props.onChange(e.currentTarget.checked)} />, + editor(props) { + return props.onChange(e.currentTarget.checked)} />; + }, }; const select: StandardEditorsRegistryItem = { @@ -273,7 +275,9 @@ export const getStandardOptionEditors = () => { id: 'radio', name: 'Radio', description: 'Allows option selection', - editor: props => , + editor(props) { + return ; + }, }; const unit: StandardEditorsRegistryItem = { @@ -301,7 +305,9 @@ export const getStandardOptionEditors = () => { id: 'color', name: 'Color', description: 'Allows color selection', - editor: props => , + editor(props) { + return ; + }, }; const fieldColor: StandardEditorsRegistryItem = { diff --git a/packages/grafana-ui/src/utils/storybook/data.ts b/packages/grafana-ui/src/utils/storybook/data.ts index be008d93ed6..0063fc482b7 100644 --- a/packages/grafana-ui/src/utils/storybook/data.ts +++ b/packages/grafana-ui/src/utils/storybook/data.ts @@ -9,6 +9,5 @@ export function prepDataForStorybook(data: DataFrame[], theme: GrafanaTheme) { }, theme, replaceVariables: (value: string) => value, - getDataSourceSettingsByUid: (value: string) => ({} as any), }); } diff --git a/packages/grafana-ui/src/utils/storybook/withTheme.tsx b/packages/grafana-ui/src/utils/storybook/withTheme.tsx index ad6c55fb6c7..293ee7b8e36 100644 --- a/packages/grafana-ui/src/utils/storybook/withTheme.tsx +++ b/packages/grafana-ui/src/utils/storybook/withTheme.tsx @@ -32,6 +32,7 @@ export const renderComponentWithTheme = (component: React.ComponentType, pr ); }; +// eslint-disable-next-line react/display-name export const withTheme = (handleSassThemeChange: SassThemeChangeHandler) => (story: RenderFunction) => ( {story()} ); diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json index 473837ee699..e2da7dc5f28 100644 --- a/packages/jaeger-ui-components/package.json +++ b/packages/jaeger-ui-components/package.json @@ -11,7 +11,7 @@ "devDependencies": { "enzyme": "^3.8.0", "enzyme-adapter-react-16": "^1.2.0", - "typescript": "4.0.2" + "typescript": "4.1.2" }, "dependencies": { "@grafana/data": "7.4.0-pre.0", diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx index fe5f7a44ce6..3874d056fae 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx @@ -68,7 +68,7 @@ export default class SpanGraph extends React.PureComponent) => { + renderer(trace: Trace, styles?: ReturnType) { const dateStr = formatDatetime(trace.startTime); const match = dateStr.match(/^(.+)(:\d\d\.\d+)$/); return match ? ( diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap index baf54ebe67e..3c12d1d9a8f 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap @@ -2,11 +2,8 @@ exports[` shallow tests matches a snapshot 1`] = `
', () => { itemsWrapperClassName: 'SomeClassName', viewBuffer: 10, viewBufferMin: 5, - windowScroller: false, + windowScroller: true, }; describe('shallow tests', () => { @@ -152,10 +152,6 @@ describe('', () => { instance = wrapper.instance(); }); - it('getViewHeight() returns the viewHeight', () => { - expect(instance.getViewHeight()).toBe(clientHeight); - }); - it('getBottomVisibleIndex() returns a number', () => { const n = instance.getBottomVisibleIndex(); expect(Number.isNaN(n)).toBe(false); @@ -233,9 +229,9 @@ describe('', () => { }, }); const hasChanged = instance._isViewChanged(); + expect(hasChanged).toBe(true); expect(spyFns.clientHeight).toHaveBeenCalled(); expect(spyFns.scrollTop).toHaveBeenCalled(); - expect(hasChanged).toBe(true); }); }); }); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx index 55bd4dbf86d..22bc0aeb421 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx @@ -67,7 +67,7 @@ type TListViewProps = { itemsWrapperClassName?: string; /** * When adding new items to the DOM, this is the number of items to add above - * and below the current view. E.g. if list is 100 items and is srcolled + * and below the current view. E.g. if list is 100 items and is scrolled * halfway down (so items [46, 55] are in view), then when a new range of * items is rendered, it will render items `46 - viewBuffer` to * `55 + viewBuffer`. @@ -89,15 +89,20 @@ type TListViewProps = { * - Ref:https://github.com/bvaughn/react-virtualized/blob/497e2a1942529560681d65a9ef9f5e9c9c9a49ba/docs/WindowScroller.md */ windowScroller?: boolean; + /** + * You need to pass in scrollElement when windowScroller is set to false. + * This element is responsible for tracking scrolling for lazy loading. + */ + scrollElement?: Element; }; -const DEFAULT_INITIAL_DRAW = 300; +const DEFAULT_INITIAL_DRAW = 100; /** * Virtualized list view component, for the most part, only renders the window * of items that are in-view with some buffer before and after. Listens for * scroll events and updates which items are rendered. See react-virtualized - * for a suite of components with similar, but generalized, functinality. + * for a suite of components with similar, but generalized, functionality. * https://github.com/bvaughn/react-virtualized * * Note: Presently, ListView cannot be a PureComponent. This is because ListView @@ -157,9 +162,9 @@ export default class ListView extends React.Component { _windowScrollListenerAdded: boolean; _htmlElm: HTMLElement; /** - * HTMLElement holding the scroller. + * Element holding the scroller. */ - _wrapperElm: HTMLElement | TNil; + _wrapperElm: Element | TNil; /** * HTMLElement holding the rendered items. */ @@ -202,6 +207,10 @@ export default class ListView extends React.Component { } window.addEventListener('scroll', this._onScroll); this._windowScrollListenerAdded = true; + } else { + // The wrapper element should be the one that handles the scrolling. Once we are not using scroll-canvas we can remove this. + this._wrapperElm = this.props.scrollElement; + this._wrapperElm?.addEventListener('scroll', this._onScroll); } } @@ -214,6 +223,8 @@ export default class ListView extends React.Component { componentWillUnmount() { if (this._windowScrollListenerAdded) { window.removeEventListener('scroll', this._onScroll); + } else { + this._wrapperElm?.removeEventListener('scroll', this._onScroll); } } @@ -308,8 +319,11 @@ export default class ListView extends React.Component { }; _initWrapper = (elm: HTMLElement | TNil) => { + if (!this.props.windowScroller) { + return; + } this._wrapperElm = elm; - if (!this.props.windowScroller && elm) { + if (elm) { this._viewHeight = elm.clientHeight; } }; @@ -374,8 +388,8 @@ export default class ListView extends React.Component { }; /** - * Get the height of the element at index `i`; first check the known heigths, - * fallbck to `.props.itemHeightGetter(...)`. + * Get the height of the element at index `i`; first check the known heights, + * fallback to `.props.itemHeightGetter(...)`. */ _getHeight = (i: number) => { const key = this.props.getKeyFromIndex(i); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.test.js index 5c9a172fcb3..a36b6a7038b 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.test.js @@ -71,7 +71,9 @@ describe(ReferencesButton, () => { // here const menuInstance = shallow( shallow(dropdown.first().props().overlay).prop('children')({ - Menu: ({ children }) =>
{children}
, + Menu({ children }) { + return
{children}
; + }, }) ); const submenuItems = menuInstance.find(UIMenuItem); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js index 8b23d30ac99..0a2d198ea76 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js @@ -112,7 +112,13 @@ describe('', () => { const overlay = shallow(dropdown.prop('overlay')); // We have some wrappers here that dynamically inject specific component so we need to traverse a bit // here - const menu = shallow(overlay.prop('children')({ Menu: ({ children }) =>
{children}
})); + const menu = shallow( + overlay.prop('children')({ + Menu({ children }) { + return
{children}
; + }, + }) + ); const anchors = menu.find(LinkValue); expect(anchors).toHaveLength(2); const firstAnchor = anchors.first(); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx index b387dc42aa7..cfe877f4fb3 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx @@ -194,7 +194,7 @@ export default class TimelineViewingLayer extends React.PureComponent { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode }; + scrollElement?: Element; }; type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TExtractUiFindFromStateReturn & TTraceTimeline; @@ -189,7 +190,7 @@ export class UnthemedVirtualizedTraceView extends React.Component
); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx index be668aaa066..c2d229004bc 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx @@ -102,6 +102,7 @@ type TProps = TExtractUiFindFromStateReturn & { createSpanLink?: ( span: TraceSpan ) => { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode }; + scrollElement?: Element; }; type State = { diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 1d2817b74a5..155534d41d5 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -433,7 +433,7 @@ func NotificationTest(c *models.ReqContext, dto dtos.NotificationTestCommand) Re SecureSettings: dto.SecureSettings, } - if err := bus.Dispatch(cmd); err != nil { + if err := bus.DispatchCtx(c.Req.Context(), cmd); err != nil { if errors.Is(err, models.ErrSmtpNotEnabled) { return Error(412, err.Error(), err) } diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index 9eb99db8b3b..95b6cea3efc 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -132,6 +132,9 @@ func newNotFound() *Avatar { avatar := &Avatar{notFound: true} // load user_profile png into buffer + // It's safe to ignore gosec warning G304 since the variable part of the file path comes from a configuration + // variable. + // nolint:gosec path := filepath.Join(setting.StaticRootPath, "img", "user_profile.png") if data, err := ioutil.ReadFile(path); err != nil { diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 8a9028aaa63..51d38bb0a77 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/dashdiffs" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/guardian" @@ -334,7 +333,7 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) Response { dashRedirect := dtos.DashboardRedirect{RedirectUri: url} return JSON(200, &dashRedirect) } - log.Warnf("Failed to get slug from database, %s", err.Error()) + hs.log.Warn("Failed to get slug from database", "err", err) } filePath := hs.Cfg.DefaultHomeDashboardPath @@ -342,11 +341,18 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) Response { filePath = filepath.Join(hs.Cfg.StaticRootPath, "dashboards/home.json") } + // It's safe to ignore gosec warning G304 since the variable part of the file path comes from a configuration + // variable + // nolint:gosec file, err := os.Open(filePath) if err != nil { return Error(500, "Failed to load home dashboard", err) } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + hs.log.Warn("Failed to close dashboard file", "path", filePath, "err", err) + } + }() dash := dtos.DashboardFullWithMeta{} dash.Meta.IsHome = true diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 2449522531b..2aa18b44c25 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -132,6 +132,7 @@ func CreateDashboardSnapshot(c *models.ReqContext, cmd models.CreateDashboardSna "deleteKey": cmd.DeleteKey, "url": url, "deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey), + "id": cmd.Result.Id, }) } @@ -223,7 +224,10 @@ func DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) Response { return Error(500, "Failed to delete dashboard snapshot", err) } - return JSON(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches."}) + return JSON(200, util.DynMap{ + "message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", + "id": query.Result.Id, + }) } // DELETE /api/snapshots/:key @@ -269,7 +273,10 @@ func DeleteDashboardSnapshot(c *models.ReqContext) Response { return Error(500, "Failed to delete dashboard snapshot", err) } - return JSON(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches."}) + return JSON(200, util.DynMap{ + "message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", + "id": query.Result.Id, + }) } // GET /api/dashboard/snapshots diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index 9689e02a215..6dbc39332a4 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -109,6 +109,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { require.NoError(t, err) assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted")) + assert.Equal(t, 1, respJSON.Get("id").MustInt()) assert.Equal(t, http.MethodGet, externalRequest.Method) assert.Equal(t, ts.URL, fmt.Sprintf("http://%s", externalRequest.Host)) @@ -141,6 +142,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { require.NoError(t, err) assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted")) + assert.Equal(t, 1, respJSON.Get("id").MustInt()) assert.Equal(t, ts.URL, fmt.Sprintf("http://%s", externalRequest.Host)) assert.Equal(t, "/", externalRequest.URL.EscapedPath()) }) @@ -163,6 +165,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { require.NoError(t, err) assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted")) + assert.Equal(t, 1, respJSON.Get("id").MustInt()) }) }) @@ -186,6 +189,11 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { require.NoError(t, writeErr) assert.Equal(t, 200, sc.resp.Code) + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + require.NoError(t, err) + + assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted")) + assert.Equal(t, 1, respJSON.Get("id").MustInt()) }) loggedInUserScenarioWithRole(t, diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 79f2e859557..b404f32e31c 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -1,6 +1,10 @@ package dtos -import "github.com/grafana/grafana/pkg/setting" +import ( + "github.com/grafana/grafana/pkg/setting" + + "html/template" +) type IndexViewData struct { User *CurrentUser @@ -17,8 +21,8 @@ type IndexViewData struct { NewGrafanaVersion string AppName string AppNameBodyClass string - FavIcon string - AppleTouchIcon string + FavIcon template.URL + AppleTouchIcon template.URL AppTitle string Sentry *setting.Sentry } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 914ac809291..b734f4dd78b 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -146,7 +146,6 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i if isDefault, _ := dsM["isDefault"].(bool); isDefault { defaultDS = n } - delete(dsM, "isDefault") meta := dsM["meta"].(*plugins.DataSourcePlugin) if meta.Preload { diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 8ca5ce36e88..625b142f39d 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -109,6 +109,7 @@ func (hs *HTTPServer) Run(ctx context.Context) error { if err := hs.configureHttps(); err != nil { return err } + default: } listener, err := hs.getListener() @@ -366,7 +367,7 @@ func (hs *HTTPServer) metricsEndpoint(ctx *macaron.Context) { } promhttp. - HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{}). + HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true}). ServeHTTP(ctx.Resp, ctx.Req.Request) } diff --git a/pkg/api/index.go b/pkg/api/index.go index e88d1e00318..79d35c4bf75 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -104,6 +104,7 @@ func getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) { Text: include.Name, } } + link.Icon = include.Icon appLink.Children = append(appLink.Children, link) } @@ -163,7 +164,15 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto {Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true}, {Text: "Manage", Id: "manage-dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "sitemap"}, {Text: "Playlists", Id: "playlists", Url: setting.AppSubUrl + "/playlists", Icon: "presentation-play"}, - {Text: "Snapshots", Id: "snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots", Icon: "camera"}, + } + + if c.IsSignedIn { + dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ + Text: "Snapshots", + Id: "snapshots", + Url: setting.AppSubUrl + "/dashboard/snapshots", + Icon: "camera", + }) } navTree = append(navTree, &dtos.NavLink{ diff --git a/pkg/api/routing/route_register.go b/pkg/api/routing/route_register.go index b0acb581681..0d9952c6db0 100644 --- a/pkg/api/routing/route_register.go +++ b/pkg/api/routing/route_register.go @@ -118,22 +118,24 @@ func (rr *routeRegister) Register(router Router) { func (rr *routeRegister) route(pattern, method string, handlers ...macaron.Handler) { h := make([]macaron.Handler, 0) + fullPattern := rr.prefix + pattern + for _, fn := range rr.namedMiddleware { - h = append(h, fn(pattern)) + h = append(h, fn(fullPattern)) } h = append(h, rr.subfixHandlers...) h = append(h, handlers...) for _, r := range rr.routes { - if r.pattern == rr.prefix+pattern && r.method == method { + if r.pattern == fullPattern && r.method == method { panic("cannot add duplicate route") } } rr.routes = append(rr.routes, route{ method: method, - pattern: rr.prefix + pattern, + pattern: fullPattern, handlers: h, }) } diff --git a/pkg/api/routing/route_register_test.go b/pkg/api/routing/route_register_test.go index e7030d57eab..cf67abf06dc 100644 --- a/pkg/api/routing/route_register_test.go +++ b/pkg/api/routing/route_register_test.go @@ -214,8 +214,13 @@ func TestNamedMiddlewareRouteRegister(t *testing.T) { {method: "GET", pattern: "/user/admin/all", handlers: emptyHandlers(5)}, } + namedMiddlewares := map[string]bool{} // Setup - rr := NewRouteRegister(emptyHandler) + rr := NewRouteRegister(func(name string) macaron.Handler { + namedMiddlewares[name] = true + + return struct{ name string }{name: name} + }) rr.Delete("/admin", emptyHandler("1")) rr.Get("/down", emptyHandler("1"), emptyHandler("2")) @@ -247,6 +252,10 @@ func TestNamedMiddlewareRouteRegister(t *testing.T) { t.Errorf("want %s got %v", testTable[i].pattern, fr.route[i].pattern) } + if _, exist := namedMiddlewares[testTable[i].pattern]; !exist { + t.Errorf("could not find named route named %s", testTable[i].pattern) + } + if len(testTable[i].handlers) != len(fr.route[i].handlers) { t.Errorf("want %d handlers got %d handlers \ntestcase: %v\nroute: %v\n", len(testTable[i].handlers), diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 40849d90d3e..5f44774bb23 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "reflect" + + "github.com/opentracing/opentracing-go" ) // HandlerFunc defines a handler function interface. @@ -84,6 +86,11 @@ func (b *InProcBus) SetTransactionManager(tm TransactionManager) { func (b *InProcBus) DispatchCtx(ctx context.Context, msg Msg) error { var msgName = reflect.TypeOf(msg).Elem().Name() + span, ctx := opentracing.StartSpanFromContext(ctx, "bus - "+msgName) + defer span.Finish() + + span.SetTag("msg", msgName) + var handler = b.handlersWithCtx[msgName] if handler == nil { return ErrHandlerNotFound diff --git a/pkg/cmd/grafana-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index 558ca9b3da4..311b686165e 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -291,6 +291,10 @@ func extractFile(file *zip.File, filePath string) (err error) { fileMode = os.FileMode(0755) } + // We can ignore the gosec G304 warning on this one, since the variable part of the file path stems + // from command line flag "pluginsDir", and the only possible damage would be writing to the wrong directory. + // If the user shouldn't be writing to this directory, they shouldn't have the permission in the file system. + // nolint:gosec dst, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode) if err != nil { if os.IsPermission(err) { diff --git a/pkg/cmd/grafana-cli/services/api_client.go b/pkg/cmd/grafana-cli/services/api_client.go index 2aa40a2a2de..1ac8cbf6458 100644 --- a/pkg/cmd/grafana-cli/services/api_client.go +++ b/pkg/cmd/grafana-cli/services/api_client.go @@ -44,8 +44,11 @@ func (client *GrafanaComClient) GetPlugin(pluginId, repoUrl string) (models.Plug } func (client *GrafanaComClient) DownloadFile(pluginName string, tmpFile *os.File, url string, checksum string) (err error) { - // Try handling url like local file path first + // Try handling URL as a local file path first if _, err := os.Stat(url); err == nil { + // We can ignore this gosec G304 warning since `url` stems from command line flag "pluginUrl". If the + // user shouldn't be able to read the file, it should be handled through filesystem permissions. + // nolint:gosec f, err := os.Open(url) if err != nil { return errutil.Wrap("Failed to read plugin archive", err) diff --git a/pkg/cmd/grafana-cli/services/io_util.go b/pkg/cmd/grafana-cli/services/io_util.go index b1f1c140e2c..38aa81d94f7 100644 --- a/pkg/cmd/grafana-cli/services/io_util.go +++ b/pkg/cmd/grafana-cli/services/io_util.go @@ -21,5 +21,9 @@ func (i IoUtilImp) ReadDir(path string) ([]os.FileInfo, error) { } func (i IoUtilImp) ReadFile(filename string) ([]byte, error) { + // We can ignore the gosec G304 warning on this one, since the variable part of the file path stems + // from command line flag "pluginsDir". If the user shouldn't be reading from this directory, they shouldn't have + // the permission in the file system. + // nolint:gosec return ioutil.ReadFile(filename) } diff --git a/pkg/cmd/grafana-cli/utils/grafana_path.go b/pkg/cmd/grafana-cli/utils/grafana_path.go index aba5e1b8d48..51f953d26cd 100644 --- a/pkg/cmd/grafana-cli/utils/grafana_path.go +++ b/pkg/cmd/grafana-cli/utils/grafana_path.go @@ -16,8 +16,8 @@ func GetGrafanaPluginDir(currentOS string) string { return returnOsDefault(currentOS) } -// getGrafanaRoot tries to get root of directory when developing grafana ie repo root. It is not perfect it just -// checks what is the binary path and tries to guess based on that but if it is not running in dev env you get a bogus +// getGrafanaRoot tries to get root of directory when developing grafana, ie. repo root. It is not perfect, it just +// checks what is the binary path and tries to guess based on that, but if it is not running in dev env you get a bogus // path back. func getGrafanaRoot() (string, error) { ex, err := os.Executable() diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index b68a76238e8..0ab0a6a7186 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -116,7 +116,11 @@ func executeServer(configFile, homePath, pidFile, packaging string, traceDiagnos if err != nil { panic(err) } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + log.Error("Failed to write trace diagnostics", "path", traceDiagnostics.file, "err", err) + } + }() if err := trace.Start(f); err != nil { panic(err) diff --git a/pkg/components/imguploader/azureblobuploader.go b/pkg/components/imguploader/azureblobuploader.go index 50f5165b4af..80023b8c30c 100644 --- a/pkg/components/imguploader/azureblobuploader.go +++ b/pkg/components/imguploader/azureblobuploader.go @@ -45,11 +45,18 @@ func (az *AzureBlobUploader) Upload(ctx context.Context, imageDiskPath string) ( // setup client blob := NewStorageClient(az.account_name, az.account_key) + // We can ignore the gosec G304 warning on this one because `imageDiskPath` comes + // from alert notifiers and is only used to upload images generated by alerting. + // nolint:gosec file, err := os.Open(imageDiskPath) if err != nil { return "", err } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + az.log.Warn("Failed to close file", "path", imageDiskPath, "err", err) + } + }() randomFileName, err := util.GetRandomString(30) if err != nil { diff --git a/pkg/components/imguploader/gcs/gcsuploader.go b/pkg/components/imguploader/gcs/gcsuploader.go index a2fee45b580..391e5b78ece 100644 --- a/pkg/components/imguploader/gcs/gcsuploader.go +++ b/pkg/components/imguploader/gcs/gcsuploader.go @@ -149,11 +149,19 @@ func (u *Uploader) uploadFile( key string, ) error { u.log.Debug("Opening image file", "path", imageDiskPath) + + // We can ignore the gosec G304 warning on this one because `imageDiskPath` comes + // from alert notifiers and is only used to upload images generated by alerting. + // nolint:gosec fileReader, err := os.Open(imageDiskPath) if err != nil { return err } - defer fileReader.Close() + defer func() { + if err := fileReader.Close(); err != nil { + u.log.Warn("Failed to close file", "err", err, "path", imageDiskPath) + } + }() // Set public access if not generating a signed URL pubAcc := !u.enableSignedURLs diff --git a/pkg/components/imguploader/s3uploader.go b/pkg/components/imguploader/s3uploader.go index d49e9018f91..d0b8b251740 100644 --- a/pkg/components/imguploader/s3uploader.go +++ b/pkg/components/imguploader/s3uploader.go @@ -76,16 +76,24 @@ func (u *S3Uploader) Upload(ctx context.Context, imageDiskPath string) (string, key := u.path + rand + pngExt log.Debugf("Uploading image to s3. bucket = %s, path = %s", u.bucket, key) + // We can ignore the gosec G304 warning on this one because `imageDiskPath` comes + // from alert notifiers and is only used to upload images generated by alerting. + // nolint:gosec file, err := os.Open(imageDiskPath) if err != nil { return "", err } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + u.log.Warn("Failed to close file", "path", imageDiskPath, "err", err) + } + }() sess, err = session.NewSession(cfg) if err != nil { return "", err } + uploader := s3manager.NewUploader(sess) result, err := uploader.UploadWithContext(ctx, &s3manager.UploadInput{ Bucket: aws.String(u.bucket), diff --git a/pkg/components/imguploader/webdavuploader.go b/pkg/components/imguploader/webdavuploader.go index f0a157a8291..3fd9894a8cf 100644 --- a/pkg/components/imguploader/webdavuploader.go +++ b/pkg/components/imguploader/webdavuploader.go @@ -45,7 +45,7 @@ func (u *WebdavUploader) PublicURL(filename string) string { return publicURL.String() } -func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) { +func (u *WebdavUploader) Upload(ctx context.Context, imgToUpload string) (string, error) { url, _ := url.Parse(u.url) filename, err := util.GetRandomString(20) if err != nil { @@ -55,7 +55,10 @@ func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) filename += pngExt url.Path = path.Join(url.Path, filename) - imgData, err := ioutil.ReadFile(pa) + // We can ignore the gosec G304 warning on this one because `imgToUpload` comes + // from alert notifiers and is only used to upload images generated by alerting. + // nolint:gosec + imgData, err := ioutil.ReadFile(imgToUpload) if err != nil { return "", err } diff --git a/pkg/expr/graph.go b/pkg/expr/graph.go index 8322d037236..3fb60c1dc6c 100644 --- a/pkg/expr/graph.go +++ b/pkg/expr/graph.go @@ -52,8 +52,8 @@ func (dp *DataPipeline) execute(c context.Context) (mathexp.Vars, error) { // BuildPipeline builds a graph of the nodes, and returns the nodes in an // executable order. -func buildPipeline(queries []backend.DataQuery) (DataPipeline, error) { - graph, err := buildDependencyGraph(queries) +func buildPipeline(req *backend.QueryDataRequest) (DataPipeline, error) { + graph, err := buildDependencyGraph(req) if err != nil { return nil, err } @@ -67,8 +67,8 @@ func buildPipeline(queries []backend.DataQuery) (DataPipeline, error) { } // buildDependencyGraph returns a dependency graph for a set of queries. -func buildDependencyGraph(queries []backend.DataQuery) (*simple.DirectedGraph, error) { - graph, err := buildGraph(queries) +func buildDependencyGraph(req *backend.QueryDataRequest) (*simple.DirectedGraph, error) { + graph, err := buildGraph(req) if err != nil { return nil, err } @@ -113,10 +113,10 @@ func buildNodeRegistry(g *simple.DirectedGraph) map[string]Node { } // buildGraph creates a new graph populated with nodes for every query. -func buildGraph(queries []backend.DataQuery) (*simple.DirectedGraph, error) { +func buildGraph(req *backend.QueryDataRequest) (*simple.DirectedGraph, error) { dp := simple.NewDirectedGraph() - for _, query := range queries { + for _, query := range req.Queries { rawQueryProp := make(map[string]interface{}) err := json.Unmarshal(query.JSON, &rawQueryProp) if err != nil { @@ -139,7 +139,7 @@ func buildGraph(queries []backend.DataQuery) (*simple.DirectedGraph, error) { case DatasourceName: node, err = buildCMDNode(dp, rn) default: // If it's not an expression query, it's a data source query. - node, err = buildDSNode(dp, rn) + node, err = buildDSNode(dp, rn, req.PluginContext.OrgID) } if err != nil { return nil, err diff --git a/pkg/expr/mathexp/exp.go b/pkg/expr/mathexp/exp.go index dfcc22a91de..a748a0f9418 100644 --- a/pkg/expr/mathexp/exp.go +++ b/pkg/expr/mathexp/exp.go @@ -505,7 +505,7 @@ func (e *State) walkFunc(node *parse.FuncNode) (Results, error) { case *parse.VarNode: v = e.Vars[t.Name] case *parse.ScalarNode: - v = t.Float64 + v = NewScalarResults(&t.Float64) case *parse.FuncNode: v, err = e.walkFunc(t) case *parse.UnaryNode: diff --git a/pkg/expr/mathexp/funcs.go b/pkg/expr/mathexp/funcs.go index 98541dbb2ed..527fe8a22f2 100644 --- a/pkg/expr/mathexp/funcs.go +++ b/pkg/expr/mathexp/funcs.go @@ -107,6 +107,9 @@ func perFloat(val Value, floatF func(x float64) float64) (Value, error) { } } newVal = newSeries + default: + // TODO: Should we deal with TypeString, TypeVariantSet? } + return newVal, nil } diff --git a/pkg/expr/mathexp/reduce.go b/pkg/expr/mathexp/reduce.go index dd1432a19f6..1c0d63118aa 100644 --- a/pkg/expr/mathexp/reduce.go +++ b/pkg/expr/mathexp/reduce.go @@ -11,7 +11,7 @@ func Sum(v *data.Field) *float64 { var sum float64 for i := 0; i < v.Len(); i++ { if f, ok := v.At(i).(*float64); ok { - if f == nil { + if f == nil || math.IsNaN(*f) { nan := math.NaN() return &nan } @@ -35,7 +35,7 @@ func Min(fv *data.Field) *float64 { } for i := 0; i < fv.Len(); i++ { if v, ok := fv.At(i).(*float64); ok { - if v == nil { + if v == nil || math.IsNaN(*v) { nan := math.NaN() return &nan } @@ -55,7 +55,7 @@ func Max(fv *data.Field) *float64 { } for i := 0; i < fv.Len(); i++ { if v, ok := fv.At(i).(*float64); ok { - if v == nil { + if v == nil || math.IsNaN(*v) { nan := math.NaN() return &nan } diff --git a/pkg/expr/mathexp/type_series.go b/pkg/expr/mathexp/type_series.go index d1fe1884055..ac716a80556 100644 --- a/pkg/expr/mathexp/type_series.go +++ b/pkg/expr/mathexp/type_series.go @@ -46,6 +46,8 @@ func SeriesFromFrame(frame *data.Frame) (s Series, err error) { s.ValueIsNullabe = true foundValue = true s.ValueIdx = i + default: + // Handle default case } } if !foundTime { diff --git a/pkg/expr/nodes.go b/pkg/expr/nodes.go index 2e2731378bf..35815f10013 100644 --- a/pkg/expr/nodes.go +++ b/pkg/expr/nodes.go @@ -137,7 +137,7 @@ func (dn *DSNode) NodeType() NodeType { return TypeDatasourceNode } -func buildDSNode(dp *simple.DirectedGraph, rn *rawNode) (*DSNode, error) { +func buildDSNode(dp *simple.DirectedGraph, rn *rawNode, orgID int64) (*DSNode, error) { encodedQuery, err := json.Marshal(rn.Query) if err != nil { return nil, err @@ -148,6 +148,7 @@ func buildDSNode(dp *simple.DirectedGraph, rn *rawNode) (*DSNode, error) { id: dp.NewNode().ID(), refID: rn.RefID, }, + orgID: orgID, query: json.RawMessage(encodedQuery), queryType: rn.QueryType, intervalMS: defaultIntervalMS, @@ -165,16 +166,6 @@ func buildDSNode(dp *simple.DirectedGraph, rn *rawNode) (*DSNode, error) { } dsNode.datasourceID = int64(floatDsID) - rawOrgID, ok := rn.Query["orgId"] - if !ok { - return nil, fmt.Errorf("no orgId in expression data source request command for refId %v", rn.RefID) - } - floatOrgID, ok := rawOrgID.(float64) - if !ok { - return nil, fmt.Errorf("expected orgId to be a float64, got type %T for refId %v", rawOrgID, rn.RefID) - } - dsNode.orgID = int64(floatOrgID) - var floatIntervalMS float64 if rawIntervalMS := rn.Query["intervalMs"]; ok { if floatIntervalMS, ok = rawIntervalMS.(float64); !ok { diff --git a/pkg/expr/service.go b/pkg/expr/service.go index a8bfc73136a..54a8e235ae4 100644 --- a/pkg/expr/service.go +++ b/pkg/expr/service.go @@ -19,8 +19,8 @@ type Service struct { } // BuildPipeline builds a pipeline from a request. -func (s *Service) BuildPipeline(queries []backend.DataQuery) (DataPipeline, error) { - return buildPipeline(queries) +func (s *Service) BuildPipeline(req *backend.QueryDataRequest) (DataPipeline, error) { + return buildPipeline(req) } // ExecutePipeline executes an expression pipeline and returns all the results. diff --git a/pkg/expr/service_test.go b/pkg/expr/service_test.go index ed8dcebbb94..85396ea146c 100644 --- a/pkg/expr/service_test.go +++ b/pkg/expr/service_test.go @@ -36,7 +36,9 @@ func TestService(t *testing.T) { }, } - pl, err := s.BuildPipeline(queries) + req := &backend.QueryDataRequest{Queries: queries} + + pl, err := s.BuildPipeline(req) require.NoError(t, err) res, err := s.ExecutePipeline(context.Background(), pl) diff --git a/pkg/expr/transform.go b/pkg/expr/transform.go index a539ec37ee3..ea0c53fe786 100644 --- a/pkg/expr/transform.go +++ b/pkg/expr/transform.go @@ -73,7 +73,7 @@ func TransformData(ctx context.Context, req *backend.QueryDataRequest) (*backend svc := Service{} // Build the pipeline from the request, checking for ordering issues (e.g. loops) // and parsing graph nodes from the queries. - pipeline, err := svc.BuildPipeline(req.Queries) + pipeline, err := svc.BuildPipeline(req) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } @@ -154,7 +154,7 @@ func QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.Que } queries[i] = &tsdb.Query{ RefId: query.RefID, - IntervalMs: query.Interval.Microseconds(), + IntervalMs: query.Interval.Milliseconds(), MaxDataPoints: query.MaxDataPoints, QueryType: query.QueryType, DataSource: getDsInfo.Result, diff --git a/pkg/infra/log/file.go b/pkg/infra/log/file.go index 8810d97e959..59ccf52f063 100644 --- a/pkg/infra/log/file.go +++ b/pkg/infra/log/file.go @@ -123,13 +123,13 @@ func (w *FileLogWriter) createLogFile() (*os.File, error) { } func (w *FileLogWriter) lineCounter() (int, error) { - r, err := os.OpenFile(w.Filename, os.O_RDONLY, 0644) + r, err := os.Open(w.Filename) if err != nil { - return 0, fmt.Errorf("lineCounter Open File : %s", err) + return 0, fmt.Errorf("failed to open file %q: %w", w.Filename, err) } + buf := make([]byte, 32*1024) count := 0 - for { c, err := r.Read(buf) count += bytes.Count(buf[:c], []byte{'\n'}) diff --git a/pkg/infra/log/log.go b/pkg/infra/log/log.go index 737d48941c3..ccec1058e9f 100644 --- a/pkg/infra/log/log.go +++ b/pkg/infra/log/log.go @@ -80,6 +80,10 @@ func Warnf(format string, v ...interface{}) { Root.Warn(message) } +func Error(msg string, args ...interface{}) { + Root.Error(msg, args...) +} + func Errorf(skip int, format string, v ...interface{}) { Root.Error(fmt.Sprintf(format, v...)) } diff --git a/pkg/infra/remotecache/testing.go b/pkg/infra/remotecache/testing.go index 43cd449cb68..1333ef1ea04 100644 --- a/pkg/infra/remotecache/testing.go +++ b/pkg/infra/remotecache/testing.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" ) // NewFakeStore creates store for testing @@ -26,9 +27,7 @@ func NewFakeStore(t *testing.T) *RemoteCache { } err := dc.Init() - if err != nil { - t.Fatalf("failed to init remote cache for test. error: %v", err) - } + require.NoError(t, err, "Failed to init remote cache for test") return dc } diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index c44d7dd9a7c..d24871d31ca 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -57,7 +57,7 @@ func notAuthorized(c *models.ReqContext) { // remove any forceLogin=true params redirectTo = removeForceLoginParams(redirectTo) - WriteCookie(c.Resp, "redirect_to", url.QueryEscape(redirectTo), 0, newCookieOptions) + WriteCookie(c.Resp, "redirect_to", url.QueryEscape(redirectTo), 0, nil) c.Redirect(setting.AppSubUrl + "/login") } diff --git a/pkg/middleware/auth_proxy.go b/pkg/middleware/auth_proxy.go index d69fd6dd2de..4c5f9d22c48 100644 --- a/pkg/middleware/auth_proxy.go +++ b/pkg/middleware/auth_proxy.go @@ -5,25 +5,48 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/remotecache" - authproxy "github.com/grafana/grafana/pkg/middleware/auth_proxy" + "github.com/grafana/grafana/pkg/middleware/authproxy" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) var header = setting.AuthProxyHeaderName -func logUserIn(auth *authproxy.AuthProxy, username string, logger log.Logger, ignoreCache bool) (int64, *authproxy.Error) { +func logUserIn(auth *authproxy.AuthProxy, username string, logger log.Logger, ignoreCache bool) (int64, error) { logger.Debug("Trying to log user in", "username", username, "ignoreCache", ignoreCache) // Try to log in user via various providers - id, e := auth.Login(logger, ignoreCache) - if e != nil { - logger.Error("Failed to login", "username", username, "message", e.Error(), "error", e.DetailsError, + id, err := auth.Login(logger, ignoreCache) + if err != nil { + details := err + var e authproxy.Error + if errors.As(err, &e) { + details = e.DetailsError + } + logger.Error("Failed to login", "username", username, "message", err.Error(), "error", details, "ignoreCache", ignoreCache) - return 0, e + return 0, err } return id, nil } +// handleError calls ctx.Handle with the error message and the underlying error. +// If the error is of type authproxy.Error, its DetailsError is unwrapped and passed to ctx.Handle. +// If a callback is provided, it's called with either err.DetailsError, if err is of type +// authproxy.Error, otherwise err itself. +func handleError(ctx *models.ReqContext, err error, statusCode int, cb func(err error)) { + details := err + var e authproxy.Error + if errors.As(err, &e) { + details = e.DetailsError + } + + ctx.Handle(statusCode, err.Error(), details) + + if cb != nil { + cb(details) + } +} + func initContextWithAuthProxy(store *remotecache.RemoteCache, ctx *models.ReqContext, orgID int64) bool { username := ctx.Req.Header.Get(header) auth := authproxy.New(&authproxy.Options{ @@ -45,19 +68,16 @@ func initContextWithAuthProxy(store *remotecache.RemoteCache, ctx *models.ReqCon } // Check if allowed to continue with this IP - if result, err := auth.IsAllowedIP(); !result { - logger.Error( - "Failed to check whitelisted IP addresses", - "message", err.Error(), - "error", err.DetailsError, - ) - ctx.Handle(407, err.Error(), err.DetailsError) + if err := auth.IsAllowedIP(); err != nil { + handleError(ctx, err, 407, func(details error) { + logger.Error("Failed to check whitelisted IP addresses", "message", err.Error(), "error", details) + }) return true } - id, e := logUserIn(auth, username, logger, false) - if e != nil { - ctx.Handle(407, e.Error(), e.DetailsError) + id, err := logUserIn(auth, username, logger, false) + if err != nil { + handleError(ctx, err, 407, nil) return true } @@ -76,15 +96,16 @@ func initContextWithAuthProxy(store *remotecache.RemoteCache, ctx *models.ReqCon logger.Error("Got unexpected error when removing user from auth cache", "error", err) } } - id, e = logUserIn(auth, username, logger, true) - if e != nil { - ctx.Handle(407, e.Error(), e.DetailsError) + id, err = logUserIn(auth, username, logger, true) + if err != nil { + handleError(ctx, err, 407, nil) return true } - user, e = auth.GetSignedUser(id) - if e != nil { - ctx.Handle(407, e.Error(), e.DetailsError) + user, err = auth.GetSignedUser(id) + if err != nil { + handleError(ctx, err, 407, nil) + return true } } @@ -96,14 +117,15 @@ func initContextWithAuthProxy(store *remotecache.RemoteCache, ctx *models.ReqCon ctx.IsSignedIn = true // Remember user data in cache - if e := auth.Remember(id); e != nil { - logger.Error( - "Failed to store user in cache", - "username", username, - "message", e.Error(), - "error", e.DetailsError, - ) - ctx.Handle(500, e.Error(), e.DetailsError) + if err := auth.Remember(id); err != nil { + handleError(ctx, err, 500, func(details error) { + logger.Error( + "Failed to store user in cache", + "username", username, + "message", e.Error(), + "error", details, + ) + }) return true } diff --git a/pkg/middleware/auth_proxy/auth_proxy_test.go b/pkg/middleware/auth_proxy/auth_proxy_test.go deleted file mode 100644 index e4a66a0d830..00000000000 --- a/pkg/middleware/auth_proxy/auth_proxy_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package authproxy - -import ( - "errors" - "fmt" - "net/http" - "testing" - - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/remotecache" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/ldap" - "github.com/grafana/grafana/pkg/services/multildap" - "github.com/grafana/grafana/pkg/setting" - . "github.com/smartystreets/goconvey/convey" - "gopkg.in/macaron.v1" -) - -type TestMultiLDAP struct { - multildap.MultiLDAP - ID int64 - userCalled bool - loginCalled bool -} - -func (stub *TestMultiLDAP) Login(query *models.LoginUserQuery) ( - *models.ExternalUserInfo, error, -) { - stub.loginCalled = true - result := &models.ExternalUserInfo{ - UserId: stub.ID, - } - return result, nil -} - -func (stub *TestMultiLDAP) User(login string) ( - *models.ExternalUserInfo, - ldap.ServerConfig, - error, -) { - stub.userCalled = true - result := &models.ExternalUserInfo{ - UserId: stub.ID, - } - return result, ldap.ServerConfig{}, nil -} - -func prepareMiddleware(t *testing.T, req *http.Request, store *remotecache.RemoteCache) *AuthProxy { - t.Helper() - - ctx := &models.ReqContext{ - Context: &macaron.Context{ - Req: macaron.Request{ - Request: req, - }, - }, - } - - auth := New(&Options{ - Store: store, - Ctx: ctx, - OrgID: 4, - }) - - return auth -} - -func TestMiddlewareContext(t *testing.T) { - logger := log.New("test") - Convey("auth_proxy helper", t, func() { - req, err := http.NewRequest("POST", "http://example.com", nil) - So(err, ShouldBeNil) - setting.AuthProxyHeaderName = "X-Killa" - store := remotecache.NewFakeStore(t) - - name := "markelog" - req.Header.Add(setting.AuthProxyHeaderName, name) - - Convey("when the cache only contains the main header", func() { - Convey("with a simple cache key", func() { - // Set cache key - key := fmt.Sprintf(CachePrefix, HashCacheKey(name)) - err := store.Set(key, int64(33), 0) - So(err, ShouldBeNil) - - // Set up the middleware - auth := prepareMiddleware(t, req, store) - So(auth.getKey(), ShouldEqual, "auth-proxy-sync-ttl:0a7f3374e9659b10980fd66247b0cf2f") - - id, err := auth.Login(logger, false) - So(err, ShouldBeNil) - - So(id, ShouldEqual, 33) - }) - - Convey("when the cache key contains additional headers", func() { - setting.AuthProxyHeaders = map[string]string{"Groups": "X-WEBAUTH-GROUPS"} - group := "grafana-core-team" - req.Header.Add("X-WEBAUTH-GROUPS", group) - - key := fmt.Sprintf(CachePrefix, HashCacheKey(name+"-"+group)) - err := store.Set(key, int64(33), 0) - So(err, ShouldBeNil) - - auth := prepareMiddleware(t, req, store) - So(auth.getKey(), ShouldEqual, "auth-proxy-sync-ttl:14f69b7023baa0ac98c96b31cec07bc0") - - id, err := auth.Login(logger, false) - So(err, ShouldBeNil) - So(id, ShouldEqual, 33) - }) - }) - - Convey("LDAP", func() { - Convey("logs in via LDAP", func() { - bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { - cmd.Result = &models.User{ - Id: 42, - } - - return nil - }) - - isLDAPEnabled = func() bool { - return true - } - - stub := &TestMultiLDAP{ - ID: 42, - } - - getLDAPConfig = func() (*ldap.Config, error) { - config := &ldap.Config{ - Servers: []*ldap.ServerConfig{ - { - SearchBaseDNs: []string{"BaseDNHere"}, - }, - }, - } - return config, nil - } - - newLDAP = func(servers []*ldap.ServerConfig) multildap.IMultiLDAP { - return stub - } - - defer func() { - newLDAP = multildap.New - isLDAPEnabled = ldap.IsEnabled - getLDAPConfig = ldap.GetConfig - }() - - store := remotecache.NewFakeStore(t) - - auth := prepareMiddleware(t, req, store) - - id, err := auth.Login(logger, false) - - So(err, ShouldBeNil) - So(id, ShouldEqual, 42) - So(stub.userCalled, ShouldEqual, true) - }) - - Convey("gets nice error if ldap is enabled but not configured", func() { - isLDAPEnabled = func() bool { - return true - } - - getLDAPConfig = func() (*ldap.Config, error) { - return nil, errors.New("Something went wrong") - } - - defer func() { - newLDAP = multildap.New - isLDAPEnabled = ldap.IsEnabled - getLDAPConfig = ldap.GetConfig - }() - - store := remotecache.NewFakeStore(t) - - auth := prepareMiddleware(t, req, store) - - stub := &TestMultiLDAP{ - ID: 42, - } - - newLDAP = func(servers []*ldap.ServerConfig) multildap.IMultiLDAP { - return stub - } - - id, err := auth.Login(logger, false) - - So(err, ShouldNotBeNil) - So(err.Error(), ShouldContainSubstring, "failed to get the user") - So(id, ShouldNotEqual, 42) - So(stub.loginCalled, ShouldEqual, false) - }) - }) - }) -} diff --git a/pkg/middleware/auth_proxy_test.go b/pkg/middleware/auth_proxy_test.go index 1c678efe535..ee022d7f74c 100644 --- a/pkg/middleware/auth_proxy_test.go +++ b/pkg/middleware/auth_proxy_test.go @@ -8,7 +8,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/remotecache" - authproxy "github.com/grafana/grafana/pkg/middleware/auth_proxy" + "github.com/grafana/grafana/pkg/middleware/authproxy" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" @@ -29,7 +29,7 @@ func TestInitContextWithAuthProxy_CachedInvalidUserID(t *testing.T) { cmd.Result = &models.User{Id: userID} return nil } - getSignedUserHandler := func(cmd *models.GetSignedInUserQuery) error { + getUserHandler := func(cmd *models.GetSignedInUserQuery) error { // Simulate that the cached user ID is stale if cmd.UserId != userID { return models.ErrUserNotFound @@ -46,7 +46,7 @@ func TestInitContextWithAuthProxy_CachedInvalidUserID(t *testing.T) { origEnabled := setting.AuthProxyEnabled origHeaderProperty := setting.AuthProxyHeaderProperty bus.AddHandler("", upsertHandler) - bus.AddHandler("", getSignedUserHandler) + bus.AddHandler("", getUserHandler) t.Cleanup(func() { setting.AuthProxyHeaderName = origHeaderName setting.AuthProxyEnabled = origEnabled diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go index 6f6b243d8a9..1cf03aa4df2 100644 --- a/pkg/middleware/auth_test.go +++ b/pkg/middleware/auth_test.go @@ -7,103 +7,105 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - . "github.com/smartystreets/goconvey/convey" ) func TestMiddlewareAuth(t *testing.T) { - Convey("Given the grafana middleware", t, func() { - reqSignIn := Auth(&AuthOptions{ReqSignedIn: true}) + reqSignIn := Auth(&AuthOptions{ReqSignedIn: true}) - middlewareScenario(t, "ReqSignIn true and unauthenticated request", func(sc *scenarioContext) { - sc.m.Get("/secure", reqSignIn, sc.defaultHandler) + middlewareScenario(t, "ReqSignIn true and unauthenticated request", func(t *testing.T, sc *scenarioContext) { + sc.m.Get("/secure", reqSignIn, sc.defaultHandler) - sc.fakeReq("GET", "/secure").exec() + sc.fakeReq("GET", "/secure").exec() - Convey("Should redirect to login", func() { - So(sc.resp.Code, ShouldEqual, 302) - }) + assert.Equal(t, 302, sc.resp.Code) + }) + + middlewareScenario(t, "ReqSignIn true and unauthenticated API request", func(t *testing.T, sc *scenarioContext) { + sc.m.Get("/api/secure", reqSignIn, sc.defaultHandler) + + sc.fakeReq("GET", "/api/secure").exec() + + assert.Equal(t, 401, sc.resp.Code) + }) + + t.Run("Anonymous auth enabled", func(t *testing.T) { + const orgID int64 = 1 + + origEnabled := setting.AnonymousEnabled + t.Cleanup(func() { + setting.AnonymousEnabled = origEnabled }) - - middlewareScenario(t, "ReqSignIn true and unauthenticated API request", func(sc *scenarioContext) { - sc.m.Get("/api/secure", reqSignIn, sc.defaultHandler) - - sc.fakeReq("GET", "/api/secure").exec() - - Convey("Should return 401", func() { - So(sc.resp.Code, ShouldEqual, 401) - }) + origName := setting.AnonymousOrgName + t.Cleanup(func() { + setting.AnonymousOrgName = origName }) + setting.AnonymousEnabled = true + setting.AnonymousOrgName = "test" - Convey("Anonymous auth enabled", func() { - origEnabled := setting.AnonymousEnabled - t.Cleanup(func() { - setting.AnonymousEnabled = origEnabled - }) - origName := setting.AnonymousOrgName - t.Cleanup(func() { - setting.AnonymousOrgName = origName - }) - setting.AnonymousEnabled = true - setting.AnonymousOrgName = "test" - + middlewareScenario(t, "ReqSignIn true and request with forceLogin in query string", func( + t *testing.T, sc *scenarioContext) { bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error { - query.Result = &models.Org{Id: 1, Name: "test"} + query.Result = &models.Org{Id: orgID, Name: "test"} return nil }) - middlewareScenario(t, "ReqSignIn true and request with forceLogin in query string", func(sc *scenarioContext) { - sc.m.Get("/secure", reqSignIn, sc.defaultHandler) + sc.m.Get("/secure", reqSignIn, sc.defaultHandler) - sc.fakeReq("GET", "/secure?forceLogin=true").exec() + sc.fakeReq("GET", "/secure?forceLogin=true").exec() - Convey("Should redirect to login", func() { - So(sc.resp.Code, ShouldEqual, 302) - location, ok := sc.resp.Header()["Location"] - So(ok, ShouldBeTrue) - So(location[0], ShouldEqual, "/login") - }) - }) - - middlewareScenario(t, "ReqSignIn true and request with same org provided in query string", func(sc *scenarioContext) { - sc.m.Get("/secure", reqSignIn, sc.defaultHandler) - - sc.fakeReq("GET", "/secure?orgId=1").exec() - - Convey("Should not redirect to login", func() { - So(sc.resp.Code, ShouldEqual, 200) - }) - }) - - middlewareScenario(t, "ReqSignIn true and request with different org provided in query string", func(sc *scenarioContext) { - sc.m.Get("/secure", reqSignIn, sc.defaultHandler) - - sc.fakeReq("GET", "/secure?orgId=2").exec() - - Convey("Should redirect to login", func() { - So(sc.resp.Code, ShouldEqual, 302) - location, ok := sc.resp.Header()["Location"] - So(ok, ShouldBeTrue) - So(location[0], ShouldEqual, "/login") - }) - }) + assert.Equal(t, 302, sc.resp.Code) + location, ok := sc.resp.Header()["Location"] + assert.True(t, ok) + assert.Equal(t, "/login", location[0]) }) - Convey("snapshot public mode or signed in", func() { - middlewareScenario(t, "Snapshot public mode disabled and unauthenticated request should return 401", func(sc *scenarioContext) { - sc.m.Get("/api/snapshot", SnapshotPublicModeOrSignedIn(), sc.defaultHandler) - sc.fakeReq("GET", "/api/snapshot").exec() - So(sc.resp.Code, ShouldEqual, 401) + middlewareScenario(t, "ReqSignIn true and request with same org provided in query string", func( + t *testing.T, sc *scenarioContext) { + bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error { + query.Result = &models.Org{Id: orgID, Name: "test"} + return nil }) - middlewareScenario(t, "Snapshot public mode enabled and unauthenticated request should return 200", func(sc *scenarioContext) { - setting.SnapshotPublicMode = true - sc.m.Get("/api/snapshot", SnapshotPublicModeOrSignedIn(), sc.defaultHandler) - sc.fakeReq("GET", "/api/snapshot").exec() - So(sc.resp.Code, ShouldEqual, 200) - }) + sc.m.Get("/secure", reqSignIn, sc.defaultHandler) + + sc.fakeReq("GET", fmt.Sprintf("/secure?orgId=%d", orgID)).exec() + + assert.Equal(t, 200, sc.resp.Code) }) + + middlewareScenario(t, "ReqSignIn true and request with different org provided in query string", func( + t *testing.T, sc *scenarioContext) { + bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error { + query.Result = &models.Org{Id: orgID, Name: "test"} + return nil + }) + + sc.m.Get("/secure", reqSignIn, sc.defaultHandler) + + sc.fakeReq("GET", "/secure?orgId=2").exec() + + assert.Equal(t, 302, sc.resp.Code) + location, ok := sc.resp.Header()["Location"] + assert.True(t, ok) + assert.Equal(t, "/login", location[0]) + }) + }) + + middlewareScenario(t, "Snapshot public mode disabled and unauthenticated request should return 401", func( + t *testing.T, sc *scenarioContext) { + sc.m.Get("/api/snapshot", SnapshotPublicModeOrSignedIn(), sc.defaultHandler) + sc.fakeReq("GET", "/api/snapshot").exec() + assert.Equal(t, 401, sc.resp.Code) + }) + + middlewareScenario(t, "Snapshot public mode enabled and unauthenticated request should return 200", func( + t *testing.T, sc *scenarioContext) { + setting.SnapshotPublicMode = true + sc.m.Get("/api/snapshot", SnapshotPublicModeOrSignedIn(), sc.defaultHandler) + sc.fakeReq("GET", "/api/snapshot").exec() + assert.Equal(t, 200, sc.resp.Code) }) } diff --git a/pkg/middleware/auth_proxy/auth_proxy.go b/pkg/middleware/authproxy/auth_proxy.go similarity index 89% rename from pkg/middleware/auth_proxy/auth_proxy.go rename to pkg/middleware/authproxy/auth_proxy.go index 6df587a0579..2b80e0f8fd4 100644 --- a/pkg/middleware/auth_proxy/auth_proxy.go +++ b/pkg/middleware/authproxy/auth_proxy.go @@ -62,16 +62,16 @@ type Error struct { DetailsError error } -// newError creates the Error -func newError(message string, err error) *Error { - return &Error{ +// newError returns an Error. +func newError(message string, err error) Error { + return Error{ Message: message, DetailsError: err, } } -// Error returns a Error error string -func (err *Error) Error() string { +// Error returns the error message. +func (err Error) Error() string { return err.Message } @@ -114,11 +114,11 @@ func (auth *AuthProxy) HasHeader() bool { } // IsAllowedIP compares presented IP with the whitelist one -func (auth *AuthProxy) IsAllowedIP() (bool, *Error) { +func (auth *AuthProxy) IsAllowedIP() error { ip := auth.ctx.Req.RemoteAddr if len(strings.TrimSpace(auth.whitelistIP)) == 0 { - return true, nil + return nil } proxies := strings.Split(auth.whitelistIP, ",") @@ -126,7 +126,7 @@ func (auth *AuthProxy) IsAllowedIP() (bool, *Error) { for _, proxy := range proxies { result, err := coerceProxyAddress(proxy) if err != nil { - return false, newError("Could not get the network", err) + return newError("could not get the network", err) } proxyObjs = append(proxyObjs, result) @@ -134,21 +134,20 @@ func (auth *AuthProxy) IsAllowedIP() (bool, *Error) { sourceIP, _, err := net.SplitHostPort(ip) if err != nil { - return false, newError("could not parse address", err) + return newError("could not parse address", err) } sourceObj := net.ParseIP(sourceIP) for _, proxyObj := range proxyObjs { if proxyObj.Contains(sourceObj) { - return true, nil + return nil } } - err = fmt.Errorf( + return newError("proxy authentication required", fmt.Errorf( "request for user (%s) from %s is not from the authentication proxy", auth.header, sourceIP, - ) - return false, newError("Proxy authentication required", err) + )) } func HashCacheKey(key string) string { @@ -173,7 +172,7 @@ func (auth *AuthProxy) getKey() string { } // Login logs in user ID by whatever means possible. -func (auth *AuthProxy) Login(logger log.Logger, ignoreCache bool) (int64, *Error) { +func (auth *AuthProxy) Login(logger log.Logger, ignoreCache bool) (int64, error) { if !ignoreCache { // Error here means absent cache - we don't need to handle that id, err := auth.GetUserViaCache(logger) @@ -229,15 +228,15 @@ func (auth *AuthProxy) RemoveUserFromCache(logger log.Logger) error { } // LoginViaLDAP logs in user via LDAP request -func (auth *AuthProxy) LoginViaLDAP() (int64, *Error) { +func (auth *AuthProxy) LoginViaLDAP() (int64, error) { config, err := getLDAPConfig() if err != nil { - return 0, newError("Failed to get LDAP config", nil) + return 0, newError("failed to get LDAP config", err) } extUser, _, err := newLDAP(config.Servers).User(auth.header) if err != nil { - return 0, newError(err.Error(), nil) + return 0, err } // Have to sync grafana and LDAP user during log in @@ -246,9 +245,8 @@ func (auth *AuthProxy) LoginViaLDAP() (int64, *Error) { SignupAllowed: auth.LDAPAllowSignup, ExternalUser: extUser, } - err = bus.Dispatch(upsert) - if err != nil { - return 0, newError(err.Error(), nil) + if err := bus.Dispatch(upsert); err != nil { + return 0, err } return upsert.Result.Id, nil @@ -273,7 +271,7 @@ func (auth *AuthProxy) LoginViaHeader() (int64, error) { extUser.Email = auth.header extUser.Login = auth.header default: - return 0, newError("Auth proxy header property invalid", nil) + return 0, fmt.Errorf("auth proxy header property invalid") } auth.headersIterator(func(field string, header string) { @@ -314,21 +312,21 @@ func (auth *AuthProxy) headersIterator(fn func(field string, header string)) { } // GetSignedUser gets full signed user info. -func (auth *AuthProxy) GetSignedUser(userID int64) (*models.SignedInUser, *Error) { +func (auth *AuthProxy) GetSignedUser(userID int64) (*models.SignedInUser, error) { query := &models.GetSignedInUserQuery{ OrgId: auth.orgID, UserId: userID, } if err := bus.Dispatch(query); err != nil { - return nil, newError(err.Error(), nil) + return nil, err } return query.Result, nil } // Remember user in cache -func (auth *AuthProxy) Remember(id int64) *Error { +func (auth *AuthProxy) Remember(id int64) error { key := auth.getKey() // Check if user already in cache @@ -341,7 +339,7 @@ func (auth *AuthProxy) Remember(id int64) *Error { err := auth.store.Set(key, id, expiration) if err != nil { - return newError(err.Error(), nil) + return err } return nil diff --git a/pkg/middleware/authproxy/auth_proxy_test.go b/pkg/middleware/authproxy/auth_proxy_test.go new file mode 100644 index 00000000000..5a3604946e0 --- /dev/null +++ b/pkg/middleware/authproxy/auth_proxy_test.go @@ -0,0 +1,210 @@ +package authproxy + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/remotecache" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/ldap" + "github.com/grafana/grafana/pkg/services/multildap" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/macaron.v1" +) + +type fakeMultiLDAP struct { + multildap.MultiLDAP + ID int64 + userCalled bool + loginCalled bool +} + +func (m *fakeMultiLDAP) Login(query *models.LoginUserQuery) ( + *models.ExternalUserInfo, error, +) { + m.loginCalled = true + result := &models.ExternalUserInfo{ + UserId: m.ID, + } + return result, nil +} + +func (m *fakeMultiLDAP) User(login string) ( + *models.ExternalUserInfo, + ldap.ServerConfig, + error, +) { + m.userCalled = true + result := &models.ExternalUserInfo{ + UserId: m.ID, + } + return result, ldap.ServerConfig{}, nil +} + +func prepareMiddleware(t *testing.T, req *http.Request, store *remotecache.RemoteCache) *AuthProxy { + t.Helper() + + ctx := &models.ReqContext{ + Context: &macaron.Context{ + Req: macaron.Request{ + Request: req, + }, + }, + } + + auth := New(&Options{ + Store: store, + Ctx: ctx, + OrgID: 4, + }) + + return auth +} + +func TestMiddlewareContext(t *testing.T) { + logger := log.New("test") + req, err := http.NewRequest("POST", "http://example.com", nil) + require.NoError(t, err) + setting.AuthProxyHeaderName = "X-Killa" + store := remotecache.NewFakeStore(t) + + name := "markelog" + req.Header.Add(setting.AuthProxyHeaderName, name) + + t.Run("When the cache only contains the main header with a simple cache key", func(t *testing.T) { + const id int64 = 33 + // Set cache key + key := fmt.Sprintf(CachePrefix, HashCacheKey(name)) + err := store.Set(key, id, 0) + require.NoError(t, err) + + // Set up the middleware + auth := prepareMiddleware(t, req, store) + assert.Equal(t, "auth-proxy-sync-ttl:0a7f3374e9659b10980fd66247b0cf2f", auth.getKey()) + + gotID, err := auth.Login(logger, false) + require.NoError(t, err) + + assert.Equal(t, id, gotID) + }) + + t.Run("When the cache key contains additional headers", func(t *testing.T) { + const id int64 = 33 + setting.AuthProxyHeaders = map[string]string{"Groups": "X-WEBAUTH-GROUPS"} + group := "grafana-core-team" + req.Header.Add("X-WEBAUTH-GROUPS", group) + + key := fmt.Sprintf(CachePrefix, HashCacheKey(name+"-"+group)) + err := store.Set(key, id, 0) + require.NoError(t, err) + + auth := prepareMiddleware(t, req, store) + assert.Equal(t, "auth-proxy-sync-ttl:14f69b7023baa0ac98c96b31cec07bc0", auth.getKey()) + + gotID, err := auth.Login(logger, false) + require.NoError(t, err) + assert.Equal(t, id, gotID) + }) +} + +func TestMiddlewareContext_ldap(t *testing.T) { + logger := log.New("test") + req, err := http.NewRequest("POST", "http://example.com", nil) + require.NoError(t, err) + setting.AuthProxyHeaderName = "X-Killa" + + const headerName = "markelog" + req.Header.Add(setting.AuthProxyHeaderName, headerName) + + t.Run("Logs in via LDAP", func(t *testing.T) { + const id int64 = 42 + + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{ + Id: id, + } + + return nil + }) + + isLDAPEnabled = func() bool { + return true + } + + stub := &fakeMultiLDAP{ + ID: id, + } + + getLDAPConfig = func() (*ldap.Config, error) { + config := &ldap.Config{ + Servers: []*ldap.ServerConfig{ + { + SearchBaseDNs: []string{"BaseDNHere"}, + }, + }, + } + return config, nil + } + + newLDAP = func(servers []*ldap.ServerConfig) multildap.IMultiLDAP { + return stub + } + + defer func() { + newLDAP = multildap.New + isLDAPEnabled = ldap.IsEnabled + getLDAPConfig = ldap.GetConfig + }() + + store := remotecache.NewFakeStore(t) + + auth := prepareMiddleware(t, req, store) + + gotID, err := auth.Login(logger, false) + require.NoError(t, err) + + assert.Equal(t, id, gotID) + assert.True(t, stub.userCalled) + }) + + t.Run("Gets nice error if ldap is enabled but not configured", func(t *testing.T) { + const id int64 = 42 + isLDAPEnabled = func() bool { + return true + } + + getLDAPConfig = func() (*ldap.Config, error) { + return nil, errors.New("something went wrong") + } + + defer func() { + newLDAP = multildap.New + isLDAPEnabled = ldap.IsEnabled + getLDAPConfig = ldap.GetConfig + }() + + store := remotecache.NewFakeStore(t) + + auth := prepareMiddleware(t, req, store) + + stub := &fakeMultiLDAP{ + ID: id, + } + + newLDAP = func(servers []*ldap.ServerConfig) multildap.IMultiLDAP { + return stub + } + + gotID, err := auth.Login(logger, false) + require.EqualError(t, err, "failed to get the user") + + assert.NotEqual(t, id, gotID) + assert.False(t, stub.loginCalled) + }) +} diff --git a/pkg/middleware/cookie.go b/pkg/middleware/cookie.go index c81cb0dfa6c..78939632dbc 100644 --- a/pkg/middleware/cookie.go +++ b/pkg/middleware/cookie.go @@ -2,7 +2,10 @@ package middleware import ( "net/http" + "net/url" + "time" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) @@ -26,14 +29,18 @@ func newCookieOptions() CookieOptions { } } -type GetCookieOptionsFunc func() CookieOptions +type getCookieOptionsFunc func() CookieOptions -func DeleteCookie(w http.ResponseWriter, name string, getCookieOptionsFunc GetCookieOptionsFunc) { - WriteCookie(w, name, "", -1, getCookieOptionsFunc) +func DeleteCookie(w http.ResponseWriter, name string, getCookieOptions getCookieOptionsFunc) { + WriteCookie(w, name, "", -1, getCookieOptions) } -func WriteCookie(w http.ResponseWriter, name string, value string, maxAge int, getCookieOptionsFunc GetCookieOptionsFunc) { - options := getCookieOptionsFunc() +func WriteCookie(w http.ResponseWriter, name string, value string, maxAge int, getCookieOptions getCookieOptionsFunc) { + if getCookieOptions == nil { + getCookieOptions = newCookieOptions + } + + options := getCookieOptions() cookie := http.Cookie{ Name: name, MaxAge: maxAge, @@ -47,3 +54,18 @@ func WriteCookie(w http.ResponseWriter, name string, value string, maxAge int, g } http.SetCookie(w, &cookie) } + +func WriteSessionCookie(ctx *models.ReqContext, value string, maxLifetime time.Duration) { + if setting.Env == setting.Dev { + ctx.Logger.Info("New token", "unhashed token", value) + } + + var maxAge int + if maxLifetime <= 0 { + maxAge = -1 + } else { + maxAge = int(maxLifetime.Seconds()) + } + + WriteCookie(ctx.Resp, setting.LoginCookieName, url.QueryEscape(value), maxAge, nil) +} diff --git a/pkg/middleware/dashboard_redirect_test.go b/pkg/middleware/dashboard_redirect_test.go index 5730bf15d14..30a9d27fe4c 100644 --- a/pkg/middleware/dashboard_redirect_test.go +++ b/pkg/middleware/dashboard_redirect_test.go @@ -8,6 +8,8 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/util" . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMiddlewareDashboardRedirect(t *testing.T) { @@ -22,62 +24,57 @@ func TestMiddlewareDashboardRedirect(t *testing.T) { fakeDash.HasAcl = false fakeDash.Uid = util.GenerateShortUID() - bus.AddHandler("test", func(query *models.GetDashboardQuery) error { - query.Result = fakeDash - return nil - }) + middlewareScenario(t, "GET dashboard by legacy url", func(t *testing.T, sc *scenarioContext) { + bus.AddHandler("test", func(query *models.GetDashboardQuery) error { + query.Result = fakeDash + return nil + }) - middlewareScenario(t, "GET dashboard by legacy url", func(sc *scenarioContext) { sc.m.Get("/dashboard/db/:slug", redirectFromLegacyDashboardUrl, sc.defaultHandler) sc.fakeReqWithParams("GET", "/dashboard/db/dash?orgId=1&panelId=2", map[string]string{}).exec() - Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() { - So(sc.resp.Code, ShouldEqual, 301) - resp := sc.resp.Result() - defer resp.Body.Close() - redirectURL, err := resp.Location() - So(err, ShouldBeNil) - So(redirectURL.Path, ShouldEqual, models.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)) - So(len(redirectURL.Query()), ShouldEqual, 2) - }) + assert.Equal(t, 301, sc.resp.Code) + resp := sc.resp.Result() + resp.Body.Close() + redirectURL, err := resp.Location() + require.NoError(t, err) + assert.Equal(t, models.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug), redirectURL.Path) + assert.Equal(t, 2, len(redirectURL.Query())) }) - middlewareScenario(t, "GET dashboard solo by legacy url", func(sc *scenarioContext) { + middlewareScenario(t, "GET dashboard solo by legacy url", func(t *testing.T, sc *scenarioContext) { + bus.AddHandler("test", func(query *models.GetDashboardQuery) error { + query.Result = fakeDash + return nil + }) + sc.m.Get("/dashboard-solo/db/:slug", redirectFromLegacyDashboardSoloUrl, sc.defaultHandler) sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash?orgId=1&panelId=2", map[string]string{}).exec() - Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() { - So(sc.resp.Code, ShouldEqual, 301) - resp := sc.resp.Result() - defer resp.Body.Close() - redirectURL, err := resp.Location() - So(err, ShouldBeNil) - expectedURL := models.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug) - expectedURL = strings.Replace(expectedURL, "/d/", "/d-solo/", 1) - So(redirectURL.Path, ShouldEqual, expectedURL) - So(len(redirectURL.Query()), ShouldEqual, 2) - }) + assert.Equal(t, 301, sc.resp.Code) + resp := sc.resp.Result() + resp.Body.Close() + redirectURL, err := resp.Location() + require.NoError(t, err) + expectedURL := models.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug) + expectedURL = strings.Replace(expectedURL, "/d/", "/d-solo/", 1) + assert.Equal(t, expectedURL, redirectURL.Path) + assert.Equal(t, 2, len(redirectURL.Query())) }) }) - Convey("Given the dashboard legacy edit panel middleware", t, func() { - bus.ClearBusHandlers() + middlewareScenario(t, "GET dashboard by legacy edit url", func(t *testing.T, sc *scenarioContext) { + sc.m.Get("/d/:uid/:slug", RedirectFromLegacyPanelEditURL(), sc.defaultHandler) - middlewareScenario(t, "GET dashboard by legacy edit url", func(sc *scenarioContext) { - sc.m.Get("/d/:uid/:slug", RedirectFromLegacyPanelEditURL(), sc.defaultHandler) + sc.fakeReqWithParams("GET", "/d/asd/dash?orgId=1&panelId=12&fullscreen&edit", map[string]string{}).exec() - sc.fakeReqWithParams("GET", "/d/asd/dash?orgId=1&panelId=12&fullscreen&edit", map[string]string{}).exec() - - Convey("Should redirect to new dashboard edit url with a 301 Moved Permanently", func() { - So(sc.resp.Code, ShouldEqual, 301) - resp := sc.resp.Result() - defer resp.Body.Close() - redirectURL, err := resp.Location() - So(err, ShouldBeNil) - So(redirectURL.String(), ShouldEqual, "/d/asd/d/asd/dash?editPanel=12&orgId=1") - }) - }) + assert.Equal(t, 301, sc.resp.Code) + resp := sc.resp.Result() + resp.Body.Close() + redirectURL, err := resp.Location() + require.NoError(t, err) + assert.Equal(t, "/d/asd/d/asd/dash?editPanel=12&orgId=1", redirectURL.String()) }) } diff --git a/pkg/middleware/logger.go b/pkg/middleware/logger.go index c698d511802..94de372fee4 100644 --- a/pkg/middleware/logger.go +++ b/pkg/middleware/logger.go @@ -16,15 +16,13 @@ package middleware import ( - "context" "net/http" "time" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" - opentracing "github.com/opentracing/opentracing-go" "github.com/prometheus/client_golang/prometheus" - "github.com/uber/jaeger-client-go" + cw "github.com/weaveworks/common/middleware" "gopkg.in/macaron.v1" ) @@ -63,7 +61,7 @@ func Logger() macaron.Handler { "referer", req.Referer(), } - traceID, exist := extractTraceID(ctxTyped.Req.Request.Context()) + traceID, exist := cw.ExtractTraceID(ctxTyped.Req.Request.Context()) if exist { logParams = append(logParams, "traceID", traceID) } @@ -76,16 +74,3 @@ func Logger() macaron.Handler { } } } - -func extractTraceID(ctx context.Context) (string, bool) { - sp := opentracing.SpanFromContext(ctx) - if sp == nil { - return "", false - } - sctx, ok := sp.Context().(jaeger.SpanContext) - if !ok { - return "", false - } - - return sctx.TraceID().String(), true -} diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index ac82532e52b..00531f22823 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/url" "strconv" "strings" "time" @@ -55,10 +54,13 @@ func GetContextHandler( Logger: log.New("context"), } - orgId := int64(0) - orgIdHeader := ctx.Req.Header.Get("X-Grafana-Org-Id") - if orgIdHeader != "" { - orgId, _ = strconv.ParseInt(orgIdHeader, 10, 64) + orgID := int64(0) + orgIDHeader := ctx.Req.Header.Get("X-Grafana-Org-Id") + if orgIDHeader != "" { + orgIDParsed, err := strconv.ParseInt(orgIDHeader, 10, 64) + if err == nil { + orgID = orgIDParsed + } } // the order in which these are tested are important @@ -69,9 +71,9 @@ func GetContextHandler( switch { case initContextWithRenderAuth(ctx, renderService): case initContextWithApiKey(ctx): - case initContextWithBasicAuth(ctx, orgId): - case initContextWithAuthProxy(remoteCache, ctx, orgId): - case initContextWithToken(ats, ctx, orgId): + case initContextWithBasicAuth(ctx, orgID): + case initContextWithAuthProxy(remoteCache, ctx, orgID): + case initContextWithToken(ats, ctx, orgID): case initContextWithAnonymousUser(ctx): } @@ -274,21 +276,6 @@ func rotateEndOfRequestFunc(ctx *models.ReqContext, authTokenService models.User } } -func WriteSessionCookie(ctx *models.ReqContext, value string, maxLifetime time.Duration) { - if setting.Env == setting.Dev { - ctx.Logger.Info("New token", "unhashed token", value) - } - - var maxAge int - if maxLifetime <= 0 { - maxAge = -1 - } else { - maxAge = int(maxLifetime.Seconds()) - } - - WriteCookie(ctx.Resp, setting.LoginCookieName, url.QueryEscape(value), maxAge, newCookieOptions) -} - func AddDefaultResponseHeaders() macaron.Handler { return func(ctx *macaron.Context) { ctx.Resp.Before(func(w macaron.ResponseWriter) { diff --git a/pkg/middleware/middleware_basic_auth_test.go b/pkg/middleware/middleware_basic_auth_test.go index 1535af0f5ea..447d715dd79 100644 --- a/pkg/middleware/middleware_basic_auth_test.go +++ b/pkg/middleware/middleware_basic_auth_test.go @@ -4,149 +4,136 @@ import ( "encoding/json" "testing" - . "github.com/smartystreets/goconvey/convey" - "github.com/grafana/grafana/pkg/bus" - authLogin "github.com/grafana/grafana/pkg/login" + "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMiddlewareBasicAuth(t *testing.T) { - Convey("Given the basic auth", t, func() { - var oldBasicAuthEnabled = setting.BasicAuthEnabled - var oldDisableBruteForceLoginProtection = setting.DisableBruteForceLoginProtection - var id int64 = 12 + var origBasicAuthEnabled = setting.BasicAuthEnabled + var origDisableBruteForceLoginProtection = setting.DisableBruteForceLoginProtection + t.Cleanup(func() { + setting.BasicAuthEnabled = origBasicAuthEnabled + setting.DisableBruteForceLoginProtection = origDisableBruteForceLoginProtection + }) + setting.BasicAuthEnabled = true + setting.DisableBruteForceLoginProtection = true - Convey("Setup", func() { - setting.BasicAuthEnabled = true - setting.DisableBruteForceLoginProtection = true - bus.ClearBusHandlers() + bus.ClearBusHandlers() + + const id int64 = 12 + + middlewareScenario(t, "Valid API key", func(t *testing.T, sc *scenarioContext) { + const orgID int64 = 2 + keyhash, err := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") + require.NoError(t, err) + + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + query.Result = &models.ApiKey{OrgId: orgID, Role: models.ROLE_EDITOR, Key: keyhash} + return nil }) - middlewareScenario(t, "Valid API key", func(sc *scenarioContext) { - var orgID int64 = 2 - keyhash, err := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") - So(err, ShouldBeNil) + authHeader := util.GetBasicAuthHeader("api_key", "eyJrIjoidjVuQXdwTWFmRlA2em5hUzR1cmhkV0RMUzU1MTFNNDIiLCJuIjoiYXNkIiwiaWQiOjF9") + sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec() - bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { - query.Result = &models.ApiKey{OrgId: orgID, Role: models.ROLE_EDITOR, Key: keyhash} - return nil - }) + assert.Equal(t, 200, sc.resp.Code) + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, orgID, sc.context.OrgId) + assert.Equal(t, models.ROLE_EDITOR, sc.context.OrgRole) + }) - authHeader := util.GetBasicAuthHeader("api_key", "eyJrIjoidjVuQXdwTWFmRlA2em5hUzR1cmhkV0RMUzU1MTFNNDIiLCJuIjoiYXNkIiwiaWQiOjF9") - sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec() + middlewareScenario(t, "Handle auth", func(t *testing.T, sc *scenarioContext) { + const password = "MyPass" + const salt = "Salt" + const orgID int64 = 2 - Convey("Should return 200", func() { - So(sc.resp.Code, ShouldEqual, 200) - }) + t.Cleanup(bus.ClearBusHandlers) - Convey("Should init middleware context", func() { - So(sc.context.IsSignedIn, ShouldEqual, true) - So(sc.context.OrgId, ShouldEqual, orgID) - So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR) - }) + bus.AddHandler("grafana-auth", func(query *models.LoginUserQuery) error { + encoded, err := util.EncodePassword(password, salt) + if err != nil { + return err + } + query.User = &models.User{ + Password: encoded, + Salt: salt, + } + return nil }) - middlewareScenario(t, "Handle auth", func(sc *scenarioContext) { - var password = "MyPass" - var salt = "Salt" - var orgID int64 = 2 - - bus.AddHandler("grafana-auth", func(query *models.LoginUserQuery) error { - encoded, err := util.EncodePassword(password, salt) - if err != nil { - return err - } - query.User = &models.User{ - Password: encoded, - Salt: salt, - } - return nil - }) - - bus.AddHandler("get-sign-user", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: orgID, UserId: id} - return nil - }) - - authHeader := util.GetBasicAuthHeader("myUser", password) - sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec() - - Convey("Should init middleware context with users", func() { - So(sc.context.IsSignedIn, ShouldEqual, true) - So(sc.context.OrgId, ShouldEqual, orgID) - So(sc.context.UserId, ShouldEqual, id) - }) - - bus.ClearBusHandlers() + bus.AddHandler("get-sign-user", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: orgID, UserId: id} + return nil }) - middlewareScenario(t, "Auth sequence", func(sc *scenarioContext) { - var password = "MyPass" - var salt = "Salt" + authHeader := util.GetBasicAuthHeader("myUser", password) + sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec() - authLogin.Init() + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, orgID, sc.context.OrgId) + assert.Equal(t, id, sc.context.UserId) + }) - bus.AddHandler("user-query", func(query *models.GetUserByLoginQuery) error { - encoded, err := util.EncodePassword(password, salt) - if err != nil { - return err - } - query.Result = &models.User{ - Password: encoded, - Id: id, - Salt: salt, - } - return nil - }) + middlewareScenario(t, "Auth sequence", func(t *testing.T, sc *scenarioContext) { + const password = "MyPass" + const salt = "Salt" - bus.AddHandler("get-sign-user", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{UserId: query.UserId} - return nil - }) + login.Init() - authHeader := util.GetBasicAuthHeader("myUser", password) - sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec() - - Convey("Should init middleware context with user", func() { - So(sc.context.IsSignedIn, ShouldEqual, true) - So(sc.context.UserId, ShouldEqual, id) - }) + bus.AddHandler("user-query", func(query *models.GetUserByLoginQuery) error { + encoded, err := util.EncodePassword(password, salt) + if err != nil { + return err + } + query.Result = &models.User{ + Password: encoded, + Id: id, + Salt: salt, + } + return nil }) - middlewareScenario(t, "Should return error if user is not found", func(sc *scenarioContext) { - sc.fakeReq("GET", "/") - sc.req.SetBasicAuth("user", "password") - sc.exec() - - err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson) - So(err, ShouldNotBeNil) - - So(sc.resp.Code, ShouldEqual, 401) - So(sc.respJson["message"], ShouldEqual, errStringInvalidUsernamePassword) + bus.AddHandler("get-sign-user", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{UserId: query.UserId} + return nil }) - middlewareScenario(t, "Should return error if user & password do not match", func(sc *scenarioContext) { - bus.AddHandler("user-query", func(loginUserQuery *models.GetUserByLoginQuery) error { - return nil - }) + authHeader := util.GetBasicAuthHeader("myUser", password) + sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec() - sc.fakeReq("GET", "/") - sc.req.SetBasicAuth("killa", "gorilla") - sc.exec() + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, id, sc.context.UserId) + }) - err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson) - So(err, ShouldNotBeNil) + middlewareScenario(t, "Should return error if user is not found", func(t *testing.T, sc *scenarioContext) { + sc.fakeReq("GET", "/") + sc.req.SetBasicAuth("user", "password") + sc.exec() - So(sc.resp.Code, ShouldEqual, 401) - So(sc.respJson["message"], ShouldEqual, errStringInvalidUsernamePassword) + err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson) + require.Error(t, err) + + assert.Equal(t, 401, sc.resp.Code) + assert.Equal(t, errStringInvalidUsernamePassword, sc.respJson["message"]) + }) + + middlewareScenario(t, "Should return error if user & password do not match", func(t *testing.T, sc *scenarioContext) { + bus.AddHandler("user-query", func(loginUserQuery *models.GetUserByLoginQuery) error { + return nil }) - Convey("Destroy", func() { - setting.BasicAuthEnabled = oldBasicAuthEnabled - setting.DisableBruteForceLoginProtection = oldDisableBruteForceLoginProtection - }) + sc.fakeReq("GET", "/") + sc.req.SetBasicAuth("killa", "gorilla") + sc.exec() + + err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson) + require.Error(t, err) + + assert.Equal(t, 401, sc.resp.Code) + assert.Equal(t, errStringInvalidUsernamePassword, sc.respJson["message"]) }) } diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 7a996f82c3c..b4dfde543b9 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -11,7 +11,6 @@ import ( "testing" "time" - . "github.com/smartystreets/goconvey/convey" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/macaron.v1" @@ -21,7 +20,7 @@ import ( "github.com/grafana/grafana/pkg/components/gtime" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/remotecache" - authproxy "github.com/grafana/grafana/pkg/middleware/auth_proxy" + "github.com/grafana/grafana/pkg/middleware/authproxy" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/login" @@ -45,226 +44,250 @@ func resetGetTime() { } func TestMiddleWareSecurityHeaders(t *testing.T) { + origErrTemplateName := setting.ErrTemplateName + t.Cleanup(func() { + setting.ErrTemplateName = origErrTemplateName + }) setting.ErrTemplateName = errorTemplate - Convey("Given the grafana middleware", t, func() { - middlewareScenario(t, "middleware should get correct x-xss-protection header", func(sc *scenarioContext) { - setting.XSSProtectionHeader = true - sc.fakeReq("GET", "/api/").exec() - So(sc.resp.Header().Get("X-XSS-Protection"), ShouldEqual, "1; mode=block") + middlewareScenario(t, "middleware should get correct x-xss-protection header", func(t *testing.T, sc *scenarioContext) { + origXSSProtectionHeader := setting.XSSProtectionHeader + t.Cleanup(func() { + setting.XSSProtectionHeader = origXSSProtectionHeader }) + setting.XSSProtectionHeader = true + sc.fakeReq("GET", "/api/").exec() + assert.Equal(t, "1; mode=block", sc.resp.Header().Get("X-XSS-Protection")) + }) - middlewareScenario(t, "middleware should not get x-xss-protection when disabled", func(sc *scenarioContext) { - setting.XSSProtectionHeader = false - sc.fakeReq("GET", "/api/").exec() - So(sc.resp.Header().Get("X-XSS-Protection"), ShouldBeEmpty) + middlewareScenario(t, "middleware should not get x-xss-protection when disabled", func(t *testing.T, sc *scenarioContext) { + origXSSProtectionHeader := setting.XSSProtectionHeader + t.Cleanup(func() { + setting.XSSProtectionHeader = origXSSProtectionHeader }) + setting.XSSProtectionHeader = false + sc.fakeReq("GET", "/api/").exec() + assert.Empty(t, sc.resp.Header().Get("X-XSS-Protection")) + }) - middlewareScenario(t, "middleware should add correct Strict-Transport-Security header", func(sc *scenarioContext) { - setting.StrictTransportSecurity = true - setting.Protocol = setting.HTTPSScheme - setting.StrictTransportSecurityMaxAge = 64000 - sc.fakeReq("GET", "/api/").exec() - So(sc.resp.Header().Get("Strict-Transport-Security"), ShouldEqual, "max-age=64000") - setting.StrictTransportSecurityPreload = true - sc.fakeReq("GET", "/api/").exec() - So(sc.resp.Header().Get("Strict-Transport-Security"), ShouldEqual, "max-age=64000; preload") - setting.StrictTransportSecuritySubDomains = true - sc.fakeReq("GET", "/api/").exec() - So(sc.resp.Header().Get("Strict-Transport-Security"), ShouldEqual, "max-age=64000; preload; includeSubDomains") + middlewareScenario(t, "middleware should add correct Strict-Transport-Security header", func(t *testing.T, sc *scenarioContext) { + origStrictTransportSecurity := setting.StrictTransportSecurity + origProtocol := setting.Protocol + origStrictTransportSecurityMaxAge := setting.StrictTransportSecurityMaxAge + t.Cleanup(func() { + setting.StrictTransportSecurity = origStrictTransportSecurity + setting.Protocol = origProtocol + setting.StrictTransportSecurityMaxAge = origStrictTransportSecurityMaxAge }) + setting.StrictTransportSecurity = true + setting.Protocol = setting.HTTPSScheme + setting.StrictTransportSecurityMaxAge = 64000 + + sc.fakeReq("GET", "/api/").exec() + assert.Equal(t, "max-age=64000", sc.resp.Header().Get("Strict-Transport-Security")) + setting.StrictTransportSecurityPreload = true + sc.fakeReq("GET", "/api/").exec() + assert.Equal(t, "max-age=64000; preload", sc.resp.Header().Get("Strict-Transport-Security")) + setting.StrictTransportSecuritySubDomains = true + sc.fakeReq("GET", "/api/").exec() + assert.Equal(t, "max-age=64000; preload; includeSubDomains", sc.resp.Header().Get("Strict-Transport-Security")) }) } func TestMiddlewareContext(t *testing.T) { + origErrTemplateName := setting.ErrTemplateName + t.Cleanup(func() { + setting.ErrTemplateName = origErrTemplateName + }) setting.ErrTemplateName = errorTemplate - Convey("Given the grafana middleware", t, func() { - middlewareScenario(t, "middleware should add context to injector", func(sc *scenarioContext) { - sc.fakeReq("GET", "/").exec() - So(sc.context, ShouldNotBeNil) - }) + middlewareScenario(t, "middleware should add context to injector", func(t *testing.T, sc *scenarioContext) { + sc.fakeReq("GET", "/").exec() + assert.NotNil(t, sc.context) + }) - middlewareScenario(t, "Default middleware should allow get request", func(sc *scenarioContext) { - sc.fakeReq("GET", "/").exec() - So(sc.resp.Code, ShouldEqual, 200) - }) + middlewareScenario(t, "Default middleware should allow get request", func(t *testing.T, sc *scenarioContext) { + sc.fakeReq("GET", "/").exec() + assert.Equal(t, 200, sc.resp.Code) + }) - middlewareScenario(t, "middleware should add Cache-Control header for requests to API", func(sc *scenarioContext) { - sc.fakeReq("GET", "/api/search").exec() - So(sc.resp.Header().Get("Cache-Control"), ShouldEqual, "no-cache") - So(sc.resp.Header().Get("Pragma"), ShouldEqual, "no-cache") - So(sc.resp.Header().Get("Expires"), ShouldEqual, "-1") - }) + middlewareScenario(t, "middleware should add Cache-Control header for requests to API", func(t *testing.T, sc *scenarioContext) { + sc.fakeReq("GET", "/api/search").exec() + assert.Equal(t, "no-cache", sc.resp.Header().Get("Cache-Control")) + assert.Equal(t, "no-cache", sc.resp.Header().Get("Pragma")) + assert.Equal(t, "-1", sc.resp.Header().Get("Expires")) + }) - middlewareScenario(t, "middleware should not add Cache-Control header for requests to datasource proxy API", func(sc *scenarioContext) { - sc.fakeReq("GET", "/api/datasources/proxy/1/test").exec() - So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty) - So(sc.resp.Header().Get("Pragma"), ShouldBeEmpty) - So(sc.resp.Header().Get("Expires"), ShouldBeEmpty) - }) + middlewareScenario(t, "middleware should not add Cache-Control header for requests to datasource proxy API", func( + t *testing.T, sc *scenarioContext) { + sc.fakeReq("GET", "/api/datasources/proxy/1/test").exec() + assert.Empty(t, sc.resp.Header().Get("Cache-Control")) + assert.Empty(t, sc.resp.Header().Get("Pragma")) + assert.Empty(t, sc.resp.Header().Get("Expires")) + }) - middlewareScenario(t, "middleware should add Cache-Control header for requests with html response", func(sc *scenarioContext) { - sc.handler(func(c *models.ReqContext) { - data := &dtos.IndexViewData{ - User: &dtos.CurrentUser{}, - Settings: map[string]interface{}{}, - NavTree: []*dtos.NavLink{}, - } - c.HTML(200, "index-template", data) - }) - sc.fakeReq("GET", "/").exec() - So(sc.resp.Code, ShouldEqual, 200) - So(sc.resp.Header().Get("Cache-Control"), ShouldEqual, "no-cache") - So(sc.resp.Header().Get("Pragma"), ShouldEqual, "no-cache") - So(sc.resp.Header().Get("Expires"), ShouldEqual, "-1") - }) - - middlewareScenario(t, "middleware should add X-Frame-Options header with deny for request when not allowing embedding", func(sc *scenarioContext) { - sc.fakeReq("GET", "/api/search").exec() - So(sc.resp.Header().Get("X-Frame-Options"), ShouldEqual, "deny") - }) - - middlewareScenario(t, "middleware should not add X-Frame-Options header for request when allowing embedding", func(sc *scenarioContext) { - setting.AllowEmbedding = true - sc.fakeReq("GET", "/api/search").exec() - So(sc.resp.Header().Get("X-Frame-Options"), ShouldBeEmpty) - }) - - middlewareScenario(t, "Invalid api key", func(sc *scenarioContext) { - sc.apiKey = "invalid_key_test" - sc.fakeReq("GET", "/").exec() - - Convey("Should not init session", func() { - So(sc.resp.Header().Get("Set-Cookie"), ShouldBeEmpty) - }) - - Convey("Should return 401", func() { - So(sc.resp.Code, ShouldEqual, 401) - So(sc.respJson["message"], ShouldEqual, errStringInvalidAPIKey) - }) - }) - - middlewareScenario(t, "Valid api key", func(sc *scenarioContext) { - keyhash, err := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") - So(err, ShouldBeNil) - - bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { - query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash} - return nil - }) - - sc.fakeReq("GET", "/").withValidApiKey().exec() - - Convey("Should return 200", func() { - So(sc.resp.Code, ShouldEqual, 200) - }) - - Convey("Should init middleware context", func() { - So(sc.context.IsSignedIn, ShouldEqual, true) - So(sc.context.OrgId, ShouldEqual, 12) - So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR) - }) - }) - - middlewareScenario(t, "Valid api key, but does not match db hash", func(sc *scenarioContext) { - keyhash := "Something_not_matching" - - bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { - query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash} - return nil - }) - - sc.fakeReq("GET", "/").withValidApiKey().exec() - - Convey("Should return api key invalid", func() { - So(sc.resp.Code, ShouldEqual, 401) - So(sc.respJson["message"], ShouldEqual, errStringInvalidAPIKey) - }) - }) - - middlewareScenario(t, "Valid api key, but expired", func(sc *scenarioContext) { - mockGetTime() - defer resetGetTime() - - keyhash, err := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") - So(err, ShouldBeNil) - - bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { - // api key expired one second before - expires := getTime().Add(-1 * time.Second).Unix() - query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash, - Expires: &expires} - return nil - }) - - sc.fakeReq("GET", "/").withValidApiKey().exec() - - Convey("Should return 401", func() { - So(sc.resp.Code, ShouldEqual, 401) - So(sc.respJson["message"], ShouldEqual, "Expired API key") - }) - }) - - middlewareScenario(t, "Non-expired auth token in cookie which not are being rotated", func(sc *scenarioContext) { - sc.withTokenSessionCookie("token") - - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} - return nil - }) - - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ - UserId: 12, - UnhashedToken: unhashedToken, - }, nil + middlewareScenario(t, "middleware should add Cache-Control header for requests with html response", func( + t *testing.T, sc *scenarioContext) { + sc.handler(func(c *models.ReqContext) { + data := &dtos.IndexViewData{ + User: &dtos.CurrentUser{}, + Settings: map[string]interface{}{}, + NavTree: []*dtos.NavLink{}, } + c.HTML(200, "index-template", data) + }) + sc.fakeReq("GET", "/").exec() + assert.Equal(t, 200, sc.resp.Code) + assert.Equal(t, "no-cache", sc.resp.Header().Get("Cache-Control")) + assert.Equal(t, "no-cache", sc.resp.Header().Get("Pragma")) + assert.Equal(t, "-1", sc.resp.Header().Get("Expires")) + }) - sc.fakeReq("GET", "/").exec() + middlewareScenario(t, "middleware should add X-Frame-Options header with deny for request when not allowing embedding", func( + t *testing.T, sc *scenarioContext) { + sc.fakeReq("GET", "/api/search").exec() + assert.Equal(t, "deny", sc.resp.Header().Get("X-Frame-Options")) + }) - Convey("Should init context with user info", func() { - So(sc.context.IsSignedIn, ShouldBeTrue) - So(sc.context.UserId, ShouldEqual, 12) - So(sc.context.UserToken.UserId, ShouldEqual, 12) - So(sc.context.UserToken.UnhashedToken, ShouldEqual, "token") - }) + middlewareScenario(t, "middleware should not add X-Frame-Options header for request when allowing embedding", func( + t *testing.T, sc *scenarioContext) { + origAllowEmbedding := setting.AllowEmbedding + t.Cleanup(func() { + setting.AllowEmbedding = origAllowEmbedding + }) + setting.AllowEmbedding = true + sc.fakeReq("GET", "/api/search").exec() + assert.Empty(t, sc.resp.Header().Get("X-Frame-Options")) + }) - Convey("Should not set cookie", func() { - So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, "") - }) + middlewareScenario(t, "Invalid api key", func(t *testing.T, sc *scenarioContext) { + sc.apiKey = "invalid_key_test" + sc.fakeReq("GET", "/").exec() + + assert.Empty(t, sc.resp.Header().Get("Set-Cookie")) + assert.Equal(t, 401, sc.resp.Code) + assert.Equal(t, errStringInvalidAPIKey, sc.respJson["message"]) + }) + + middlewareScenario(t, "Valid api key", func(t *testing.T, sc *scenarioContext) { + const orgID int64 = 12 + keyhash, err := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") + require.NoError(t, err) + + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + query.Result = &models.ApiKey{OrgId: orgID, Role: models.ROLE_EDITOR, Key: keyhash} + return nil }) - middlewareScenario(t, "Non-expired auth token in cookie which are being rotated", func(sc *scenarioContext) { - sc.withTokenSessionCookie("token") + sc.fakeReq("GET", "/").withValidApiKey().exec() - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} - return nil - }) + assert.Equal(t, 200, sc.resp.Code) - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ - UserId: 12, - UnhashedToken: "", - }, nil - } + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, orgID, sc.context.OrgId) + assert.Equal(t, models.ROLE_EDITOR, sc.context.OrgRole) + }) - sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *models.UserToken, - clientIP net.IP, userAgent string) (bool, error) { - userToken.UnhashedToken = "rotated" - return true, nil - } + middlewareScenario(t, "Valid api key, but does not match db hash", func(t *testing.T, sc *scenarioContext) { + keyhash := "Something_not_matching" - maxAge := int(setting.LoginMaxLifetime.Seconds()) + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash} + return nil + }) + + sc.fakeReq("GET", "/").withValidApiKey().exec() + + assert.Equal(t, 401, sc.resp.Code) + assert.Equal(t, errStringInvalidAPIKey, sc.respJson["message"]) + }) + + middlewareScenario(t, "Valid api key, but expired", func(t *testing.T, sc *scenarioContext) { + mockGetTime() + defer resetGetTime() + + keyhash, err := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") + require.NoError(t, err) + + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + // api key expired one second before + expires := getTime().Add(-1 * time.Second).Unix() + query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash, + Expires: &expires} + return nil + }) + + sc.fakeReq("GET", "/").withValidApiKey().exec() + + assert.Equal(t, 401, sc.resp.Code) + assert.Equal(t, "Expired API key", sc.respJson["message"]) + }) + + middlewareScenario(t, "Non-expired auth token in cookie which not are being rotated", func( + t *testing.T, sc *scenarioContext) { + const userID int64 = 12 + + sc.withTokenSessionCookie("token") + + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 2, UserId: userID} + return nil + }) + + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ + UserId: userID, + UnhashedToken: unhashedToken, + }, nil + } + + sc.fakeReq("GET", "/").exec() + + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, userID, sc.context.UserId) + assert.Equal(t, userID, sc.context.UserToken.UserId) + assert.Equal(t, "token", sc.context.UserToken.UnhashedToken) + assert.Equal(t, "", sc.resp.Header().Get("Set-Cookie")) + }) + + middlewareScenario(t, "Non-expired auth token in cookie which are being rotated", func(t *testing.T, sc *scenarioContext) { + const userID int64 = 12 + + sc.withTokenSessionCookie("token") + + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 2, UserId: userID} + return nil + }) + + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ + UserId: userID, + UnhashedToken: "", + }, nil + } + + sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *models.UserToken, + clientIP net.IP, userAgent string) (bool, error) { + userToken.UnhashedToken = "rotated" + return true, nil + } + + maxAge := int(setting.LoginMaxLifetime.Seconds()) + + sameSiteModes := []http.SameSite{ + http.SameSiteNoneMode, + http.SameSiteLaxMode, + http.SameSiteStrictMode, + } + for _, sameSiteMode := range sameSiteModes { + t.Run(fmt.Sprintf("Same site mode %d", sameSiteMode), func(t *testing.T) { + origCookieSameSiteMode := setting.CookieSameSiteMode + t.Cleanup(func() { + setting.CookieSameSiteMode = origCookieSameSiteMode + }) + setting.CookieSameSiteMode = sameSiteMode - sameSitePolicies := []http.SameSite{ - http.SameSiteNoneMode, - http.SameSiteLaxMode, - http.SameSiteStrictMode, - } - for _, sameSitePolicy := range sameSitePolicies { - setting.CookieSameSiteMode = sameSitePolicy expectedCookiePath := "/" if len(setting.AppSubUrl) > 0 { expectedCookiePath = setting.AppSubUrl @@ -276,283 +299,334 @@ func TestMiddlewareContext(t *testing.T) { HttpOnly: true, MaxAge: maxAge, Secure: setting.CookieSecure, - SameSite: sameSitePolicy, + SameSite: sameSiteMode, } sc.fakeReq("GET", "/").exec() - Convey(fmt.Sprintf("Should init context with user info and setting.SameSite=%v", sameSitePolicy), func() { - So(sc.context.IsSignedIn, ShouldBeTrue) - So(sc.context.UserId, ShouldEqual, 12) - So(sc.context.UserToken.UserId, ShouldEqual, 12) - So(sc.context.UserToken.UnhashedToken, ShouldEqual, "rotated") - }) - - Convey(fmt.Sprintf("Should set cookie with setting.SameSite=%v", sameSitePolicy), func() { - So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, expectedCookie.String()) - }) - } - - Convey("Should not set cookie with SameSite attribute when setting.CookieSameSiteDisabled is true", func() { - setting.CookieSameSiteDisabled = true - setting.CookieSameSiteMode = http.SameSiteLaxMode - expectedCookiePath := "/" - if len(setting.AppSubUrl) > 0 { - expectedCookiePath = setting.AppSubUrl - } - expectedCookie := &http.Cookie{ - Name: setting.LoginCookieName, - Value: "rotated", - Path: expectedCookiePath, - HttpOnly: true, - MaxAge: maxAge, - Secure: setting.CookieSecure, - } - - sc.fakeReq("GET", "/").exec() - So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, expectedCookie.String()) + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, userID, sc.context.UserId) + assert.Equal(t, userID, sc.context.UserToken.UserId) + assert.Equal(t, "rotated", sc.context.UserToken.UnhashedToken) + assert.Equal(t, expectedCookie.String(), sc.resp.Header().Get("Set-Cookie")) }) - }) + } - middlewareScenario(t, "Invalid/expired auth token in cookie", func(sc *scenarioContext) { - sc.withTokenSessionCookie("token") + t.Run("Should not set cookie with SameSite attribute when setting.CookieSameSiteDisabled is true", func(t *testing.T) { + origCookieSameSiteDisabled := setting.CookieSameSiteDisabled + origCookieSameSiteMode := setting.CookieSameSiteMode + t.Cleanup(func() { + setting.CookieSameSiteDisabled = origCookieSameSiteDisabled + setting.CookieSameSiteMode = origCookieSameSiteMode + }) + setting.CookieSameSiteDisabled = true + setting.CookieSameSiteMode = http.SameSiteLaxMode - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return nil, models.ErrUserTokenNotFound + expectedCookiePath := "/" + if len(setting.AppSubUrl) > 0 { + expectedCookiePath = setting.AppSubUrl + } + expectedCookie := &http.Cookie{ + Name: setting.LoginCookieName, + Value: "rotated", + Path: expectedCookiePath, + HttpOnly: true, + MaxAge: maxAge, + Secure: setting.CookieSecure, } sc.fakeReq("GET", "/").exec() + assert.Equal(t, expectedCookie.String(), sc.resp.Header().Get("Set-Cookie")) + }) + }) - Convey("Should not init context with user info", func() { - So(sc.context.IsSignedIn, ShouldBeFalse) - So(sc.context.UserId, ShouldEqual, 0) - So(sc.context.UserToken, ShouldBeNil) - }) + middlewareScenario(t, "Invalid/expired auth token in cookie", func(t *testing.T, sc *scenarioContext) { + sc.withTokenSessionCookie("token") + + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return nil, models.ErrUserTokenNotFound + } + + sc.fakeReq("GET", "/").exec() + + assert.False(t, sc.context.IsSignedIn) + assert.Equal(t, int64(0), sc.context.UserId) + assert.Nil(t, sc.context.UserToken) + }) + + middlewareScenario(t, "When anonymous access is enabled", func(t *testing.T, sc *scenarioContext) { + const orgID int64 = 2 + + origAnonymousEnabled := setting.AnonymousEnabled + origAnonymousOrgName := setting.AnonymousOrgName + origAnonymousOrgRole := setting.AnonymousOrgRole + t.Cleanup(func() { + setting.AnonymousEnabled = origAnonymousEnabled + setting.AnonymousOrgName = origAnonymousOrgName + setting.AnonymousOrgRole = origAnonymousOrgRole + }) + setting.AnonymousEnabled = true + setting.AnonymousOrgName = "test" + setting.AnonymousOrgRole = string(models.ROLE_EDITOR) + + bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error { + assert.Equal(t, "test", query.Name) + + query.Result = &models.Org{Id: orgID, Name: "test"} + return nil }) - middlewareScenario(t, "When anonymous access is enabled", func(sc *scenarioContext) { - setting.AnonymousEnabled = true - setting.AnonymousOrgName = "test" - setting.AnonymousOrgRole = string(models.ROLE_EDITOR) + sc.fakeReq("GET", "/").exec() - bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error { - So(query.Name, ShouldEqual, "test") + assert.Equal(t, int64(0), sc.context.UserId) + assert.Equal(t, orgID, sc.context.OrgId) + assert.Equal(t, models.ROLE_EDITOR, sc.context.OrgRole) + assert.False(t, sc.context.IsSignedIn) + }) - query.Result = &models.Org{Id: 2, Name: "test"} + t.Run("auth_proxy", func(t *testing.T) { + const userID int64 = 33 + const orgID int64 = 4 + + origAuthProxyEnabled := setting.AuthProxyEnabled + origAuthProxyWhitelist := setting.AuthProxyWhitelist + origAuthProxyAutoSignUp := setting.AuthProxyAutoSignUp + origLDAPEnabled := setting.LDAPEnabled + origAuthProxyHeaderName := setting.AuthProxyHeaderName + origAuthProxyHeaderProperty := setting.AuthProxyHeaderProperty + origAuthProxyHeaders := setting.AuthProxyHeaders + t.Cleanup(func() { + setting.AuthProxyEnabled = origAuthProxyEnabled + setting.AuthProxyWhitelist = origAuthProxyWhitelist + setting.AuthProxyAutoSignUp = origAuthProxyAutoSignUp + setting.LDAPEnabled = origLDAPEnabled + setting.AuthProxyHeaderName = origAuthProxyHeaderName + setting.AuthProxyHeaderProperty = origAuthProxyHeaderProperty + setting.AuthProxyHeaders = origAuthProxyHeaders + }) + setting.AuthProxyEnabled = true + setting.AuthProxyWhitelist = "" + setting.AuthProxyAutoSignUp = true + setting.LDAPEnabled = true + setting.AuthProxyHeaderName = "X-WEBAUTH-USER" + setting.AuthProxyHeaderProperty = "username" + setting.AuthProxyHeaders = map[string]string{"Groups": "X-WEBAUTH-GROUPS"} + + const hdrName = "markelog" + const group = "grafana-core-team" + + middlewareScenario(t, "Should not sync the user if it's in the cache", func(t *testing.T, sc *scenarioContext) { + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: orgID, UserId: query.UserId} return nil }) - sc.fakeReq("GET", "/").exec() + key := fmt.Sprintf(authproxy.CachePrefix, authproxy.HashCacheKey(hdrName+"-"+group)) + err := sc.remoteCacheService.Set(key, userID, 0) + require.NoError(t, err) + sc.fakeReq("GET", "/") - Convey("Should init context with org info", func() { - So(sc.context.UserId, ShouldEqual, 0) - So(sc.context.OrgId, ShouldEqual, 2) - So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR) - }) + sc.req.Header.Set(setting.AuthProxyHeaderName, hdrName) + sc.req.Header.Set("X-WEBAUTH-GROUPS", group) + sc.exec() - Convey("context signed in should be false", func() { - So(sc.context.IsSignedIn, ShouldBeFalse) - }) + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, userID, sc.context.UserId) + assert.Equal(t, orgID, sc.context.OrgId) }) - Convey("auth_proxy", func() { - setting.AuthProxyEnabled = true - setting.AuthProxyWhitelist = "" + middlewareScenario(t, "Should respect auto signup option", func(t *testing.T, sc *scenarioContext) { + origLDAPEnabled = setting.LDAPEnabled + origAuthProxyAutoSignUp = setting.AuthProxyAutoSignUp + t.Cleanup(func() { + setting.LDAPEnabled = origLDAPEnabled + setting.AuthProxyAutoSignUp = origAuthProxyAutoSignUp + }) + setting.LDAPEnabled = false + setting.AuthProxyAutoSignUp = false + + var actualAuthProxyAutoSignUp *bool = nil + + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + actualAuthProxyAutoSignUp = &cmd.SignupAllowed + return login.ErrInvalidCredentials + }) + + sc.fakeReq("GET", "/") + sc.req.Header.Set(setting.AuthProxyHeaderName, hdrName) + sc.exec() + + assert.False(t, *actualAuthProxyAutoSignUp) + assert.Equal(t, sc.resp.Code, 407) + assert.Nil(t, sc.context) + }) + + middlewareScenario(t, "Should create an user from a header", func(t *testing.T, sc *scenarioContext) { + origLDAPEnabled = setting.LDAPEnabled + origAuthProxyAutoSignUp = setting.AuthProxyAutoSignUp + t.Cleanup(func() { + setting.LDAPEnabled = origLDAPEnabled + setting.AuthProxyAutoSignUp = origAuthProxyAutoSignUp + }) + setting.LDAPEnabled = false setting.AuthProxyAutoSignUp = true - setting.LDAPEnabled = true - setting.AuthProxyHeaderName = "X-WEBAUTH-USER" - setting.AuthProxyHeaderProperty = "username" - setting.AuthProxyHeaders = map[string]string{"Groups": "X-WEBAUTH-GROUPS"} - name := "markelog" - group := "grafana-core-team" - middlewareScenario(t, "Should not sync the user if it's in the cache", func(sc *scenarioContext) { - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 4, UserId: query.UserId} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + if query.UserId > 0 { + query.Result = &models.SignedInUser{OrgId: orgID, UserId: userID} return nil - }) - - key := fmt.Sprintf(authproxy.CachePrefix, authproxy.HashCacheKey(name+"-"+group)) - err := sc.remoteCacheService.Set(key, int64(33), 0) - So(err, ShouldBeNil) - sc.fakeReq("GET", "/") - - sc.req.Header.Add(setting.AuthProxyHeaderName, name) - sc.req.Header.Add("X-WEBAUTH-GROUPS", group) - sc.exec() - - Convey("Should init user via cache", func() { - So(sc.context.IsSignedIn, ShouldBeTrue) - So(sc.context.UserId, ShouldEqual, 33) - So(sc.context.OrgId, ShouldEqual, 4) - }) + } + return models.ErrUserNotFound }) - middlewareScenario(t, "Should respect auto signup option", func(sc *scenarioContext) { - setting.LDAPEnabled = false - setting.AuthProxyAutoSignUp = false - var actualAuthProxyAutoSignUp *bool = nil - - bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { - actualAuthProxyAutoSignUp = &cmd.SignupAllowed - return login.ErrInvalidCredentials - }) - - sc.fakeReq("GET", "/") - sc.req.Header.Add(setting.AuthProxyHeaderName, name) - sc.exec() - - assert.False(t, *actualAuthProxyAutoSignUp) - assert.Equal(t, sc.resp.Code, 407) - assert.Nil(t, sc.context) + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: userID} + return nil }) - middlewareScenario(t, "Should create an user from a header", func(sc *scenarioContext) { - setting.LDAPEnabled = false - setting.AuthProxyAutoSignUp = true + sc.fakeReq("GET", "/") + sc.req.Header.Set(setting.AuthProxyHeaderName, hdrName) + sc.exec() - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - if query.UserId > 0 { - query.Result = &models.SignedInUser{OrgId: 4, UserId: 33} - return nil - } - return models.ErrUserNotFound - }) + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, userID, sc.context.UserId) + assert.Equal(t, orgID, sc.context.OrgId) + }) - bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { - cmd.Result = &models.User{Id: 33} - return nil - }) + middlewareScenario(t, "Should get an existing user from header", func(t *testing.T, sc *scenarioContext) { + const userID int64 = 12 + const orgID int64 = 2 - sc.fakeReq("GET", "/") - sc.req.Header.Add(setting.AuthProxyHeaderName, name) - sc.exec() + origLDAPEnabled = setting.LDAPEnabled + t.Cleanup(func() { + setting.LDAPEnabled = origLDAPEnabled + }) + setting.LDAPEnabled = false - Convey("Should create user from header info", func() { - So(sc.context.IsSignedIn, ShouldBeTrue) - So(sc.context.UserId, ShouldEqual, 33) - So(sc.context.OrgId, ShouldEqual, 4) - }) + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: orgID, UserId: userID} + return nil }) - middlewareScenario(t, "Should get an existing user from header", func(sc *scenarioContext) { - setting.LDAPEnabled = false - - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} - return nil - }) - - bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { - cmd.Result = &models.User{Id: 12} - return nil - }) - - sc.fakeReq("GET", "/") - sc.req.Header.Add(setting.AuthProxyHeaderName, name) - sc.exec() - - Convey("Should init context with user info", func() { - So(sc.context.IsSignedIn, ShouldBeTrue) - So(sc.context.UserId, ShouldEqual, 12) - So(sc.context.OrgId, ShouldEqual, 2) - }) + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: userID} + return nil }) - middlewareScenario(t, "Should allow the request from whitelist IP", func(sc *scenarioContext) { - setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120" - setting.LDAPEnabled = false + sc.fakeReq("GET", "/") + sc.req.Header.Set(setting.AuthProxyHeaderName, hdrName) + sc.exec() - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 4, UserId: 33} - return nil - }) + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, userID, sc.context.UserId) + assert.Equal(t, orgID, sc.context.OrgId) + }) - bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { - cmd.Result = &models.User{Id: 33} - return nil - }) + middlewareScenario(t, "Should allow the request from whitelist IP", func(t *testing.T, sc *scenarioContext) { + origAuthProxyWhitelist = setting.AuthProxyWhitelist + origLDAPEnabled = setting.LDAPEnabled + t.Cleanup(func() { + setting.AuthProxyWhitelist = origAuthProxyWhitelist + setting.LDAPEnabled = origLDAPEnabled + }) + setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120" + setting.LDAPEnabled = false - sc.fakeReq("GET", "/") - sc.req.Header.Add(setting.AuthProxyHeaderName, name) - 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) - }) + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: orgID, UserId: userID} + return nil }) - middlewareScenario(t, "Should not allow the request from whitelist IP", func(sc *scenarioContext) { - setting.AuthProxyWhitelist = "8.8.8.8" - setting.LDAPEnabled = false - - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 4, UserId: 33} - return nil - }) - - bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { - cmd.Result = &models.User{Id: 33} - return nil - }) - - sc.fakeReq("GET", "/") - sc.req.Header.Add(setting.AuthProxyHeaderName, name) - sc.req.RemoteAddr = "[2001::23]:12345" - sc.exec() - - Convey("Should return 407 status code", func() { - So(sc.resp.Code, ShouldEqual, 407) - So(sc.context, ShouldBeNil) - }) + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: userID} + return nil }) - middlewareScenario(t, "Should return 407 status code if LDAP says no", func(sc *scenarioContext) { - bus.AddHandler("LDAP", func(cmd *models.UpsertUserCommand) error { - return errors.New("Do not add user") - }) + sc.fakeReq("GET", "/") + sc.req.Header.Set(setting.AuthProxyHeaderName, hdrName) + sc.req.RemoteAddr = "[2001::23]:12345" + sc.exec() - sc.fakeReq("GET", "/") - sc.req.Header.Add(setting.AuthProxyHeaderName, name) - sc.exec() + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, userID, sc.context.UserId) + assert.Equal(t, orgID, sc.context.OrgId) + }) - Convey("Should return 407 status code", func() { - So(sc.resp.Code, ShouldEqual, 407) - So(sc.context, ShouldBeNil) - }) + middlewareScenario(t, "Should not allow the request from whitelist IP", func(t *testing.T, sc *scenarioContext) { + origAuthProxyWhitelist = setting.AuthProxyWhitelist + origLDAPEnabled = setting.LDAPEnabled + t.Cleanup(func() { + setting.AuthProxyWhitelist = origAuthProxyWhitelist + setting.LDAPEnabled = origLDAPEnabled + }) + setting.AuthProxyWhitelist = "8.8.8.8" + setting.LDAPEnabled = false + + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: orgID, UserId: userID} + return nil }) - middlewareScenario(t, "Should return 407 status code if there is cache mishap", func(sc *scenarioContext) { - bus.AddHandler("Do not have the user", func(query *models.GetSignedInUserQuery) error { - return errors.New("Do not add user") - }) - - sc.fakeReq("GET", "/") - sc.req.Header.Add(setting.AuthProxyHeaderName, name) - sc.exec() - - Convey("Should return 407 status code", func() { - So(sc.resp.Code, ShouldEqual, 407) - So(sc.context, ShouldBeNil) - }) + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: userID} + return nil }) + + sc.fakeReq("GET", "/") + sc.req.Header.Set(setting.AuthProxyHeaderName, hdrName) + sc.req.RemoteAddr = "[2001::23]:12345" + sc.exec() + + assert.Equal(t, 407, sc.resp.Code) + assert.Nil(t, sc.context) + }) + + middlewareScenario(t, "Should return 407 status code if LDAP says no", func(t *testing.T, sc *scenarioContext) { + bus.AddHandler("LDAP", func(cmd *models.UpsertUserCommand) error { + return errors.New("Do not add user") + }) + + sc.fakeReq("GET", "/") + sc.req.Header.Set(setting.AuthProxyHeaderName, hdrName) + sc.exec() + + assert.Equal(t, 407, sc.resp.Code) + assert.Nil(t, sc.context) + }) + + middlewareScenario(t, "Should return 407 status code if there is cache mishap", func(t *testing.T, sc *scenarioContext) { + bus.AddHandler("Do not have the user", func(query *models.GetSignedInUserQuery) error { + return errors.New("Do not add user") + }) + + sc.fakeReq("GET", "/") + sc.req.Header.Set(setting.AuthProxyHeaderName, hdrName) + sc.exec() + + assert.Equal(t, 407, sc.resp.Code) + assert.Nil(t, sc.context) }) }) } func middlewareScenario(t *testing.T, desc string, fn scenarioFunc) { - Convey(desc, func() { - defer bus.ClearBusHandlers() + t.Helper() + t.Run(desc, func(t *testing.T) { + t.Cleanup(bus.ClearBusHandlers) + + origLoginCookieName := setting.LoginCookieName + origLoginMaxLifetime := setting.LoginMaxLifetime + t.Cleanup(func() { + setting.LoginCookieName = origLoginCookieName + setting.LoginMaxLifetime = origLoginMaxLifetime + }) setting.LoginCookieName = "grafana_session" var err error setting.LoginMaxLifetime, err = gtime.ParseDuration("30d") require.NoError(t, err) - sc := &scenarioContext{} + sc := &scenarioContext{t: t} viewsPath, err := filepath.Abs("../../public/views") require.NoError(t, err) @@ -584,13 +658,13 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc) { sc.m.Get("/", sc.defaultHandler) - fn(sc) + fn(t, sc) }) } func TestDontRotateTokensOnCancelledRequests(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - reqContext, _, err := initTokenRotationTest(ctx) + reqContext, _, err := initTokenRotationTest(ctx, t) require.NoError(t, err) tryRotateCallCount := 0 @@ -612,7 +686,7 @@ func TestDontRotateTokensOnCancelledRequests(t *testing.T) { } func TestTokenRotationAtEndOfRequest(t *testing.T) { - reqContext, rr, err := initTokenRotationTest(context.Background()) + reqContext, rr, err := initTokenRotationTest(context.Background(), t) require.NoError(t, err) uts := &auth.FakeUserAuthTokenService{ @@ -643,7 +717,15 @@ func TestTokenRotationAtEndOfRequest(t *testing.T) { assert.True(t, foundLoginCookie, "Could not find cookie") } -func initTokenRotationTest(ctx context.Context) (*models.ReqContext, *httptest.ResponseRecorder, error) { +func initTokenRotationTest(ctx context.Context, t *testing.T) (*models.ReqContext, *httptest.ResponseRecorder, error) { + t.Helper() + + origLoginCookieName := setting.LoginCookieName + origLoginMaxLifetime := setting.LoginMaxLifetime + t.Cleanup(func() { + setting.LoginCookieName = origLoginCookieName + setting.LoginMaxLifetime = origLoginMaxLifetime + }) setting.LoginCookieName = "login_token" var err error setting.LoginMaxLifetime, err = gtime.ParseDuration("7d") diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index f0698ba30e4..0d1ababb4c9 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -7,61 +7,55 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" ) func TestOrgRedirectMiddleware(t *testing.T) { - Convey("Can redirect to correct org", t, func() { - middlewareScenario(t, "when setting a correct org for the user", func(sc *scenarioContext) { - sc.withTokenSessionCookie("token") - bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error { - return nil - }) - - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 1, UserId: 12} - return nil - }) - - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ - UserId: 0, - UnhashedToken: "", - }, nil - } - - sc.m.Get("/", sc.defaultHandler) - sc.fakeReq("GET", "/?orgId=3").exec() - - Convey("change org and redirect", func() { - So(sc.resp.Code, ShouldEqual, 302) - }) + middlewareScenario(t, "when setting a correct org for the user", func(t *testing.T, sc *scenarioContext) { + sc.withTokenSessionCookie("token") + bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error { + return nil }) - middlewareScenario(t, "when setting an invalid org for user", func(sc *scenarioContext) { - sc.withTokenSessionCookie("token") - bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error { - return fmt.Errorf("") - }) - - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 1, UserId: 12} - return nil - }) - - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ - UserId: 12, - UnhashedToken: "", - }, nil - } - - sc.m.Get("/", sc.defaultHandler) - sc.fakeReq("GET", "/?orgId=3").exec() - - Convey("not allowed to change org", func() { - So(sc.resp.Code, ShouldEqual, 404) - }) + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 1, UserId: 12} + return nil }) + + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ + UserId: 0, + UnhashedToken: "", + }, nil + } + + sc.m.Get("/", sc.defaultHandler) + sc.fakeReq("GET", "/?orgId=3").exec() + + assert.Equal(t, 302, sc.resp.Code) + }) + + middlewareScenario(t, "when setting an invalid org for user", func(t *testing.T, sc *scenarioContext) { + sc.withTokenSessionCookie("token") + bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error { + return fmt.Errorf("") + }) + + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 1, UserId: 12} + return nil + }) + + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ + UserId: 12, + UnhashedToken: "", + }, nil + } + + sc.m.Get("/", sc.defaultHandler) + sc.fakeReq("GET", "/?orgId=3").exec() + + assert.Equal(t, 404, sc.resp.Code) }) } diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 75135e20612..bf2696d5f04 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -9,40 +9,40 @@ import ( "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" ) func TestMiddlewareQuota(t *testing.T) { - Convey("Given the grafana quota middleware", t, func() { - setting.AnonymousEnabled = false - setting.Quota = setting.QuotaSettings{ - Enabled: true, - Org: &setting.OrgQuota{ - User: 5, - Dashboard: 5, - DataSource: 5, - ApiKey: 5, - }, - User: &setting.UserQuota{ - Org: 5, - }, - Global: &setting.GlobalQuota{ - Org: 5, - User: 5, - Dashboard: 5, - DataSource: 5, - ApiKey: 5, - Session: 5, - }, - } + setting.AnonymousEnabled = false + setting.Quota = setting.QuotaSettings{ + Enabled: true, + Org: &setting.OrgQuota{ + User: 5, + Dashboard: 5, + DataSource: 5, + ApiKey: 5, + }, + User: &setting.UserQuota{ + Org: 5, + }, + Global: &setting.GlobalQuota{ + Org: 5, + User: 5, + Dashboard: 5, + DataSource: 5, + ApiKey: 5, + Session: 5, + }, + } - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() - qs := "a.QuotaService{ - AuthTokenService: fakeAuthTokenService, - } - QuotaFn := Quota(qs) + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + qs := "a.QuotaService{ + AuthTokenService: fakeAuthTokenService, + } + quotaFn := Quota(qs) - middlewareScenario(t, "with user not logged in", func(sc *scenarioContext) { + t.Run("With user not logged in", func(t *testing.T) { + middlewareScenario(t, "and global quota not reached", func(t *testing.T, sc *scenarioContext) { bus.AddHandler("globalQuota", func(query *models.GetGlobalQuotaByTargetQuery) error { query.Result = &models.GlobalQuotaDTO{ Target: query.Target, @@ -52,48 +52,12 @@ func TestMiddlewareQuota(t *testing.T) { return nil }) - Convey("global quota not reached", func() { - sc.m.Get("/user", QuotaFn("user"), sc.defaultHandler) - sc.fakeReq("GET", "/user").exec() - So(sc.resp.Code, ShouldEqual, 200) - }) - - Convey("global quota reached", func() { - setting.Quota.Global.User = 4 - sc.m.Get("/user", QuotaFn("user"), sc.defaultHandler) - sc.fakeReq("GET", "/user").exec() - So(sc.resp.Code, ShouldEqual, 403) - }) - - Convey("global session quota not reached", func() { - setting.Quota.Global.Session = 10 - sc.m.Get("/user", QuotaFn("session"), sc.defaultHandler) - sc.fakeReq("GET", "/user").exec() - So(sc.resp.Code, ShouldEqual, 200) - }) - - Convey("global session quota reached", func() { - setting.Quota.Global.Session = 1 - sc.m.Get("/user", QuotaFn("session"), sc.defaultHandler) - sc.fakeReq("GET", "/user").exec() - So(sc.resp.Code, ShouldEqual, 403) - }) + sc.m.Get("/user", quotaFn("user"), sc.defaultHandler) + sc.fakeReq("GET", "/user").exec() + assert.Equal(t, 200, sc.resp.Code) }) - middlewareScenario(t, "with user logged in", func(sc *scenarioContext) { - sc.withTokenSessionCookie("token") - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} - return nil - }) - - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ - UserId: 12, - UnhashedToken: "", - }, nil - } - + middlewareScenario(t, "and global quota reached", func(t *testing.T, sc *scenarioContext) { bus.AddHandler("globalQuota", func(query *models.GetGlobalQuotaByTargetQuery) error { query.Result = &models.GlobalQuotaDTO{ Target: query.Target, @@ -103,8 +67,20 @@ func TestMiddlewareQuota(t *testing.T) { return nil }) - bus.AddHandler("userQuota", func(query *models.GetUserQuotaByTargetQuery) error { - query.Result = &models.UserQuotaDTO{ + origUser := setting.Quota.Global.User + t.Cleanup(func() { + setting.Quota.Global.User = origUser + }) + setting.Quota.Global.User = 4 + + sc.m.Get("/user", quotaFn("user"), sc.defaultHandler) + sc.fakeReq("GET", "/user").exec() + assert.Equal(t, 403, sc.resp.Code) + }) + + middlewareScenario(t, "and global session quota not reached", func(t *testing.T, sc *scenarioContext) { + bus.AddHandler("globalQuota", func(query *models.GetGlobalQuotaByTargetQuery) error { + query.Result = &models.GlobalQuotaDTO{ Target: query.Target, Limit: query.Default, Used: 4, @@ -112,57 +88,112 @@ func TestMiddlewareQuota(t *testing.T) { return nil }) - bus.AddHandler("orgQuota", func(query *models.GetOrgQuotaByTargetQuery) error { - query.Result = &models.OrgQuotaDTO{ - Target: query.Target, - Limit: query.Default, - Used: 4, - } - return nil + origSession := setting.Quota.Global.Session + t.Cleanup(func() { + setting.Quota.Global.Session = origSession }) + setting.Quota.Global.Session = 10 - Convey("global datasource quota reached", func() { - setting.Quota.Global.DataSource = 4 - sc.m.Get("/ds", QuotaFn("data_source"), sc.defaultHandler) - sc.fakeReq("GET", "/ds").exec() - So(sc.resp.Code, ShouldEqual, 403) - }) + sc.m.Get("/user", quotaFn("session"), sc.defaultHandler) + sc.fakeReq("GET", "/user").exec() + assert.Equal(t, 200, sc.resp.Code) + }) - Convey("user Org quota not reached", func() { - setting.Quota.User.Org = 5 - sc.m.Get("/org", QuotaFn("org"), sc.defaultHandler) - sc.fakeReq("GET", "/org").exec() - So(sc.resp.Code, ShouldEqual, 200) + middlewareScenario(t, "and global session quota reached", func(t *testing.T, sc *scenarioContext) { + origSession := setting.Quota.Global.Session + t.Cleanup(func() { + setting.Quota.Global.Session = origSession }) + setting.Quota.Global.Session = 1 - Convey("user Org quota reached", func() { - setting.Quota.User.Org = 4 - sc.m.Get("/org", QuotaFn("org"), sc.defaultHandler) - sc.fakeReq("GET", "/org").exec() - So(sc.resp.Code, ShouldEqual, 403) - }) + sc.m.Get("/user", quotaFn("session"), sc.defaultHandler) + sc.fakeReq("GET", "/user").exec() + assert.Equal(t, 403, sc.resp.Code) + }) + }) - Convey("org dashboard quota not reached", func() { - setting.Quota.Org.Dashboard = 10 - sc.m.Get("/dashboard", QuotaFn("dashboard"), sc.defaultHandler) - sc.fakeReq("GET", "/dashboard").exec() - So(sc.resp.Code, ShouldEqual, 200) - }) + middlewareScenario(t, "with user logged in", func(t *testing.T, sc *scenarioContext) { + sc.withTokenSessionCookie("token") + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} + return nil + }) - Convey("org dashboard quota reached", func() { - setting.Quota.Org.Dashboard = 4 - sc.m.Get("/dashboard", QuotaFn("dashboard"), sc.defaultHandler) - sc.fakeReq("GET", "/dashboard").exec() - So(sc.resp.Code, ShouldEqual, 403) - }) + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ + UserId: 12, + UnhashedToken: "", + }, nil + } - Convey("org dashboard quota reached but quotas disabled", func() { - setting.Quota.Org.Dashboard = 4 - setting.Quota.Enabled = false - sc.m.Get("/dashboard", QuotaFn("dashboard"), sc.defaultHandler) - sc.fakeReq("GET", "/dashboard").exec() - So(sc.resp.Code, ShouldEqual, 200) - }) + bus.AddHandler("globalQuota", func(query *models.GetGlobalQuotaByTargetQuery) error { + query.Result = &models.GlobalQuotaDTO{ + Target: query.Target, + Limit: query.Default, + Used: 4, + } + return nil + }) + + bus.AddHandler("userQuota", func(query *models.GetUserQuotaByTargetQuery) error { + query.Result = &models.UserQuotaDTO{ + Target: query.Target, + Limit: query.Default, + Used: 4, + } + return nil + }) + + bus.AddHandler("orgQuota", func(query *models.GetOrgQuotaByTargetQuery) error { + query.Result = &models.OrgQuotaDTO{ + Target: query.Target, + Limit: query.Default, + Used: 4, + } + return nil + }) + + t.Run("global datasource quota reached", func(t *testing.T) { + setting.Quota.Global.DataSource = 4 + sc.m.Get("/ds", quotaFn("data_source"), sc.defaultHandler) + sc.fakeReq("GET", "/ds").exec() + assert.Equal(t, 403, sc.resp.Code) + }) + + t.Run("user Org quota not reached", func(t *testing.T) { + setting.Quota.User.Org = 5 + sc.m.Get("/org", quotaFn("org"), sc.defaultHandler) + sc.fakeReq("GET", "/org").exec() + assert.Equal(t, 200, sc.resp.Code) + }) + + t.Run("user Org quota reached", func(t *testing.T) { + setting.Quota.User.Org = 4 + sc.m.Get("/org", quotaFn("org"), sc.defaultHandler) + sc.fakeReq("GET", "/org").exec() + assert.Equal(t, 403, sc.resp.Code) + }) + + t.Run("org dashboard quota not reached", func(t *testing.T) { + setting.Quota.Org.Dashboard = 10 + sc.m.Get("/dashboard", quotaFn("dashboard"), sc.defaultHandler) + sc.fakeReq("GET", "/dashboard").exec() + assert.Equal(t, 200, sc.resp.Code) + }) + + t.Run("org dashboard quota reached", func(t *testing.T) { + setting.Quota.Org.Dashboard = 4 + sc.m.Get("/dashboard", quotaFn("dashboard"), sc.defaultHandler) + sc.fakeReq("GET", "/dashboard").exec() + assert.Equal(t, 403, sc.resp.Code) + }) + + t.Run("org dashboard quota reached but quotas disabled", func(t *testing.T) { + setting.Quota.Org.Dashboard = 4 + setting.Quota.Enabled = false + sc.m.Get("/dashboard", quotaFn("dashboard"), sc.defaultHandler) + sc.fakeReq("GET", "/dashboard").exec() + assert.Equal(t, 200, sc.resp.Code) }) }) } diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go index c238ecbfeee..8048cc6f53b 100644 --- a/pkg/middleware/recovery.go +++ b/pkg/middleware/recovery.go @@ -52,6 +52,9 @@ func stack(skip int) []byte { // Print this much at least. If we can't find the source, it won't show. fmt.Fprintf(buf, "%s:%d (0x%x)\n", file, line, pc) if file != lastFile { + // We can ignore the gosec G304 warning on this one because `file` + // comes from the runtime.Caller() function. + // nolint:gosec data, err := ioutil.ReadFile(file) if err != nil { continue diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 03746daa2d4..5507ba5aac5 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -2,6 +2,7 @@ package middleware import ( "path/filepath" + "strings" "testing" "github.com/grafana/grafana/pkg/bus" @@ -9,52 +10,55 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/setting" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" macaron "gopkg.in/macaron.v1" ) func TestRecoveryMiddleware(t *testing.T) { setting.ErrTemplateName = "error-template" - Convey("Given an api route that panics", t, func() { + t.Run("Given an API route that panics", func(t *testing.T) { apiURL := "/api/whatever" - recoveryScenario(t, "recovery middleware should return json", apiURL, func(sc *scenarioContext) { - sc.handlerFunc = PanicHandler + recoveryScenario(t, "recovery middleware should return json", apiURL, func(t *testing.T, sc *scenarioContext) { + sc.handlerFunc = panicHandler sc.fakeReq("GET", apiURL).exec() sc.req.Header.Add("content-type", "application/json") - So(sc.resp.Code, ShouldEqual, 500) - So(sc.respJson["message"], ShouldStartWith, "Internal Server Error - Check the Grafana server logs for the detailed error message.") - So(sc.respJson["error"], ShouldStartWith, "Server Error") + assert.Equal(t, 500, sc.resp.Code) + assert.Equal(t, "Internal Server Error - Check the Grafana server logs for the detailed error message.", sc.respJson["message"]) + assert.True(t, strings.HasPrefix(sc.respJson["error"].(string), "Server Error")) }) }) - Convey("Given a non-api route that panics", t, func() { + t.Run("Given a non-API route that panics", func(t *testing.T) { apiURL := "/whatever" - recoveryScenario(t, "recovery middleware should return html", apiURL, func(sc *scenarioContext) { - sc.handlerFunc = PanicHandler + recoveryScenario(t, "recovery middleware should return html", apiURL, func(t *testing.T, sc *scenarioContext) { + sc.handlerFunc = panicHandler sc.fakeReq("GET", apiURL).exec() - So(sc.resp.Code, ShouldEqual, 500) - So(sc.resp.Header().Get("content-type"), ShouldEqual, "text/html; charset=UTF-8") - So(sc.resp.Body.String(), ShouldContainSubstring, "Grafana - Error") + assert.Equal(t, 500, sc.resp.Code) + assert.Equal(t, "text/html; charset=UTF-8", sc.resp.Header().Get("content-type")) + assert.True(t, strings.Contains(sc.resp.Body.String(), "Grafana - Error")) }) }) } -func PanicHandler(c *models.ReqContext) { +func panicHandler(c *models.ReqContext) { panic("Handler has panicked") } func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) { - Convey(desc, func() { + t.Run(desc, func(t *testing.T) { defer bus.ClearBusHandlers() sc := &scenarioContext{ + t: t, url: url, } - viewsPath, _ := filepath.Abs("../../public/views") + viewsPath, err := filepath.Abs("../../public/views") + require.NoError(t, err) sc.m = macaron.New() sc.m.Use(Recovery()) @@ -81,6 +85,6 @@ func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) { sc.m.Get(url, sc.defaultHandler) - fn(sc) + fn(t, sc) }) } diff --git a/pkg/middleware/request_metrics.go b/pkg/middleware/request_metrics.go index 50c0f711da2..a55d707323d 100644 --- a/pkg/middleware/request_metrics.go +++ b/pkg/middleware/request_metrics.go @@ -9,12 +9,17 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/client_golang/prometheus" + cw "github.com/weaveworks/common/middleware" "gopkg.in/macaron.v1" ) var ( httpRequestsInFlight prometheus.Gauge httpRequestDurationHistogram *prometheus.HistogramVec + + // DefBuckets are histogram buckets for the response time (in seconds) + // of a network service, including one that is responding very slowly. + defBuckets = []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5} ) func init() { @@ -30,9 +35,9 @@ func init() { Namespace: "grafana", Name: "http_request_duration_seconds", Help: "Histogram of latencies for HTTP requests.", - Buckets: []float64{.1, .2, .4, 1, 3, 8, 20, 60, 120}, + Buckets: defBuckets, }, - []string{"handler"}, + []string{"handler", "code", "method"}, ) prometheus.MustRegister(httpRequestsInFlight, httpRequestDurationHistogram) @@ -52,14 +57,26 @@ func RequestMetrics(cfg *setting.Cfg) func(handler string) macaron.Handler { code := sanitizeCode(status) method := sanitizeMethod(req.Method) - metrics.MHttpRequestTotal.WithLabelValues(handler, code, method).Inc() - duration := time.Since(now).Nanoseconds() / int64(time.Millisecond) - - // enable histogram and disable summaries for http requests. + // enable histogram and disable summaries + counters for http requests. if cfg.IsHTTPRequestHistogramEnabled() { - httpRequestDurationHistogram.WithLabelValues(handler).Observe(float64(duration)) + // avoiding the sanitize functions for in the new instrumentation + // since they dont make much sense. We should remove them later. + histogram := httpRequestDurationHistogram. + WithLabelValues(handler, strconv.Itoa(rw.Status()), req.Method) + if traceID, ok := cw.ExtractSampledTraceID(c.Req.Context()); ok { + // Need to type-convert the Observer to an + // ExemplarObserver. This will always work for a + // HistogramVec. + histogram.(prometheus.ExemplarObserver).ObserveWithExemplar( + time.Since(now).Seconds(), prometheus.Labels{"traceID": traceID}, + ) + return + } + histogram.Observe(time.Since(now).Seconds()) } else { + duration := time.Since(now).Nanoseconds() / int64(time.Millisecond) + metrics.MHttpRequestTotal.WithLabelValues(handler, code, method).Inc() metrics.MHttpRequestSummary.WithLabelValues(handler, code, method).Observe(float64(duration)) } diff --git a/pkg/middleware/testing.go b/pkg/middleware/testing.go index 0e24cc3e7cc..8229fb36116 100644 --- a/pkg/middleware/testing.go +++ b/pkg/middleware/testing.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "testing" "gopkg.in/macaron.v1" @@ -11,10 +12,11 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/setting" - "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/require" ) type scenarioContext struct { + t *testing.T m *macaron.Macaron context *models.ReqContext resp *httptest.ResponseRecorder @@ -47,15 +49,19 @@ func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioC } func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { + sc.t.Helper() + sc.resp = httptest.NewRecorder() req, err := http.NewRequest(method, url, nil) - convey.So(err, convey.ShouldBeNil) + require.NoError(sc.t, err) sc.req = req return sc } func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext { + sc.t.Helper() + sc.resp = httptest.NewRecorder() req, err := http.NewRequest(method, url, nil) q := req.URL.Query() @@ -63,7 +69,7 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map q.Add(k, v) } req.URL.RawQuery = q.Encode() - convey.So(err, convey.ShouldBeNil) + require.NoError(sc.t, err) sc.req = req return sc @@ -75,15 +81,20 @@ func (sc *scenarioContext) handler(fn handlerFunc) *scenarioContext { } func (sc *scenarioContext) exec() { + sc.t.Helper() + if sc.apiKey != "" { + sc.t.Logf(`Adding header "Authorization: Bearer %s"`, sc.apiKey) sc.req.Header.Add("Authorization", "Bearer "+sc.apiKey) } if sc.authHeader != "" { + sc.t.Logf(`Adding header "Authorization: %s"`, sc.authHeader) sc.req.Header.Add("Authorization", sc.authHeader) } if sc.tokenSessionCookie != "" { + sc.t.Log(`Adding cookie`, "name", setting.LoginCookieName, "value", sc.tokenSessionCookie) sc.req.AddCookie(&http.Cookie{ Name: setting.LoginCookieName, Value: sc.tokenSessionCookie, @@ -94,9 +105,9 @@ func (sc *scenarioContext) exec() { if sc.resp.Header().Get("Content-Type") == "application/json; charset=UTF-8" { err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson) - convey.So(err, convey.ShouldBeNil) + require.NoError(sc.t, err) } } -type scenarioFunc func(c *scenarioContext) +type scenarioFunc func(t *testing.T, c *scenarioContext) type handlerFunc func(c *models.ReqContext) diff --git a/pkg/models/sigv4.go b/pkg/models/sigv4.go index d4fe0287172..2a8237e507f 100644 --- a/pkg/models/sigv4.go +++ b/pkg/models/sigv4.go @@ -97,10 +97,8 @@ func (m *SigV4Middleware) signer() (*v4.Signer, error) { c = credentials.NewStaticCredentials(m.Config.AccessKey, m.Config.SecretKey, "") case Credentials: c = credentials.NewSharedCredentials("", m.Config.Profile) - } - - // passing nil credentials will force AWS to allow a more complete credential chain vs the explicit default - if c == nil { + case Default: + // passing nil credentials will force AWS to allow a more complete credential chain vs the explicit default s, err := session.NewSession(&aws.Config{ Region: aws.String(m.Config.Region), }) diff --git a/pkg/plugins/backendplugin/grpcplugin/log_wrapper.go b/pkg/plugins/backendplugin/grpcplugin/log_wrapper.go index 3a879d845b6..65c1948b0d4 100644 --- a/pkg/plugins/backendplugin/grpcplugin/log_wrapper.go +++ b/pkg/plugins/backendplugin/grpcplugin/log_wrapper.go @@ -51,6 +51,8 @@ func (lw logWrapper) Log(level hclog.Level, msg string, args ...interface{}) { lw.Warn(msg, args...) case hclog.Error: lw.Error(msg, args...) + default: + // TODO: Handle hclog.NoLevel } } diff --git a/pkg/plugins/dashboards.go b/pkg/plugins/dashboards.go index 30396bf2004..6cd9f827449 100644 --- a/pkg/plugins/dashboards.go +++ b/pkg/plugins/dashboards.go @@ -91,18 +91,25 @@ func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDT func loadPluginDashboard(pluginId, path string) (*models.Dashboard, error) { plugin, exists := Plugins[pluginId] - if !exists { return nil, PluginNotFoundError{pluginId} } + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `plugin.PluginDir` is based + // on plugin folder structure on disk and not user input. `path` comes from the + // `plugin.json` configuration file for the loaded plugin dashboardFilePath := filepath.Join(plugin.PluginDir, path) reader, err := os.Open(dashboardFilePath) if err != nil { return nil, err } - defer reader.Close() + defer func() { + if err := reader.Close(); err != nil { + plog.Warn("Failed to close file", "path", dashboardFilePath, "err", err) + } + }() data, err := simplejson.NewFromReader(reader) if err != nil { diff --git a/pkg/plugins/manifest.go b/pkg/plugins/manifest.go index 9b6e2cb00c4..4eaf721bad6 100644 --- a/pkg/plugins/manifest.go +++ b/pkg/plugins/manifest.go @@ -87,6 +87,9 @@ func getPluginSignatureState(log log.Logger, plugin *PluginBase) PluginSignature log.Debug("Getting signature state of plugin", "plugin", plugin.Id, "isBackend", plugin.Backend) manifestPath := filepath.Join(plugin.PluginDir, "MANIFEST.txt") + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `manifestPath` is based + // on plugin the folder structure on disk and not user input. byteValue, err := ioutil.ReadFile(manifestPath) if err != nil || len(byteValue) < 10 { log.Debug("Plugin is unsigned", "id", plugin.Id) @@ -109,11 +112,19 @@ func getPluginSignatureState(log log.Logger, plugin *PluginBase) PluginSignature for p, hash := range manifest.Files { // Open the file fp := filepath.Join(plugin.PluginDir, p) + + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `fp` is based + // on the manifest file for a plugin and not user input. f, err := os.Open(fp) if err != nil { return PluginSignatureModified } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + log.Warn("Failed to close plugin file", "path", fp, "err", err) + } + }() h := sha256.New() if _, err := io.Copy(h, f); err != nil { diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 53120f5fc01..3b2574595f1 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -133,6 +133,7 @@ type PluginInclude struct { AddToNav bool `json:"addToNav"` DefaultNav bool `json:"defaultNav"` Slug string `json:"slug"` + Icon string `json:"icon"` Id string `json:"-"` } diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 0818a8dde05..754a5eab16e 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -269,11 +269,18 @@ func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error { } } + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `jsonFPath` is based + // on plugin the folder structure on disk and not user input. reader, err := os.Open(jsonFPath) if err != nil { return err } - defer reader.Close() + defer func() { + if err := reader.Close(); err != nil { + scanner.log.Warn("Failed to close JSON file", "path", jsonFPath, "err", err) + } + }() jsonParser := json.NewDecoder(reader) @@ -328,6 +335,9 @@ func (s *PluginScanner) walker(currentPath string, f os.FileInfo, err error) err return nil } + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `currentPath` is based + // on plugin the folder structure on disk and not user input. if err := s.loadPlugin(currentPath); err != nil { s.log.Error("Failed to load plugin", "error", err, "pluginPath", filepath.Dir(currentPath)) s.errors = append(s.errors, err) @@ -343,7 +353,11 @@ func (s *PluginScanner) loadPlugin(pluginJSONFilePath string) error { if err != nil { return err } - defer reader.Close() + defer func() { + if err := reader.Close(); err != nil { + s.log.Warn("Failed to close JSON file", "path", pluginJSONFilePath, "err", err) + } + }() jsonParser := json.NewDecoder(reader) pluginCommon := PluginBase{} @@ -463,6 +477,9 @@ func GetPluginMarkdown(pluginId string, name string) ([]byte, error) { return nil, PluginNotFoundError{pluginId} } + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `plug.PluginDir` is based + // on plugin the folder structure on disk and not user input. path := filepath.Join(plug.PluginDir, fmt.Sprintf("%s.md", strings.ToUpper(name))) exists, err := fs.Exists(path) if err != nil { @@ -480,6 +497,9 @@ func GetPluginMarkdown(pluginId string, name string) ([]byte, error) { return make([]byte, 0), nil } + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `plug.PluginDir` is based + // on plugin the folder structure on disk and not user input. data, err := ioutil.ReadFile(path) if err != nil { return nil, err diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 477079d34e4..dd60fa56f6e 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -17,7 +17,7 @@ type Descriptor struct { var services []*Descriptor func RegisterServiceWithPriority(instance Service, priority Priority) { - services = append(services, &Descriptor{ + Register(&Descriptor{ Name: reflect.TypeOf(instance).Elem().Name(), Instance: instance, InitPriority: priority, @@ -25,7 +25,7 @@ func RegisterServiceWithPriority(instance Service, priority Priority) { } func RegisterService(instance Service) { - services = append(services, &Descriptor{ + Register(&Descriptor{ Name: reflect.TypeOf(instance).Elem().Name(), Instance: instance, InitPriority: Medium, @@ -33,6 +33,17 @@ func RegisterService(instance Service) { } func Register(descriptor *Descriptor) { + if descriptor == nil { + return + } + // Overwrite any existing equivalent service + for i, svc := range services { + if svc.Name == descriptor.Name { + services[i] = descriptor + return + } + } + services = append(services, descriptor) } diff --git a/pkg/server/server.go b/pkg/server/server.go index 228482e88b6..6fd28aa63e9 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -35,6 +35,7 @@ import ( _ "github.com/grafana/grafana/pkg/services/alerting" _ "github.com/grafana/grafana/pkg/services/auth" _ "github.com/grafana/grafana/pkg/services/cleanup" + _ "github.com/grafana/grafana/pkg/services/librarypanels" _ "github.com/grafana/grafana/pkg/services/ngalert" _ "github.com/grafana/grafana/pkg/services/notifications" _ "github.com/grafana/grafana/pkg/services/provisioning" @@ -273,7 +274,7 @@ func (s *Server) buildServiceGraph(services []*registry.Descriptor) error { objs := []interface{}{ bus.GetBus(), s.cfg, - routing.NewRouteRegister(middleware.RequestMetrics(s.cfg), middleware.RequestTracing), + routing.NewRouteRegister(middleware.RequestTracing, middleware.RequestMetrics(s.cfg)), localcache.New(5*time.Minute, 10*time.Minute), s, } diff --git a/pkg/services/alerting/notifier_test.go b/pkg/services/alerting/notifier_test.go index 0b2f2b779bc..fcb49e3abc0 100644 --- a/pkg/services/alerting/notifier_test.go +++ b/pkg/services/alerting/notifier_test.go @@ -30,94 +30,102 @@ func TestNotificationService(t *testing.T) { } evalCtx := NewEvalContext(context.Background(), testRule) - notificationServiceScenario(t, "Given alert rule with upload image enabled should render and upload image and send notification", evalCtx, true, func(scenarioCtx *scenarioContext) { - err := scenarioCtx.notificationService.SendIfNeeded(evalCtx) - require.NoError(t, err) + notificationServiceScenario(t, "Given alert rule with upload image enabled should render and upload image and send notification", + evalCtx, true, func(sc *scenarioContext) { + err := sc.notificationService.SendIfNeeded(evalCtx) + require.NoError(sc.t, err) - require.Equalf(t, 1, scenarioCtx.renderCount, "expected render to be called, but wasn't") - require.Equalf(t, 1, scenarioCtx.imageUploadCount, "expected image to be uploaded, but wasn't") - require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - }) + require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't") + require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but wasn't") + require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") + }) - notificationServiceScenario(t, "Given alert rule with upload image enabled but no renderer available should render and upload unavailable image and send notification", evalCtx, true, func(scenarioCtx *scenarioContext) { - scenarioCtx.rendererAvailable = false - err := scenarioCtx.notificationService.SendIfNeeded(evalCtx) - require.NoError(t, err) + notificationServiceScenario(t, + "Given alert rule with upload image enabled but no renderer available should render and upload unavailable image and send notification", + evalCtx, true, func(sc *scenarioContext) { + sc.rendererAvailable = false + err := sc.notificationService.SendIfNeeded(evalCtx) + require.NoError(sc.t, err) - require.Equalf(t, 1, scenarioCtx.renderCount, "expected render to be called, but it wasn't") - require.Equalf(t, 1, scenarioCtx.imageUploadCount, "expected image to be uploaded, but it wasn't") - require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - }) + require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but it wasn't") + require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but it wasn't") + require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") + }) - notificationServiceScenario(t, "Given alert rule with upload image disabled should not render and upload image, but send notification", evalCtx, false, func(scenarioCtx *scenarioContext) { - err := scenarioCtx.notificationService.SendIfNeeded(evalCtx) - require.NoError(t, err) + notificationServiceScenario( + t, "Given alert rule with upload image disabled should not render and upload image, but send notification", + evalCtx, false, func(sc *scenarioContext) { + err := sc.notificationService.SendIfNeeded(evalCtx) + require.NoError(t, err) - require.Equalf(t, 0, scenarioCtx.renderCount, "expected render not to be called, but it was") - require.Equalf(t, 0, scenarioCtx.imageUploadCount, "expected image not to be uploaded, but it was") - require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - }) + require.Equalf(sc.t, 0, sc.renderCount, "expected render not to be called, but it was") + require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was") + require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") + }) - notificationServiceScenario(t, "Given alert rule with upload image enabled and render times out should send notification", evalCtx, true, func(scenarioCtx *scenarioContext) { - setting.AlertingNotificationTimeout = 200 * time.Millisecond - scenarioCtx.renderProvider = func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) { - wait := make(chan bool) + notificationServiceScenario(t, "Given alert rule with upload image enabled and render times out should send notification", + evalCtx, true, func(sc *scenarioContext) { + setting.AlertingNotificationTimeout = 200 * time.Millisecond + sc.renderProvider = func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) { + wait := make(chan bool) - go func() { - time.Sleep(1 * time.Second) - wait <- true - }() + go func() { + time.Sleep(1 * time.Second) + wait <- true + }() - select { - case <-ctx.Done(): - if err := ctx.Err(); err != nil { - return nil, err + select { + case <-ctx.Done(): + if err := ctx.Err(); err != nil { + return nil, err + } + break + case <-wait: } - break - case <-wait: + + return nil, nil } + err := sc.notificationService.SendIfNeeded(evalCtx) + require.NoError(sc.t, err) - return nil, nil - } - err := scenarioCtx.notificationService.SendIfNeeded(evalCtx) - require.NoError(t, err) + require.Equalf(sc.t, 0, sc.renderCount, "expected render not to be called, but it was") + require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was") + require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") + }) - require.Equalf(t, 0, scenarioCtx.renderCount, "expected render not to be called, but it was") - require.Equalf(t, 0, scenarioCtx.imageUploadCount, "expected image not to be uploaded, but it was") - require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - }) + notificationServiceScenario(t, "Given alert rule with upload image enabled and upload times out should send notification", + evalCtx, true, func(sc *scenarioContext) { + setting.AlertingNotificationTimeout = 200 * time.Millisecond + sc.uploadProvider = func(ctx context.Context, path string) (string, error) { + wait := make(chan bool) - notificationServiceScenario(t, "Given alert rule with upload image enabled and upload times out should send notification", evalCtx, true, func(scenarioCtx *scenarioContext) { - setting.AlertingNotificationTimeout = 200 * time.Millisecond - scenarioCtx.uploadProvider = func(ctx context.Context, path string) (string, error) { - wait := make(chan bool) + go func() { + time.Sleep(1 * time.Second) + wait <- true + }() - go func() { - time.Sleep(1 * time.Second) - wait <- true - }() - - select { - case <-ctx.Done(): - if err := ctx.Err(); err != nil { - return "", err + select { + case <-ctx.Done(): + if err := ctx.Err(); err != nil { + return "", err + } + break + case <-wait: } - break - case <-wait: + + return "", nil } + err := sc.notificationService.SendIfNeeded(evalCtx) + require.NoError(sc.t, err) - return "", nil - } - err := scenarioCtx.notificationService.SendIfNeeded(evalCtx) - require.NoError(t, err) - - require.Equalf(t, 1, scenarioCtx.renderCount, "expected render to be called, but wasn't") - require.Equalf(t, 0, scenarioCtx.imageUploadCount, "expected image not to be uploaded, but it was") - require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - }) + require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't") + require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was") + require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") + }) } type scenarioContext struct { + t *testing.T evalCtx *EvalContext notificationService *notificationService imageUploadCount int @@ -175,6 +183,7 @@ func notificationServiceScenario(t *testing.T, name string, evalCtx *EvalContext setting.AlertingNotificationTimeout = 30 * time.Second scenarioCtx := &scenarioContext{ + t: t, evalCtx: evalCtx, } diff --git a/pkg/services/alerting/notifiers/dingding.go b/pkg/services/alerting/notifiers/dingding.go index 0070cfdacd7..cad82229253 100644 --- a/pkg/services/alerting/notifiers/dingding.go +++ b/pkg/services/alerting/notifiers/dingding.go @@ -119,14 +119,14 @@ func (dd *DingDingNotifier) genBody(evalContext *alerting.EvalContext, messageUR } for i, match := range evalContext.EvalMatches { - message += fmt.Sprintf("\\n%2d. %s: %s", i+1, match.Metric, match.Value) + message += fmt.Sprintf("\n%2d. %s: %s", i+1, match.Metric, match.Value) } var bodyMsg map[string]interface{} if dd.MsgType == "actionCard" { // Embed the pic into the markdown directly because actionCard doesn't have a picUrl field if dd.NeedsImage() && picURL != "" { - message = "![](" + picURL + ")\\n\\n" + message + message = "![](" + picURL + ")\n\n" + message } bodyMsg = map[string]interface{}{ diff --git a/pkg/services/alerting/notifiers/discord.go b/pkg/services/alerting/notifiers/discord.go index 3801b91b286..5bbc5039b93 100644 --- a/pkg/services/alerting/notifiers/discord.go +++ b/pkg/services/alerting/notifiers/discord.go @@ -2,6 +2,7 @@ package notifiers import ( "bytes" + "fmt" "io" "mime/multipart" "os" @@ -158,6 +159,9 @@ func (dn *DiscordNotifier) Notify(evalContext *alerting.EvalContext) error { } func (dn *DiscordNotifier) embedImage(cmd *models.SendWebhookSync, imagePath string, existingJSONBody []byte) error { + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `imagePath` comes + // from the alert `evalContext` that generates the images. f, err := os.Open(imagePath) if err != nil { if os.IsNotExist(err) { @@ -168,12 +172,20 @@ func (dn *DiscordNotifier) embedImage(cmd *models.SendWebhookSync, imagePath str return err } } - - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + dn.log.Warn("Failed to close file", "path", imagePath, "err", err) + } + }() var b bytes.Buffer w := multipart.NewWriter(&b) - + defer func() { + if err := w.Close(); err != nil { + // Should be OK since we already close it on non-error path + dn.log.Warn("Failed to close multipart writer", "err", err) + } + }() fw, err := w.CreateFormField("payload_json") if err != nil { return err @@ -192,7 +204,9 @@ func (dn *DiscordNotifier) embedImage(cmd *models.SendWebhookSync, imagePath str return err } - w.Close() + if err := w.Close(); err != nil { + return fmt.Errorf("failed to close multipart writer: %w", err) + } cmd.Body = b.String() cmd.ContentType = w.FormDataContentType() diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go index cf44a02abef..909308008df 100644 --- a/pkg/services/alerting/notifiers/hipchat.go +++ b/pkg/services/alerting/notifiers/hipchat.go @@ -138,6 +138,8 @@ func (hc *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { color = "gray" case models.AlertStateAlerting: color = "red" + default: + // Handle other cases? } // Add a card with link to the dashboard diff --git a/pkg/services/alerting/notifiers/opsgenie.go b/pkg/services/alerting/notifiers/opsgenie.go index 8a9d058dc40..acfafd40c84 100644 --- a/pkg/services/alerting/notifiers/opsgenie.go +++ b/pkg/services/alerting/notifiers/opsgenie.go @@ -99,6 +99,8 @@ func (on *OpsGenieNotifier) Notify(evalContext *alerting.EvalContext) error { } case models.AlertStateAlerting: err = on.createAlert(evalContext) + default: + // Handle other cases? } return err } diff --git a/pkg/services/alerting/notifiers/pushover.go b/pkg/services/alerting/notifiers/pushover.go index 79cc97520cc..c13270205bc 100644 --- a/pkg/services/alerting/notifiers/pushover.go +++ b/pkg/services/alerting/notifiers/pushover.go @@ -292,7 +292,11 @@ func (pn *PushoverNotifier) genPushoverBody(evalContext *alerting.EvalContext, m if err != nil { return nil, b, err } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + pn.log.Warn("Failed to close file", "path", evalContext.ImageOnDiskPath, "err", err) + } + }() fw, err := w.CreateFormFile("attachment", evalContext.ImageOnDiskPath) if err != nil { diff --git a/pkg/services/alerting/notifiers/sensugo.go b/pkg/services/alerting/notifiers/sensugo.go new file mode 100644 index 00000000000..d6f7d6d21a9 --- /dev/null +++ b/pkg/services/alerting/notifiers/sensugo.go @@ -0,0 +1,196 @@ +package notifiers + +import ( + "fmt" + "strconv" + "strings" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" +) + +func init() { + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "sensugo", + Name: "Sensu Go", + Description: "Sends HTTP POST request to a Sensu Go API", + Heading: "Sensu Go Settings", + Factory: NewSensuGoNotifier, + Options: []alerting.NotifierOption{ + { + Label: "Backend URL", + Element: alerting.ElementTypeInput, + InputType: alerting.InputTypeText, + Placeholder: "http://sensu-api.local:8080", + PropertyName: "url", + Required: true, + }, + { + Label: "API Key", + Element: alerting.ElementTypeInput, + InputType: alerting.InputTypePassword, + Description: "API Key to auth to Sensu Go backend", + PropertyName: "apikey", + Required: true, + Secure: true, + }, + { + Label: "Proxy entity name", + Element: alerting.ElementTypeInput, + InputType: alerting.InputTypeText, + Description: "If empty, rule name will be used", + PropertyName: "entity", + }, + { + Label: "Check name", + Element: alerting.ElementTypeInput, + InputType: alerting.InputTypeText, + Description: "If empty, rule id will be used", + PropertyName: "check", + }, + { + Label: "Handler", + Element: alerting.ElementTypeInput, + InputType: alerting.InputTypeText, + PropertyName: "handler", + }, + { + Label: "Namespace", + Element: alerting.ElementTypeInput, + InputType: alerting.InputTypeText, + Placeholder: "default", + PropertyName: "namespace", + }, + }, + }) +} + +// NewSensuGoNotifier is the constructor for the Sensu Go Notifier. +func NewSensuGoNotifier(model *models.AlertNotification) (alerting.Notifier, error) { + url := model.Settings.Get("url").MustString() + apikey := model.DecryptedValue("apikey", model.Settings.Get("apikey").MustString()) + + if url == "" { + return nil, alerting.ValidationError{Reason: "Could not find URL property in settings"} + } + if apikey == "" { + return nil, alerting.ValidationError{Reason: "Could not find the API Key property in settings"} + } + + return &SensuGoNotifier{ + NotifierBase: NewNotifierBase(model), + URL: url, + Entity: model.Settings.Get("entity").MustString(), + Check: model.Settings.Get("check").MustString(), + Namespace: model.Settings.Get("namespace").MustString(), + Handler: model.Settings.Get("handler").MustString(), + APIKey: apikey, + log: log.New("alerting.notifier.sensugo"), + }, nil +} + +// SensuGoNotifier is responsible for sending +// alert notifications to Sensu Go. +type SensuGoNotifier struct { + NotifierBase + URL string + Entity string + Check string + Namespace string + Handler string + APIKey string + log log.Logger +} + +// Notify send alert notification to Sensu Go +func (sn *SensuGoNotifier) Notify(evalContext *alerting.EvalContext) error { + sn.log.Info("Sending Sensu Go result") + + var namespace string + + bodyJSON := simplejson.New() + // Sensu Go alerts require an entity and a check. We set it to the user-specified + // value (optional), else we fallback and use the grafana rule anme and ruleID. + if sn.Entity != "" { + bodyJSON.SetPath([]string{"entity", "metadata", "name"}, sn.Entity) + } else { + // Sensu Go alerts cannot have spaces in them + bodyJSON.SetPath([]string{"entity", "metadata", "name"}, strings.ReplaceAll(evalContext.Rule.Name, " ", "_")) + } + if sn.Check != "" { + bodyJSON.SetPath([]string{"check", "metadata", "name"}, sn.Check) + } else { + bodyJSON.SetPath([]string{"check", "metadata", "name"}, "grafana_rule_"+strconv.FormatInt(evalContext.Rule.ID, 10)) + } + // Sensu Go requires the entity in an event specify its namespace. We set it to + // the user-specified value (optional), else we fallback and use default + if sn.Namespace != "" { + bodyJSON.SetPath([]string{"entity", "metadata", "namespace"}, sn.Namespace) + namespace = sn.Namespace + } else { + bodyJSON.SetPath([]string{"entity", "metadata", "namespace"}, "default") + namespace = "default" + } + // Sensu Go needs check output + if evalContext.Rule.Message != "" { + bodyJSON.SetPath([]string{"check", "output"}, evalContext.Rule.Message) + } else { + bodyJSON.SetPath([]string{"check", "output"}, "Grafana Metric Condition Met") + } + // Sensu GO requires that the check portion of the event have an interval + bodyJSON.SetPath([]string{"check", "interval"}, 86400) + + switch evalContext.Rule.State { + case "alerting": + bodyJSON.SetPath([]string{"check", "status"}, 2) + case "no_data": + bodyJSON.SetPath([]string{"check", "status"}, 1) + default: + bodyJSON.SetPath([]string{"check", "status"}, 0) + } + + if sn.Handler != "" { + bodyJSON.SetPath([]string{"check", "handlers"}, []string{sn.Handler}) + } + + ruleURL, err := evalContext.GetRuleURL() + if err == nil { + bodyJSON.Set("ruleUrl", ruleURL) + } + + labels := map[string]string{ + "ruleName": evalContext.Rule.Name, + "ruleId": strconv.FormatInt(evalContext.Rule.ID, 10), + "ruleURL": ruleURL, + } + + if sn.NeedsImage() && evalContext.ImagePublicURL != "" { + labels["imageUrl"] = evalContext.ImagePublicURL + } + + bodyJSON.SetPath([]string{"check", "metadata", "labels"}, labels) + + body, err := bodyJSON.MarshalJSON() + if err != nil { + return err + } + + cmd := &models.SendWebhookSync{ + Url: fmt.Sprintf("%s/api/core/v2/namespaces/%s/events", strings.TrimSuffix(sn.URL, "/"), namespace), + Body: string(body), + HttpMethod: "POST", + HttpHeader: map[string]string{ + "Content-Type": "application/json", + "Authorization": fmt.Sprintf("Key %s", sn.APIKey), + }, + } + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + sn.log.Error("Failed to send Sensu Go event", "error", err, "sensugo", sn.Name) + return err + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/sensugo_test.go b/pkg/services/alerting/notifiers/sensugo_test.go new file mode 100644 index 00000000000..fe8e473fbe4 --- /dev/null +++ b/pkg/services/alerting/notifiers/sensugo_test.go @@ -0,0 +1,57 @@ +package notifiers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" +) + +func TestSensuGoNotifier(t *testing.T) { + json := `{ }` + + settingsJSON, err := simplejson.NewJson([]byte(json)) + require.NoError(t, err) + model := &models.AlertNotification{ + Name: "Sensu Go", + Type: "sensugo", + Settings: settingsJSON, + } + + _, err = NewSensuGoNotifier(model) + require.Error(t, err) + + json = ` + { + "url": "http://sensu-api.example.com:8080", + "entity": "grafana_instance_01", + "check": "grafana_rule_0", + "namespace": "default", + "handler": "myhandler", + "apikey": "abcdef0123456789abcdef" + }` + + settingsJSON, err = simplejson.NewJson([]byte(json)) + require.NoError(t, err) + model = &models.AlertNotification{ + Name: "Sensu Go", + Type: "sensugo", + Settings: settingsJSON, + } + + not, err := NewSensuGoNotifier(model) + require.NoError(t, err) + sensuGoNotifier := not.(*SensuGoNotifier) + + assert.Equal(t, "Sensu Go", sensuGoNotifier.Name) + assert.Equal(t, "sensugo", sensuGoNotifier.Type) + assert.Equal(t, "http://sensu-api.example.com:8080", sensuGoNotifier.URL) + assert.Equal(t, "grafana_instance_01", sensuGoNotifier.Entity) + assert.Equal(t, "grafana_rule_0", sensuGoNotifier.Check) + assert.Equal(t, "default", sensuGoNotifier.Namespace) + assert.Equal(t, "myhandler", sensuGoNotifier.Handler) + assert.Equal(t, "abcdef0123456789abcdef", sensuGoNotifier.APIKey) +} diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go index 3ec33a30ffa..cfd4c053cc6 100644 --- a/pkg/services/alerting/notifiers/slack.go +++ b/pkg/services/alerting/notifiers/slack.go @@ -321,7 +321,7 @@ func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { return err } if sn.Token != "" && sn.UploadImage { - err = slackFileUpload(evalContext, sn.log, "https://slack.com/api/files.upload", sn.Recipient, sn.Token) + err = sn.slackFileUpload(evalContext, sn.log, "https://slack.com/api/files.upload", sn.Recipient, sn.Token) if err != nil { return err } @@ -329,12 +329,14 @@ func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { return nil } -func slackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url string, recipient string, token string) error { +func (sn *SlackNotifier) slackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url string, recipient string, token string) error { if evalContext.ImageOnDiskPath == "" { + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `setting.HomePath` comes from Grafana's configuration file. evalContext.ImageOnDiskPath = filepath.Join(setting.HomePath, "public/img/mixed_styles.png") } log.Info("Uploading to slack via file.upload API") - headers, uploadBody, err := generateSlackBody(evalContext.ImageOnDiskPath, token, recipient) + headers, uploadBody, err := sn.generateSlackBody(evalContext.ImageOnDiskPath, token, recipient) if err != nil { return err } @@ -346,37 +348,51 @@ func slackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url stri return nil } -func generateSlackBody(file string, token string, recipient string) (map[string]string, bytes.Buffer, error) { +func (sn *SlackNotifier) generateSlackBody(path string, token string, recipient string) (map[string]string, bytes.Buffer, error) { // Slack requires all POSTs to files.upload to present // an "application/x-www-form-urlencoded" encoded querystring // See https://api.slack.com/methods/files.upload var b bytes.Buffer w := multipart.NewWriter(&b) + defer func() { + if err := w.Close(); err != nil { + // Shouldn't matter since we already close w explicitly on the non-error path + sn.log.Warn("Failed to close multipart writer", "err", err) + } + }() + // Add the generated image file - f, err := os.Open(file) + // We can ignore the gosec G304 warning on this one because `imagePath` comes + // from the alert `evalContext` that generates the images. `evalContext` in turn derives the root of the file + // path from configuration variables. + // nolint:gosec + f, err := os.Open(path) if err != nil { return nil, b, err } - defer f.Close() - fw, err := w.CreateFormFile("file", file) + defer func() { + if err := f.Close(); err != nil { + sn.log.Warn("Failed to close file", "path", path, "err", err) + } + }() + fw, err := w.CreateFormFile("file", path) if err != nil { return nil, b, err } - _, err = io.Copy(fw, f) - if err != nil { + if _, err := io.Copy(fw, f); err != nil { return nil, b, err } // Add the authorization token - err = w.WriteField("token", token) - if err != nil { + if err := w.WriteField("token", token); err != nil { return nil, b, err } // Add the channel(s) to POST to - err = w.WriteField("channels", recipient) - if err != nil { + if err := w.WriteField("channels", recipient); err != nil { return nil, b, err } - w.Close() + if err := w.Close(); err != nil { + return nil, b, fmt.Errorf("failed to close multipart writer: %w", err) + } headers := map[string]string{ "Content-Type": w.FormDataContentType(), "Authorization": "auth_token=\"" + token + "\"", diff --git a/pkg/services/alerting/notifiers/threema.go b/pkg/services/alerting/notifiers/threema.go index 0ad1342e1a5..7cce463bb65 100644 --- a/pkg/services/alerting/notifiers/threema.go +++ b/pkg/services/alerting/notifiers/threema.go @@ -127,6 +127,8 @@ func (notifier *ThreemaNotifier) Notify(evalContext *alerting.EvalContext) error stateEmoji = "\u2753\uFE0F " // Question Mark case models.AlertStateAlerting: stateEmoji = "\u26A0\uFE0F " // Warning sign + default: + // Handle other cases? } // Build message diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go index dd1d7043e1f..d9248e6a1eb 100644 --- a/pkg/services/alerting/test_notification.go +++ b/pkg/services/alerting/test_notification.go @@ -30,10 +30,10 @@ var ( ) func init() { - bus.AddHandler("alerting", handleNotificationTestCommand) + bus.AddHandlerCtx("alerting", handleNotificationTestCommand) } -func handleNotificationTestCommand(cmd *NotificationTestCommand) error { +func handleNotificationTestCommand(ctx context.Context, cmd *NotificationTestCommand) error { notifier := newNotificationService(nil) model := &models.AlertNotification{ diff --git a/pkg/services/guardian/guardian_test.go b/pkg/services/guardian/guardian_test.go index 657df6b2bdc..4303894dff8 100644 --- a/pkg/services/guardian/guardian_test.go +++ b/pkg/services/guardian/guardian_test.go @@ -444,6 +444,8 @@ func (sc *scenarioContext) verifyUpdateDashboardPermissionsShouldNotBeAllowed(pt newDefaultUserPermission(dashboardID, p), newCustomTeamPermission(dashboardID, otherTeamID, p), }...) + default: + // TODO: Handle other cases? } sc.updatePermissions = permissionList diff --git a/pkg/services/ldap/ldap.go b/pkg/services/ldap/ldap.go index 1d9b02d786e..c7084839b04 100644 --- a/pkg/services/ldap/ldap.go +++ b/pkg/services/ldap/ldap.go @@ -95,6 +95,8 @@ func (server *Server) Dial() error { if server.Config.RootCACert != "" { certPool = x509.NewCertPool() for _, caCertFile := range strings.Split(server.Config.RootCACert, " ") { + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `caCertFile` comes from ldap config. pem, err := ioutil.ReadFile(caCertFile) if err != nil { return err diff --git a/pkg/services/ldap/settings.go b/pkg/services/ldap/settings.go index 19d6dd2c872..93e94f3e016 100644 --- a/pkg/services/ldap/settings.go +++ b/pkg/services/ldap/settings.go @@ -107,10 +107,7 @@ func GetConfig() (*Config, error) { loadingMutex.Lock() defer loadingMutex.Unlock() - var err error - config, err = readConfig(setting.LDAPConfigFile) - - return config, err + return readConfig(setting.LDAPConfigFile) } func readConfig(configFile string) (*Config, error) { @@ -118,6 +115,8 @@ func readConfig(configFile string) (*Config, error) { logger.Info("LDAP enabled, reading config file", "file", configFile) + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `filename` comes from grafana configuration file fileBytes, err := ioutil.ReadFile(configFile) if err != nil { return nil, errutil.Wrap("Failed to load LDAP config file", err) diff --git a/pkg/services/librarypanels/librarypanels.go b/pkg/services/librarypanels/librarypanels.go new file mode 100644 index 00000000000..d48132444e1 --- /dev/null +++ b/pkg/services/librarypanels/librarypanels.go @@ -0,0 +1,59 @@ +package librarypanels + +import ( + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" +) + +// LibraryPanelService is the service for the Panel Library feature. +type LibraryPanelService struct { + Cfg *setting.Cfg `inject:""` + log log.Logger +} + +func init() { + registry.RegisterService(&LibraryPanelService{}) +} + +// Init initializes the LibraryPanel service +func (pl *LibraryPanelService) Init() error { + pl.log = log.New("library_panel") + + return nil +} + +// IsEnabled returns true if the Panel Library feature is enabled for this instance. +func (pl *LibraryPanelService) IsEnabled() bool { + if pl.Cfg == nil { + return false + } + + return pl.Cfg.IsPanelLibraryEnabled() +} + +// AddMigration defines database migrations. +// If Panel Library is not enabled does nothing. +func (pl *LibraryPanelService) AddMigration(mg *migrator.Migrator) { + if !pl.IsEnabled() { + return + } + + libraryPanelV1 := migrator.Table{ + Name: "library_panel", + Columns: []*migrator.Column{ + {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "org_id", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "folder_id", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "title", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, + {Name: "data", Type: migrator.DB_Text, Nullable: false}, + {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "created_by", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "updated_by", Type: migrator.DB_BigInt, Nullable: false}, + }, + } + + mg.AddMigration("create library_panel table v1", migrator.NewAddTableMigration(libraryPanelV1)) +} diff --git a/pkg/services/live/features/dashboard.go b/pkg/services/live/features/dashboard.go index 4f8cba42921..45db283aa35 100644 --- a/pkg/services/live/features/dashboard.go +++ b/pkg/services/live/features/dashboard.go @@ -48,7 +48,11 @@ func (h *DashboardHandler) publish(event dashboardEvent) error { if err != nil { return err } - return h.Publisher("grafana/dashboard/"+event.UID, msg) + err = h.Publisher("grafana/dashboard/uid/"+event.UID, msg) + if err != nil { + return err + } + return h.Publisher("grafana/dashboard/changes", msg) } // DashboardSaved will broadcast to all connected dashboards diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 31dd91e6adb..04dd70ee918 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -286,5 +286,7 @@ func handleLog(msg centrifuge.LogEntry) { loggerCF.Error(msg.Message, arr...) case centrifuge.LogLevelInfo: loggerCF.Info(msg.Message, arr...) + case centrifuge.LogLevelNone: + loggerCF.Debug(msg.Message, arr...) } } diff --git a/pkg/services/login/login.go b/pkg/services/login/login.go index 83e8dfc9845..1eda6bf2df1 100644 --- a/pkg/services/login/login.go +++ b/pkg/services/login/login.go @@ -40,7 +40,6 @@ func (ls *LoginService) UpsertUser(cmd *models.UpsertUserCommand) error { Email: extUser.Email, Login: extUser.Login, } - if err := bus.Dispatch(userQuery); err != nil { if !errors.Is(err, models.ErrUserNotFound) { return err diff --git a/pkg/services/ngalert/alert_definition.go b/pkg/services/ngalert/alert_definition.go index 0deaf7b43c7..b521cc80a7d 100644 --- a/pkg/services/ngalert/alert_definition.go +++ b/pkg/services/ngalert/alert_definition.go @@ -5,7 +5,7 @@ import "fmt" // preSave sets datasource and loads the updated model for each alert query. func (alertDefinition *AlertDefinition) preSave() error { for i, q := range alertDefinition.Data { - err := q.PreSave(alertDefinition.OrgId) + err := q.PreSave() if err != nil { return fmt.Errorf("invalid alert query %s: %w", q.RefID, err) } diff --git a/pkg/services/ngalert/eval/alert_query.go b/pkg/services/ngalert/eval/alert_query.go index 170e33f410d..76b9b696bf9 100644 --- a/pkg/services/ngalert/eval/alert_query.go +++ b/pkg/services/ngalert/eval/alert_query.go @@ -237,18 +237,6 @@ func (aq *AlertQuery) getModel() ([]byte, error) { return model, nil } -func (aq *AlertQuery) setOrgID(orgID int64) error { - if aq.modelProps == nil { - err := aq.setModelProps() - if err != nil { - return err - } - } - - aq.modelProps["orgId"] = orgID - return nil -} - func (aq *AlertQuery) setQueryType() error { if aq.modelProps == nil { err := aq.setModelProps() @@ -272,12 +260,7 @@ func (aq *AlertQuery) setQueryType() error { // PreSave sets query's properties. // It should be called before being saved. -func (aq *AlertQuery) PreSave(orgID int64) error { - err := aq.setOrgID(orgID) - if err != nil { - return fmt.Errorf("failed to set orgId to query model: %w", err) - } - +func (aq *AlertQuery) PreSave() error { if err := aq.setDatasource(); err != nil { return fmt.Errorf("failed to set datasource to query model: %w", err) } diff --git a/pkg/services/ngalert/eval/eval.go b/pkg/services/ngalert/eval/eval.go index 87c96fa55d9..b37109b09b0 100644 --- a/pkg/services/ngalert/eval/eval.go +++ b/pkg/services/ngalert/eval/eval.go @@ -96,11 +96,12 @@ func (c *Condition) Execute(ctx AlertExecCtx, fromStr, toStr string) (*Execution result := ExecutionResults{} if !c.IsValid() { return nil, fmt.Errorf("invalid conditions") + // TODO: Things probably } queryDataReq := &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ - // TODO: Things probably + OrgID: ctx.SignedInUser.OrgId, }, Queries: []backend.DataQuery{}, } diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index df2d6c7bb39..92eb9a10eae 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -19,6 +19,9 @@ type configReader struct { func (cr *configReader) parseConfigs(file os.FileInfo) ([]*config, error) { filename, _ := filepath.Abs(filepath.Join(cr.path, file.Name())) + + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `filename` comes from ps.Cfg.ProvisioningPath yamlFile, err := ioutil.ReadFile(filename) if err != nil { return nil, err diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index 709dba0bb3f..576b62d4cf0 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -343,11 +343,17 @@ type dashboardJSONFile struct { } func (fr *FileReader) readDashboardFromFile(path string, lastModified time.Time, folderID int64) (*dashboardJSONFile, error) { + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `path` comes from the provisioning configuration file. reader, err := os.Open(path) if err != nil { return nil, err } - defer reader.Close() + defer func() { + if err := reader.Close(); err != nil { + fr.log.Warn("Failed to close file", "path", path, "err", err) + } + }() all, err := ioutil.ReadAll(reader) if err != nil { diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go index 52a5aa430b8..191ab962342 100644 --- a/pkg/services/provisioning/datasources/config_reader.go +++ b/pkg/services/provisioning/datasources/config_reader.go @@ -49,6 +49,9 @@ func (cr *configReader) readConfig(path string) ([]*configs, error) { func (cr *configReader) parseDatasourceConfig(path string, file os.FileInfo) (*configs, error) { filename, _ := filepath.Abs(filepath.Join(path, file.Name())) + + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `filename` comes from ps.Cfg.ProvisioningPath yamlFile, err := ioutil.ReadFile(filename) if err != nil { return nil, err diff --git a/pkg/services/provisioning/notifiers/config_reader.go b/pkg/services/provisioning/notifiers/config_reader.go index c2d40270bb4..17886adba36 100644 --- a/pkg/services/provisioning/notifiers/config_reader.go +++ b/pkg/services/provisioning/notifiers/config_reader.go @@ -61,6 +61,9 @@ func (cr *configReader) readConfig(path string) ([]*notificationsAsConfig, error func (cr *configReader) parseNotificationConfig(path string, file os.FileInfo) (*notificationsAsConfig, error) { filename, _ := filepath.Abs(filepath.Join(path, file.Name())) + + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `filename` comes from ps.Cfg.ProvisioningPath yamlFile, err := ioutil.ReadFile(filename) if err != nil { return nil, err diff --git a/pkg/services/provisioning/plugins/config_reader.go b/pkg/services/provisioning/plugins/config_reader.go index ec95c40cf78..fe5175cf066 100644 --- a/pkg/services/provisioning/plugins/config_reader.go +++ b/pkg/services/provisioning/plugins/config_reader.go @@ -69,6 +69,8 @@ func (cr *configReaderImpl) parsePluginConfig(path string, file os.FileInfo) (*p return nil, err } + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `filename` comes from ps.Cfg.ProvisioningPath yamlFile, err := ioutil.ReadFile(filename) if err != nil { return nil, err diff --git a/pkg/services/rendering/http_mode.go b/pkg/services/rendering/http_mode.go index 23bb912a75f..7cc30c891a6 100644 --- a/pkg/services/rendering/http_mode.go +++ b/pkg/services/rendering/http_mode.go @@ -96,7 +96,12 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, renderKey string, if err != nil { return nil, err } - defer out.Close() + defer func() { + if err := out.Close(); err != nil { + // We already close the file explicitly in the non-error path, so shouldn't be a problem + rs.log.Warn("Failed to close file", "path", filePath, "err", err) + } + }() _, err = io.Copy(out, resp.Body) if err != nil { // check that we didn't timeout while receiving the response. @@ -107,6 +112,9 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, renderKey string, rs.log.Error("Remote rendering request failed", "error", err) return nil, fmt.Errorf("remote rendering request failed: %w", err) } + if err := out.Close(); err != nil { + return nil, fmt.Errorf("failed to write to %q: %w", filePath, err) + } return &RenderResult{FilePath: filePath}, err } diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go index 8e53f6add53..091d2349157 100644 --- a/pkg/services/rendering/rendering.go +++ b/pkg/services/rendering/rendering.go @@ -232,6 +232,8 @@ func (rs *RenderingService) getURL(path string) string { protocol = "http" case setting.HTTP2Scheme, setting.HTTPSScheme: protocol = "https" + default: + // TODO: Handle other schemes? } subPath := "" diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index 19b57ff93f0..0bf54752876 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -5,56 +5,45 @@ import ( . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" + "github.com/stretchr/testify/require" "xorm.io/xorm" - - . "github.com/smartystreets/goconvey/convey" ) func TestMigrations(t *testing.T) { - testDBs := []sqlutil.TestDB{ - sqlutil.SQLite3TestDB(), - } + testDB := sqlutil.SQLite3TestDB() + const query = `select count(*) as count from migration_log` + result := struct{ Count int }{} - for _, testDB := range testDBs { - sql := `select count(*) as count from migration_log` - r := struct { - Count int64 - }{} + x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) + require.NoError(t, err) - Convey("Initial "+testDB.DriverName+" migration", t, func() { - x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) - So(err, ShouldBeNil) + err = NewDialect(x).CleanDB() + require.NoError(t, err) - err = NewDialect(x).CleanDB() - So(err, ShouldBeNil) + _, err = x.SQL(query).Get(&result) + require.Error(t, err) - _, err = x.SQL(sql).Get(&r) - So(err, ShouldNotBeNil) + mg := NewMigrator(x) + AddMigrations(mg) + expectedMigrations := mg.MigrationsCount() - mg := NewMigrator(x) - AddMigrations(mg) + err = mg.Start() + require.NoError(t, err) - err = mg.Start() - So(err, ShouldBeNil) + has, err := x.SQL(query).Get(&result) + require.NoError(t, err) + require.True(t, has) - has, err := x.SQL(sql).Get(&r) - So(err, ShouldBeNil) - So(has, ShouldBeTrue) - // we currently skip to migrations. We should rewrite skipped migrations to write in the log as well. - // until then we have to keep this - expectedMigrations := mg.MigrationsCount() - So(r.Count, ShouldEqual, expectedMigrations) + require.Equal(t, expectedMigrations, result.Count) - mg = NewMigrator(x) - AddMigrations(mg) + mg = NewMigrator(x) + AddMigrations(mg) - err = mg.Start() - So(err, ShouldBeNil) + err = mg.Start() + require.NoError(t, err) - has, err = x.SQL(sql).Get(&r) - So(err, ShouldBeNil) - So(has, ShouldBeTrue) - So(r.Count, ShouldEqual, expectedMigrations) - }) - } + has, err = x.SQL(query).Get(&result) + require.NoError(t, err) + require.True(t, has) + require.Equal(t, expectedMigrations, result.Count) } diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go index 2c37fc93cc1..f924de731f3 100644 --- a/pkg/services/sqlstore/org.go +++ b/pkg/services/sqlstore/org.go @@ -232,7 +232,6 @@ func verifyExistingOrg(sess *DBSession, orgId int64) error { func getOrCreateOrg(sess *DBSession, orgName string) (int64, error) { var org models.Org - if setting.AutoAssignOrg { has, err := sess.Where("id=?", setting.AutoAssignOrgId).Get(&org) if err != nil { @@ -241,15 +240,16 @@ func getOrCreateOrg(sess *DBSession, orgName string) (int64, error) { if has { return org.Id, nil } - if setting.AutoAssignOrgId == 1 { - org.Name = mainOrgName - org.Id = int64(setting.AutoAssignOrgId) - } else { + + if setting.AutoAssignOrgId != 1 { sqlog.Error("Could not create user: organization ID does not exist", "orgID", setting.AutoAssignOrgId) return 0, fmt.Errorf("could not create user: organization ID %d does not exist", setting.AutoAssignOrgId) } + + org.Name = mainOrgName + org.Id = int64(setting.AutoAssignOrgId) } else { org.Name = orgName } diff --git a/pkg/setting/expanders.go b/pkg/setting/expanders.go index 021722ba998..c6f9a61f582 100644 --- a/pkg/setting/expanders.go +++ b/pkg/setting/expanders.go @@ -138,6 +138,8 @@ func (e fileExpander) Expand(s string) (string, error) { return "", err } + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `s` comes from configuration section keys f, err := ioutil.ReadFile(s) if err != nil { return "", err diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 75b88df4f35..20c5691ffed 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -334,10 +334,16 @@ func (cfg Cfg) IsNgAlertEnabled() bool { return cfg.FeatureToggles["ngalert"] } +// IsHTTPRequestHistogramEnabled returns whether the http_request_histogram feature is enabled. func (cfg Cfg) IsHTTPRequestHistogramEnabled() bool { return cfg.FeatureToggles["http_request_histogram"] } +// IsPanelLibraryEnabled returns whether the panel library feature is enabled. +func (cfg Cfg) IsPanelLibraryEnabled() bool { + return cfg.FeatureToggles["panelLibrary"] +} + type CommandLineArgs struct { Config string HomePath string diff --git a/pkg/tsdb/azuremonitor/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/azuremonitor-datasource.go index 6980ed3690a..9b0ef6ba8bc 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-datasource.go @@ -421,7 +421,7 @@ func toGrafanaUnit(unit string) string { return "cps" case "Percent": return "percent" - case "Milliseconds": + case "MilliSeconds": return "ms" case "Seconds": return "s" diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index d0198150617..acefa9deb70 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -153,7 +153,7 @@ func (e *cloudWatchExecutor) newSession(region string) (*session.Session, error) } duration := stscreds.DefaultDuration - expiration := time.Now().Add(duration) + expiration := time.Now().UTC().Add(duration) if dsInfo.AssumeRoleARN != "" { // We should assume a role in AWS plog.Debug("Trying to assume role in AWS", "arn", dsInfo.AssumeRoleARN) diff --git a/pkg/tsdb/elasticsearch/client/index_pattern.go b/pkg/tsdb/elasticsearch/client/index_pattern.go index ffd87af017c..48c15442d30 100644 --- a/pkg/tsdb/elasticsearch/client/index_pattern.go +++ b/pkg/tsdb/elasticsearch/client/index_pattern.go @@ -247,72 +247,99 @@ var datePatternReplacements = map[string]string{ } func formatDate(t time.Time, pattern string) string { - var datePattern string + var formattedDatePatterns []string + var bases []string base := "" - ltr := false + isBaseFirst := false - if strings.HasPrefix(pattern, "[") { - parts := strings.Split(strings.TrimLeft(pattern, "["), "]") - base = parts[0] - if len(parts) == 2 { - datePattern = parts[1] + baseStart := strings.Index(pattern, "[") + for baseStart != -1 { + var datePattern string + + baseEnd := strings.Index(pattern, "]") + base = pattern[baseStart+1 : baseEnd] + bases = append(bases, base) + if baseStart == 0 { + isBaseFirst = true } else { - datePattern = base - base = "" + datePattern = pattern[:baseStart] + formatted := t.Format(patternToLayout(datePattern)) + formattedDatePatterns = append(formattedDatePatterns, formatted) } - ltr = true - } else if strings.HasSuffix(pattern, "]") { - parts := strings.Split(strings.TrimRight(pattern, "]"), "[") - datePattern = parts[0] - if len(parts) == 2 { - base = parts[1] + + if len(pattern) <= baseEnd+1 { + break + } + pattern = pattern[baseEnd+1:] + + baseStart = strings.Index(pattern, "[") + if baseStart == -1 { + datePattern = pattern } else { - base = "" + datePattern = pattern[:baseStart] + pattern = pattern[baseStart:] + baseStart = 0 } - ltr = false + formatted := t.Format(patternToLayout(datePattern)) + formattedDatePatterns = append(formattedDatePatterns, formatted) } - formatted := t.Format(patternToLayout(datePattern)) + isoYear, isoWeek := t.ISOWeek() + isoYearShort := fmt.Sprintf("%d", isoYear)[2:4] + day := t.Weekday() + dayOfWeekIso := int(day) + if day == time.Sunday { + dayOfWeekIso = 7 + } + var quarter int + switch t.Month() { + case time.January, time.February, time.March: + quarter = 1 + case time.April, time.May, time.June: + quarter = 2 + case time.July, time.August, time.September: + quarter = 3 + default: + quarter = 4 + } - if strings.Contains(formatted, "", fmt.Sprintf("%d", isoYear)) formatted = strings.ReplaceAll(formatted, "", isoYearShort) formatted = strings.ReplaceAll(formatted, "", fmt.Sprintf("%02d", isoWeek)) - formatted = strings.ReplaceAll(formatted, "", fmt.Sprintf("%d", t.Unix())) - - day := t.Weekday() - dayOfWeekIso := int(day) - if day == time.Sunday { - dayOfWeekIso = 7 - } - formatted = strings.ReplaceAll(formatted, "", fmt.Sprintf("%d", day)) formatted = strings.ReplaceAll(formatted, "", fmt.Sprintf("%d", dayOfWeekIso)) formatted = strings.ReplaceAll(formatted, "", fmt.Sprintf("%d", t.YearDay())) - - quarter := 4 - - switch t.Month() { - case time.January, time.February, time.March: - quarter = 1 - case time.April, time.May, time.June: - quarter = 2 - case time.July, time.August, time.September: - quarter = 3 - } - formatted = strings.ReplaceAll(formatted, "", fmt.Sprintf("%d", quarter)) formatted = strings.ReplaceAll(formatted, "", fmt.Sprintf("%d", t.Hour())) + + formattedDatePatterns[i] = formatted } - if ltr { - return base + formatted + var fullPattern []string + var i int + + minLen := min(len(formattedDatePatterns), len(bases)) + for i = 0; i < minLen; i++ { + if isBaseFirst { + fullPattern = append(fullPattern, bases[i], formattedDatePatterns[i]) + } else { + fullPattern = append(fullPattern, formattedDatePatterns[i], bases[i]) + } + } + formattedDatePatterns = formattedDatePatterns[i:] + bases = bases[i:] + if len(bases) == 0 { + fullPattern = append(fullPattern, formattedDatePatterns...) + } else { + fullPattern = append(fullPattern, bases...) } - return formatted + base + return strings.Join(fullPattern, "") } func patternToLayout(pattern string) string { @@ -329,3 +356,10 @@ func patternToLayout(pattern string) string { return pattern } + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/tsdb/elasticsearch/client/index_pattern_test.go b/pkg/tsdb/elasticsearch/client/index_pattern_test.go index a3a056da04f..bf413fc2605 100644 --- a/pkg/tsdb/elasticsearch/client/index_pattern_test.go +++ b/pkg/tsdb/elasticsearch/client/index_pattern_test.go @@ -77,6 +77,16 @@ func TestIndexPattern(t *testing.T) { So(indices[0], ShouldEqual, "2018-data") }) + indexPatternScenario(intervalDaily, "YYYY[-data-]MM.DD", tsdb.NewTimeRange(from, to), func(indices []string) { + So(indices, ShouldHaveLength, 1) + So(indices[0], ShouldEqual, "2018-data-05.15") + }) + + indexPatternScenario(intervalDaily, "[data-]YYYY[-moredata-]MM.DD", tsdb.NewTimeRange(from, to), func(indices []string) { + So(indices, ShouldHaveLength, 1) + So(indices[0], ShouldEqual, "data-2018-moredata-05.15") + }) + Convey("Should return 01 week", func() { from = fmt.Sprintf("%d", time.Date(2018, 1, 15, 17, 50, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond)) to = fmt.Sprintf("%d", time.Date(2018, 1, 15, 17, 55, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond)) diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go index 1bcd3630eff..a256bde330e 100644 --- a/pkg/tsdb/postgres/macros.go +++ b/pkg/tsdb/postgres/macros.go @@ -42,11 +42,9 @@ func (m *postgresMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.Tim if groups[1] == "__timeGroup" { if index := strings.Index(sql, groups[0]); index >= 0 { index += len(groups[0]) - if len(sql) > index { - // check for character after macro expression - if sql[index] == ',' { - groups[1] = "__timeGroupAlias" - } + // check for character after macro expression + if len(sql) > index && sql[index] == ',' { + groups[1] = "__timeGroupAlias" } } } diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index a8efe60b179..1eb1da6e8cf 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -218,9 +218,9 @@ func parseResponse(value model.Value, query *PrometheusQuery) (*tsdb.QueryResult return queryRes, nil } +// IsAPIError returns whether err is or wraps a Prometheus error. func IsAPIError(err error) bool { - // Have to use errors.As to compare Prometheus errors, since errors.Is won't work due to Prometheus - // errors being pointers and errors.Is ends up comparing them by pointer address + // Check if the right error type is in err's chain. var e *apiv1.Error return errors.As(err, &e) } diff --git a/pkg/tsdb/prometheus/prometheus_test.go b/pkg/tsdb/prometheus/prometheus_test.go index 0eec856be0b..43338258781 100644 --- a/pkg/tsdb/prometheus/prometheus_test.go +++ b/pkg/tsdb/prometheus/prometheus_test.go @@ -4,156 +4,125 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" - - "github.com/grafana/grafana/pkg/components/simplejson" p "github.com/prometheus/common/model" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/require" ) func TestPrometheus(t *testing.T) { - Convey("Prometheus", t, func() { - dsInfo := &models.DataSource{ - JsonData: simplejson.New(), + dsInfo := &models.DataSource{ + JsonData: simplejson.New(), + } + + t.Run("converting metric name", func(t *testing.T) { + metric := map[p.LabelName]p.LabelValue{ + p.LabelName("app"): p.LabelValue("backend"), + p.LabelName("device"): p.LabelValue("mobile"), } - Convey("converting metric name", func() { - metric := map[p.LabelName]p.LabelValue{ - p.LabelName("app"): p.LabelValue("backend"), - p.LabelName("device"): p.LabelValue("mobile"), - } + query := &PrometheusQuery{ + LegendFormat: "legend {{app}} {{ device }} {{broken}}", + } - query := &PrometheusQuery{ - LegendFormat: "legend {{app}} {{ device }} {{broken}}", - } + require.Equal(t, "legend backend mobile ", formatLegend(metric, query)) + }) - So(formatLegend(metric, query), ShouldEqual, "legend backend mobile ") - }) + t.Run("build full series name", func(t *testing.T) { + metric := map[p.LabelName]p.LabelValue{ + p.LabelName(p.MetricNameLabel): p.LabelValue("http_request_total"), + p.LabelName("app"): p.LabelValue("backend"), + p.LabelName("device"): p.LabelValue("mobile"), + } - Convey("build full series name", func() { - metric := map[p.LabelName]p.LabelValue{ - p.LabelName(p.MetricNameLabel): p.LabelValue("http_request_total"), - p.LabelName("app"): p.LabelValue("backend"), - p.LabelName("device"): p.LabelValue("mobile"), - } + query := &PrometheusQuery{ + LegendFormat: "", + } - query := &PrometheusQuery{ - LegendFormat: "", - } + require.Equal(t, `http_request_total{app="backend", device="mobile"}`, formatLegend(metric, query)) + }) - So(formatLegend(metric, query), ShouldEqual, `http_request_total{app="backend", device="mobile"}`) - }) - - Convey("parsing query model with step", func() { - json := `{ + t.Run("parsing query model with step", func(t *testing.T) { + json := `{ "expr": "go_goroutines", "format": "time_series", "refId": "A" }` - jsonModel, _ := simplejson.NewJson([]byte(json)) - queryContext := &tsdb.TsdbQuery{} - queryModels := []*tsdb.Query{ - {Model: jsonModel}, - } + jsonModel, _ := simplejson.NewJson([]byte(json)) + queryContext := &tsdb.TsdbQuery{} + queryModels := []*tsdb.Query{ + {Model: jsonModel}, + } - Convey("with 48h time range", func() { - queryContext.TimeRange = tsdb.NewTimeRange("12h", "now") + queryContext.TimeRange = tsdb.NewTimeRange("12h", "now") - models, err := parseQuery(dsInfo, queryModels, queryContext) - So(err, ShouldBeNil) + models, err := parseQuery(dsInfo, queryModels, queryContext) + require.NoError(t, err) + require.Equal(t, time.Second*30, models[0].Step) + }) - model := models[0] - So(model.Step, ShouldEqual, time.Second*30) - }) - }) - - Convey("parsing query model without step parameter", func() { - json := `{ + t.Run("parsing query model without step parameter", func(t *testing.T) { + json := `{ "expr": "go_goroutines", "format": "time_series", "intervalFactor": 1, "refId": "A" }` - jsonModel, _ := simplejson.NewJson([]byte(json)) - queryContext := &tsdb.TsdbQuery{} - queryModels := []*tsdb.Query{ - {Model: jsonModel}, - } + jsonModel, _ := simplejson.NewJson([]byte(json)) + queryContext := &tsdb.TsdbQuery{} + queryModels := []*tsdb.Query{ + {Model: jsonModel}, + } - Convey("with 48h time range", func() { - queryContext.TimeRange = tsdb.NewTimeRange("48h", "now") + queryContext.TimeRange = tsdb.NewTimeRange("48h", "now") + models, err := parseQuery(dsInfo, queryModels, queryContext) + require.NoError(t, err) + require.Equal(t, time.Minute*2, models[0].Step) - models, err := parseQuery(dsInfo, queryModels, queryContext) + queryContext.TimeRange = tsdb.NewTimeRange("1h", "now") + models, err = parseQuery(dsInfo, queryModels, queryContext) + require.NoError(t, err) + require.Equal(t, time.Second*15, models[0].Step) + }) - So(err, ShouldBeNil) - - model := models[0] - So(model.Step, ShouldEqual, time.Minute*2) - }) - - Convey("with 1h time range", func() { - queryContext.TimeRange = tsdb.NewTimeRange("1h", "now") - - models, err := parseQuery(dsInfo, queryModels, queryContext) - - So(err, ShouldBeNil) - - model := models[0] - So(model.Step, ShouldEqual, time.Second*15) - }) - }) - - Convey("parsing query model with intervalFactor", func() { - Convey("high intervalFactor", func() { - json := `{ + t.Run("parsing query model with high intervalFactor", func(t *testing.T) { + json := `{ "expr": "go_goroutines", "format": "time_series", "intervalFactor": 10, "refId": "A" }` - jsonModel, _ := simplejson.NewJson([]byte(json)) - queryContext := &tsdb.TsdbQuery{} - queryModels := []*tsdb.Query{ - {Model: jsonModel}, - } + jsonModel, _ := simplejson.NewJson([]byte(json)) + queryContext := &tsdb.TsdbQuery{} + queryModels := []*tsdb.Query{ + {Model: jsonModel}, + } - Convey("with 48h time range", func() { - queryContext.TimeRange = tsdb.NewTimeRange("48h", "now") + queryContext.TimeRange = tsdb.NewTimeRange("48h", "now") - models, err := parseQuery(dsInfo, queryModels, queryContext) + models, err := parseQuery(dsInfo, queryModels, queryContext) + require.NoError(t, err) + require.Equal(t, time.Minute*20, models[0].Step) + }) - So(err, ShouldBeNil) - - model := models[0] - So(model.Step, ShouldEqual, time.Minute*20) - }) - }) - - Convey("low intervalFactor", func() { - json := `{ + t.Run("parsing query model with low intervalFactor", func(t *testing.T) { + json := `{ "expr": "go_goroutines", "format": "time_series", "intervalFactor": 1, "refId": "A" }` - jsonModel, _ := simplejson.NewJson([]byte(json)) - queryContext := &tsdb.TsdbQuery{} - queryModels := []*tsdb.Query{ - {Model: jsonModel}, - } + jsonModel, _ := simplejson.NewJson([]byte(json)) + queryContext := &tsdb.TsdbQuery{} + queryModels := []*tsdb.Query{ + {Model: jsonModel}, + } - Convey("with 48h time range", func() { - queryContext.TimeRange = tsdb.NewTimeRange("48h", "now") + queryContext.TimeRange = tsdb.NewTimeRange("48h", "now") - models, err := parseQuery(dsInfo, queryModels, queryContext) - - So(err, ShouldBeNil) - - model := models[0] - So(model.Step, ShouldEqual, time.Minute*2) - }) - }) - }) + models, err := parseQuery(dsInfo, queryModels, queryContext) + require.NoError(t, err) + require.Equal(t, time.Minute*2, models[0].Step) }) } diff --git a/pkg/tsdb/sqleng/sql_engine_test.go b/pkg/tsdb/sqleng/sql_engine_test.go index 0c80ec64534..06b63fb8faa 100644 --- a/pkg/tsdb/sqleng/sql_engine_test.go +++ b/pkg/tsdb/sqleng/sql_engine_test.go @@ -9,317 +9,304 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" - - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/require" ) func TestSqlEngine(t *testing.T) { - Convey("SqlEngine", t, func() { - dt := time.Date(2018, 3, 14, 21, 20, 6, int(527345*time.Microsecond), time.UTC) - earlyDt := time.Date(1970, 3, 14, 21, 20, 6, int(527345*time.Microsecond), time.UTC) + dt := time.Date(2018, 3, 14, 21, 20, 6, int(527345*time.Microsecond), time.UTC) + earlyDt := time.Date(1970, 3, 14, 21, 20, 6, int(527345*time.Microsecond), time.UTC) - Convey("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func() { - from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) - to := from.Add(5 * time.Minute) - timeRange := tsdb.NewFakeTimeRange("5m", "now", to) - query := &tsdb.Query{DataSource: &models.DataSource{}, Model: simplejson.New()} + t.Run("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func(t *testing.T) { + from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) + to := from.Add(5 * time.Minute) + timeRange := tsdb.NewFakeTimeRange("5m", "now", to) + query := &tsdb.Query{DataSource: &models.DataSource{}, Model: simplejson.New()} - Convey("interpolate $__interval", func() { - sql, err := Interpolate(query, timeRange, "select $__interval ") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select 1m ") - }) - - Convey("interpolate $__interval in $__timeGroup", func() { - sql, err := Interpolate(query, timeRange, "select $__timeGroupAlias(time,$__interval)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select $__timeGroupAlias(time,1m)") - }) - - Convey("interpolate $__interval_ms", func() { - sql, err := Interpolate(query, timeRange, "select $__interval_ms ") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select 60000 ") - }) - - Convey("interpolate __unixEpochFrom function", func() { - sql, err := Interpolate(query, timeRange, "select $__unixEpochFrom()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix())) - }) - - Convey("interpolate __unixEpochTo function", func() { - sql, err := Interpolate(query, timeRange, "select $__unixEpochTo()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) - }) + t.Run("interpolate $__interval", func(t *testing.T) { + sql, err := Interpolate(query, timeRange, "select $__interval ") + require.NoError(t, err) + require.Equal(t, "select 1m ", sql) }) - Convey("Given row values with time.Time as time columns", func() { - var nilPointer *time.Time - - fixtures := make([]interface{}, 5) - fixtures[0] = dt - fixtures[1] = &dt - fixtures[2] = earlyDt - fixtures[3] = &earlyDt - fixtures[4] = nilPointer - - for i := range fixtures { - ConvertSqlTimeColumnToEpochMs(fixtures, i) - } - - Convey("When converting them should return epoch time with millisecond precision ", func() { - expected := float64(dt.UnixNano()) / float64(time.Millisecond) - expectedEarly := float64(earlyDt.UnixNano()) / float64(time.Millisecond) - - So(fixtures[0].(float64), ShouldEqual, expected) - So(fixtures[1].(float64), ShouldEqual, expected) - So(fixtures[2].(float64), ShouldEqual, expectedEarly) - So(fixtures[3].(float64), ShouldEqual, expectedEarly) - So(fixtures[4], ShouldBeNil) - }) + t.Run("interpolate $__interval in $__timeGroup", func(t *testing.T) { + sql, err := Interpolate(query, timeRange, "select $__timeGroupAlias(time,$__interval)") + require.NoError(t, err) + require.Equal(t, "select $__timeGroupAlias(time,1m)", sql) }) - Convey("Given row values with int64 as time columns", func() { - tSeconds := dt.Unix() - tMilliseconds := dt.UnixNano() / 1e6 - tNanoSeconds := dt.UnixNano() - var nilPointer *int64 - - fixtures := make([]interface{}, 7) - fixtures[0] = tSeconds - fixtures[1] = &tSeconds - fixtures[2] = tMilliseconds - fixtures[3] = &tMilliseconds - fixtures[4] = tNanoSeconds - fixtures[5] = &tNanoSeconds - fixtures[6] = nilPointer - - for i := range fixtures { - ConvertSqlTimeColumnToEpochMs(fixtures, i) - } - - Convey("When converting them should return epoch time with millisecond precision ", func() { - So(fixtures[0].(int64), ShouldEqual, tSeconds*1e3) - So(fixtures[1].(int64), ShouldEqual, tSeconds*1e3) - So(fixtures[2].(int64), ShouldEqual, tMilliseconds) - So(fixtures[3].(int64), ShouldEqual, tMilliseconds) - So(fixtures[4].(int64), ShouldEqual, tMilliseconds) - So(fixtures[5].(int64), ShouldEqual, tMilliseconds) - So(fixtures[6], ShouldBeNil) - }) + t.Run("interpolate $__interval_ms", func(t *testing.T) { + sql, err := Interpolate(query, timeRange, "select $__interval_ms ") + require.NoError(t, err) + require.Equal(t, "select 60000 ", sql) }) - Convey("Given row values with uin64 as time columns", func() { - tSeconds := uint64(dt.Unix()) - tMilliseconds := uint64(dt.UnixNano() / 1e6) - tNanoSeconds := uint64(dt.UnixNano()) - var nilPointer *uint64 - - fixtures := make([]interface{}, 7) - fixtures[0] = tSeconds - fixtures[1] = &tSeconds - fixtures[2] = tMilliseconds - fixtures[3] = &tMilliseconds - fixtures[4] = tNanoSeconds - fixtures[5] = &tNanoSeconds - fixtures[6] = nilPointer - - for i := range fixtures { - ConvertSqlTimeColumnToEpochMs(fixtures, i) - } - - Convey("When converting them should return epoch time with millisecond precision ", func() { - So(fixtures[0].(int64), ShouldEqual, tSeconds*1e3) - So(fixtures[1].(int64), ShouldEqual, tSeconds*1e3) - So(fixtures[2].(int64), ShouldEqual, tMilliseconds) - So(fixtures[3].(int64), ShouldEqual, tMilliseconds) - So(fixtures[4].(int64), ShouldEqual, tMilliseconds) - So(fixtures[5].(int64), ShouldEqual, tMilliseconds) - So(fixtures[6], ShouldBeNil) - }) + t.Run("interpolate __unixEpochFrom function", func(t *testing.T) { + sql, err := Interpolate(query, timeRange, "select $__unixEpochFrom()") + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("select %d", from.Unix()), sql) }) - Convey("Given row values with int32 as time columns", func() { - tSeconds := int32(dt.Unix()) - var nilInt *int32 - - fixtures := make([]interface{}, 3) - fixtures[0] = tSeconds - fixtures[1] = &tSeconds - fixtures[2] = nilInt - - for i := range fixtures { - ConvertSqlTimeColumnToEpochMs(fixtures, i) - } - - Convey("When converting them should return epoch time with millisecond precision ", func() { - So(fixtures[0].(int64), ShouldEqual, dt.Unix()*1e3) - So(fixtures[1].(int64), ShouldEqual, dt.Unix()*1e3) - So(fixtures[2], ShouldBeNil) - }) - }) - - Convey("Given row values with uint32 as time columns", func() { - tSeconds := uint32(dt.Unix()) - var nilInt *uint32 - - fixtures := make([]interface{}, 3) - fixtures[0] = tSeconds - fixtures[1] = &tSeconds - fixtures[2] = nilInt - - for i := range fixtures { - ConvertSqlTimeColumnToEpochMs(fixtures, i) - } - - Convey("When converting them should return epoch time with millisecond precision ", func() { - So(fixtures[0].(int64), ShouldEqual, dt.Unix()*1e3) - So(fixtures[1].(int64), ShouldEqual, dt.Unix()*1e3) - So(fixtures[2], ShouldBeNil) - }) - }) - - Convey("Given row values with float64 as time columns", func() { - tSeconds := float64(dt.UnixNano()) / float64(time.Second) - tMilliseconds := float64(dt.UnixNano()) / float64(time.Millisecond) - tNanoSeconds := float64(dt.UnixNano()) - var nilPointer *float64 - - fixtures := make([]interface{}, 7) - fixtures[0] = tSeconds - fixtures[1] = &tSeconds - fixtures[2] = tMilliseconds - fixtures[3] = &tMilliseconds - fixtures[4] = tNanoSeconds - fixtures[5] = &tNanoSeconds - fixtures[6] = nilPointer - - for i := range fixtures { - ConvertSqlTimeColumnToEpochMs(fixtures, i) - } - - Convey("When converting them should return epoch time with millisecond precision ", func() { - So(fixtures[0].(float64), ShouldEqual, tMilliseconds) - So(fixtures[1].(float64), ShouldEqual, tMilliseconds) - So(fixtures[2].(float64), ShouldEqual, tMilliseconds) - So(fixtures[3].(float64), ShouldEqual, tMilliseconds) - So(fixtures[4].(float64), ShouldEqual, tMilliseconds) - So(fixtures[5].(float64), ShouldEqual, tMilliseconds) - So(fixtures[6], ShouldBeNil) - }) - }) - - Convey("Given row values with float32 as time columns", func() { - tSeconds := float32(dt.Unix()) - var nilInt *float32 - - fixtures := make([]interface{}, 3) - fixtures[0] = tSeconds - fixtures[1] = &tSeconds - fixtures[2] = nilInt - - for i := range fixtures { - ConvertSqlTimeColumnToEpochMs(fixtures, i) - } - - Convey("When converting them should return epoch time with millisecond precision ", func() { - So(fixtures[0].(float64), ShouldEqual, float32(dt.Unix()*1e3)) - So(fixtures[1].(float64), ShouldEqual, float32(dt.Unix()*1e3)) - So(fixtures[2], ShouldBeNil) - }) - }) - - Convey("Given row with value columns", func() { - intValue := 1 - int64Value := int64(1) - int32Value := int32(1) - int16Value := int16(1) - int8Value := int8(1) - float64Value := float64(1) - float32Value := float32(1) - uintValue := uint(1) - uint64Value := uint64(1) - uint32Value := uint32(1) - uint16Value := uint16(1) - uint8Value := uint8(1) - - fixtures := make([]interface{}, 24) - fixtures[0] = intValue - fixtures[1] = &intValue - fixtures[2] = int64Value - fixtures[3] = &int64Value - fixtures[4] = int32Value - fixtures[5] = &int32Value - fixtures[6] = int16Value - fixtures[7] = &int16Value - fixtures[8] = int8Value - fixtures[9] = &int8Value - fixtures[10] = float64Value - fixtures[11] = &float64Value - fixtures[12] = float32Value - fixtures[13] = &float32Value - fixtures[14] = uintValue - fixtures[15] = &uintValue - fixtures[16] = uint64Value - fixtures[17] = &uint64Value - fixtures[18] = uint32Value - fixtures[19] = &uint32Value - fixtures[20] = uint16Value - fixtures[21] = &uint16Value - fixtures[22] = uint8Value - fixtures[23] = &uint8Value - - var intNilPointer *int - var int64NilPointer *int64 - var int32NilPointer *int32 - var int16NilPointer *int16 - var int8NilPointer *int8 - var float64NilPointer *float64 - var float32NilPointer *float32 - var uintNilPointer *uint - var uint64NilPointer *uint64 - var uint32NilPointer *uint32 - var uint16NilPointer *uint16 - var uint8NilPointer *uint8 - - nilPointerFixtures := make([]interface{}, 12) - nilPointerFixtures[0] = intNilPointer - nilPointerFixtures[1] = int64NilPointer - nilPointerFixtures[2] = int32NilPointer - nilPointerFixtures[3] = int16NilPointer - nilPointerFixtures[4] = int8NilPointer - nilPointerFixtures[5] = float64NilPointer - nilPointerFixtures[6] = float32NilPointer - nilPointerFixtures[7] = uintNilPointer - nilPointerFixtures[8] = uint64NilPointer - nilPointerFixtures[9] = uint32NilPointer - nilPointerFixtures[10] = uint16NilPointer - nilPointerFixtures[11] = uint8NilPointer - - Convey("When converting values to float should return expected value", func() { - for _, f := range fixtures { - value, _ := ConvertSqlValueColumnToFloat("col", f) - - So(value.Valid, ShouldBeTrue) - So(value.Float64, ShouldEqual, null.FloatFrom(1).Float64) - } - }) - - Convey("When converting nil pointer values to float should return expected value", func() { - for _, f := range nilPointerFixtures { - value, err := ConvertSqlValueColumnToFloat("col", f) - So(err, ShouldBeNil) - - So(value.Valid, ShouldBeFalse) - } - }) + t.Run("interpolate __unixEpochTo function", func(t *testing.T) { + sql, err := Interpolate(query, timeRange, "select $__unixEpochTo()") + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("select %d", to.Unix()), sql) }) }) + + t.Run("Given row values with time.Time as time columns", func(t *testing.T) { + var nilPointer *time.Time + + fixtures := make([]interface{}, 5) + fixtures[0] = dt + fixtures[1] = &dt + fixtures[2] = earlyDt + fixtures[3] = &earlyDt + fixtures[4] = nilPointer + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + expected := float64(dt.UnixNano()) / float64(time.Millisecond) + expectedEarly := float64(earlyDt.UnixNano()) / float64(time.Millisecond) + + require.Equal(t, expected, fixtures[0].(float64)) + require.Equal(t, expected, fixtures[1].(float64)) + require.Equal(t, expectedEarly, fixtures[2].(float64)) + require.Equal(t, expectedEarly, fixtures[3].(float64)) + require.Nil(t, fixtures[4]) + }) + + t.Run("Given row values with int64 as time columns", func(t *testing.T) { + tSeconds := dt.Unix() + tMilliseconds := dt.UnixNano() / 1e6 + tNanoSeconds := dt.UnixNano() + var nilPointer *int64 + + fixtures := make([]interface{}, 7) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = tMilliseconds + fixtures[3] = &tMilliseconds + fixtures[4] = tNanoSeconds + fixtures[5] = &tNanoSeconds + fixtures[6] = nilPointer + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + require.Equal(t, tSeconds*1e3, fixtures[0].(int64)) + require.Equal(t, tSeconds*1e3, fixtures[1].(int64)) + require.Equal(t, tMilliseconds, fixtures[2].(int64)) + require.Equal(t, tMilliseconds, fixtures[3].(int64)) + require.Equal(t, tMilliseconds, fixtures[4].(int64)) + require.Equal(t, tMilliseconds, fixtures[5].(int64)) + require.Nil(t, fixtures[6]) + }) + + t.Run("Given row values with uint64 as time columns", func(t *testing.T) { + tSeconds := uint64(dt.Unix()) + tMilliseconds := uint64(dt.UnixNano() / 1e6) + tNanoSeconds := uint64(dt.UnixNano()) + var nilPointer *uint64 + + fixtures := make([]interface{}, 7) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = tMilliseconds + fixtures[3] = &tMilliseconds + fixtures[4] = tNanoSeconds + fixtures[5] = &tNanoSeconds + fixtures[6] = nilPointer + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + require.Equal(t, int64(tSeconds*1e3), fixtures[0].(int64)) + require.Equal(t, int64(tSeconds*1e3), fixtures[1].(int64)) + require.Equal(t, int64(tMilliseconds), fixtures[2].(int64)) + require.Equal(t, int64(tMilliseconds), fixtures[3].(int64)) + require.Equal(t, int64(tMilliseconds), fixtures[4].(int64)) + require.Equal(t, int64(tMilliseconds), fixtures[5].(int64)) + require.Nil(t, fixtures[6]) + }) + + t.Run("Given row values with int32 as time columns", func(t *testing.T) { + tSeconds := int32(dt.Unix()) + var nilInt *int32 + + fixtures := make([]interface{}, 3) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = nilInt + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + require.Equal(t, dt.Unix()*1e3, fixtures[0].(int64)) + require.Equal(t, dt.Unix()*1e3, fixtures[1].(int64)) + require.Nil(t, fixtures[2]) + }) + + t.Run("Given row values with uint32 as time columns", func(t *testing.T) { + tSeconds := uint32(dt.Unix()) + var nilInt *uint32 + + fixtures := make([]interface{}, 3) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = nilInt + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + require.Equal(t, dt.Unix()*1e3, fixtures[0].(int64)) + require.Equal(t, dt.Unix()*1e3, fixtures[1].(int64)) + require.Nil(t, fixtures[2]) + }) + + t.Run("Given row values with float64 as time columns", func(t *testing.T) { + tSeconds := float64(dt.UnixNano()) / float64(time.Second) + tMilliseconds := float64(dt.UnixNano()) / float64(time.Millisecond) + tNanoSeconds := float64(dt.UnixNano()) + var nilPointer *float64 + + fixtures := make([]interface{}, 7) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = tMilliseconds + fixtures[3] = &tMilliseconds + fixtures[4] = tNanoSeconds + fixtures[5] = &tNanoSeconds + fixtures[6] = nilPointer + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + require.Equal(t, tMilliseconds, fixtures[0].(float64)) + require.Equal(t, tMilliseconds, fixtures[1].(float64)) + require.Equal(t, tMilliseconds, fixtures[2].(float64)) + require.Equal(t, tMilliseconds, fixtures[3].(float64)) + require.Equal(t, tMilliseconds, fixtures[4].(float64)) + require.Equal(t, tMilliseconds, fixtures[5].(float64)) + require.Nil(t, fixtures[6]) + }) + + t.Run("Given row values with float32 as time columns", func(t *testing.T) { + tSeconds := float32(dt.Unix()) + var nilInt *float32 + + fixtures := make([]interface{}, 3) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = nilInt + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + require.Equal(t, float64(tSeconds)*1e3, fixtures[0].(float64)) + require.Equal(t, float64(tSeconds)*1e3, fixtures[1].(float64)) + require.Nil(t, fixtures[2]) + }) + + t.Run("Given row with value columns", func(t *testing.T) { + intValue := 1 + int64Value := int64(1) + int32Value := int32(1) + int16Value := int16(1) + int8Value := int8(1) + float64Value := float64(1) + float32Value := float32(1) + uintValue := uint(1) + uint64Value := uint64(1) + uint32Value := uint32(1) + uint16Value := uint16(1) + uint8Value := uint8(1) + + testCases := []struct { + name string + value interface{} + }{ + {"intValue", intValue}, + {"&intValue", &intValue}, + {"int64Value", int64Value}, + {"&int64Value", &int64Value}, + {"int32Value", int32Value}, + {"&int32Value", &int32Value}, + {"int16Value", int16Value}, + {"&int16Value", &int16Value}, + {"int8Value", int8Value}, + {"&int8Value", &int8Value}, + {"float64Value", float64Value}, + {"&float64Value", &float64Value}, + {"float32Value", float32Value}, + {"&float32Value", &float32Value}, + {"uintValue", uintValue}, + {"&uintValue", &uintValue}, + {"uint64Value", uint64Value}, + {"&uint64Value", &uint64Value}, + {"uint32Value", uint32Value}, + {"&uint32Value", &uint32Value}, + {"uint16Value", uint16Value}, + {"&uint16Value", &uint16Value}, + {"uint8Value", uint8Value}, + {"&uint8Value", &uint8Value}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + value, err := ConvertSqlValueColumnToFloat("col", tc.value) + require.NoError(t, err) + require.True(t, value.Valid) + require.Equal(t, null.FloatFrom(1).Float64, value.Float64) + }) + } + }) + + t.Run("Given row with nil value columns", func(t *testing.T) { + var intNilPointer *int + var int64NilPointer *int64 + var int32NilPointer *int32 + var int16NilPointer *int16 + var int8NilPointer *int8 + var float64NilPointer *float64 + var float32NilPointer *float32 + var uintNilPointer *uint + var uint64NilPointer *uint64 + var uint32NilPointer *uint32 + var uint16NilPointer *uint16 + var uint8NilPointer *uint8 + + testCases := []struct { + name string + value interface{} + }{ + {"intNilPointer", intNilPointer}, + {"int64NilPointer", int64NilPointer}, + {"int32NilPointer", int32NilPointer}, + {"int16NilPointer", int16NilPointer}, + {"int8NilPointer", int8NilPointer}, + {"float64NilPointer", float64NilPointer}, + {"float32NilPointer", float32NilPointer}, + {"uintNilPointer", uintNilPointer}, + {"uint64NilPointer", uint64NilPointer}, + {"uint32NilPointer", uint32NilPointer}, + {"uint16NilPointer", uint16NilPointer}, + {"uint8NilPointer", uint8NilPointer}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + value, err := ConvertSqlValueColumnToFloat("col", tc.value) + require.NoError(t, err) + require.False(t, value.Valid) + }) + } + }) } diff --git a/pkg/tsdb/testdatasource/scenarios_test.go b/pkg/tsdb/testdatasource/scenarios_test.go index 559fd058490..9c6cd35604d 100644 --- a/pkg/tsdb/testdatasource/scenarios_test.go +++ b/pkg/tsdb/testdatasource/scenarios_test.go @@ -6,14 +6,14 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/tsdb" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/require" ) func TestTestdataScenarios(t *testing.T) { - Convey("random walk ", t, func() { + t.Run("random walk ", func(t *testing.T) { scenario := ScenarioRegistry["random_walk"] - Convey("Should start at the requested value", func() { + t.Run("Should start at the requested value", func(t *testing.T) { req := &tsdb.TsdbQuery{ TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()), Queries: []*tsdb.Query{ @@ -24,17 +24,17 @@ func TestTestdataScenarios(t *testing.T) { query.Model.Set("startValue", 1.234) result := scenario.Handler(req.Queries[0], req) - points := result.Series[0].Points + require.NotNil(t, result.Series) - So(result.Series, ShouldNotBeNil) - So(points[0][0].Float64, ShouldEqual, 1.234) + points := result.Series[0].Points + require.Equal(t, 1.234, points[0][0].Float64) }) }) - Convey("random walk table", t, func() { + t.Run("random walk table", func(t *testing.T) { scenario := ScenarioRegistry["random_walk_table"] - Convey("Should return a table that looks like value/min/max", func() { + t.Run("Should return a table that looks like value/min/max", func(t *testing.T) { req := &tsdb.TsdbQuery{ TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()), Queries: []*tsdb.Query{ @@ -45,18 +45,18 @@ func TestTestdataScenarios(t *testing.T) { result := scenario.Handler(req.Queries[0], req) table := result.Tables[0] - So(len(table.Rows), ShouldBeGreaterThan, 50) + require.Greater(t, len(table.Rows), 50) for _, row := range table.Rows { value := row[1] min := row[2] max := row[3] - So(min, ShouldBeLessThan, value) - So(max, ShouldBeGreaterThan, value) + require.Less(t, min, value) + require.Greater(t, max, value) } }) - Convey("Should return a table with some nil values", func() { + t.Run("Should return a table with some nil values", func(t *testing.T) { req := &tsdb.TsdbQuery{ TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()), Queries: []*tsdb.Query{ @@ -73,7 +73,7 @@ func TestTestdataScenarios(t *testing.T) { nil2 := false nil3 := false - So(len(table.Rows), ShouldBeGreaterThan, 50) + require.Greater(t, len(table.Rows), 50) for _, row := range table.Rows { if row[1] == nil { nil1 = true @@ -86,41 +86,37 @@ func TestTestdataScenarios(t *testing.T) { } } - So(nil1, ShouldBeTrue) - So(nil2, ShouldBeTrue) - So(nil3, ShouldBeTrue) + require.True(t, nil1) + require.True(t, nil2) + require.True(t, nil3) }) }) } -func TestToLabels(t *testing.T) { - Convey("read labels", t, func() { - tags := make(map[string]string) - tags["job"] = "foo" - tags["instance"] = "bar" +func TestParseLabels(t *testing.T) { + expectedTags := map[string]string{ + "job": "foo", + "instance": "bar", + } - query1 := tsdb.Query{ - Model: simplejson.NewFromAny(map[string]interface{}{ - "labels": `{job="foo", instance="bar"}`, - }), - } + query1 := tsdb.Query{ + Model: simplejson.NewFromAny(map[string]interface{}{ + "labels": `{job="foo", instance="bar"}`, + }), + } + require.Equal(t, expectedTags, parseLabels(&query1)) - So(parseLabels(&query1), ShouldResemble, tags) + query2 := tsdb.Query{ + Model: simplejson.NewFromAny(map[string]interface{}{ + "labels": `job=foo, instance=bar`, + }), + } + require.Equal(t, expectedTags, parseLabels(&query2)) - query2 := tsdb.Query{ - Model: simplejson.NewFromAny(map[string]interface{}{ - "labels": `job=foo, instance=bar`, - }), - } - - So(parseLabels(&query2), ShouldResemble, tags) - - query3 := tsdb.Query{ - Model: simplejson.NewFromAny(map[string]interface{}{ - "labels": `job = foo,instance = bar`, - }), - } - - So(parseLabels(&query3), ShouldResemble, tags) - }) + query3 := tsdb.Query{ + Model: simplejson.NewFromAny(map[string]interface{}{ + "labels": `job = foo,instance = bar`, + }), + } + require.Equal(t, expectedTags, parseLabels(&query3)) } diff --git a/public/app/core/components/Footer/Footer.tsx b/public/app/core/components/Footer/Footer.tsx index 15f93ac4454..a6d3bc0ec93 100644 --- a/public/app/core/components/Footer/Footer.tsx +++ b/public/app/core/components/Footer/Footer.tsx @@ -84,3 +84,5 @@ export const Footer: FC = React.memo(() => { ); }); + +Footer.displayName = 'Footer'; diff --git a/public/app/core/components/ForgottenPassword/ChangePassword.tsx b/public/app/core/components/ForgottenPassword/ChangePassword.tsx index dda6ced168f..1e6faab9447 100644 --- a/public/app/core/components/ForgottenPassword/ChangePassword.tsx +++ b/public/app/core/components/ForgottenPassword/ChangePassword.tsx @@ -47,7 +47,7 @@ export const ChangePassword: FC = ({ onSubmit, onSkip }) => { {onSkip && ( diff --git a/public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap b/public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap index c1220d90a25..98be19581a9 100644 --- a/public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap +++ b/public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap @@ -7,7 +7,7 @@ exports[`Render should render component 1`] = `
- ( +const PermissionsInfo = () => (
What are Permissions?

@@ -9,3 +9,5 @@ export default () => (

); + +export default PermissionsInfo; diff --git a/public/app/core/components/PluginHelp/PluginHelp.tsx b/public/app/core/components/PluginHelp/PluginHelp.tsx index 67364ea9366..cf55ac09ee3 100644 --- a/public/app/core/components/PluginHelp/PluginHelp.tsx +++ b/public/app/core/components/PluginHelp/PluginHelp.tsx @@ -71,7 +71,7 @@ export class PluginHelp extends PureComponent { } if (isError) { - return

'Error occurred when loading help'

; + return

'Error occurred when loading help'

; } if (type === 'panel_help' && help === '') { diff --git a/public/app/core/components/Select/DataSourcePicker.tsx b/public/app/core/components/Select/DataSourcePicker.tsx index a2e9eeb4dca..090ab2392ee 100644 --- a/public/app/core/components/Select/DataSourcePicker.tsx +++ b/public/app/core/components/Select/DataSourcePicker.tsx @@ -3,72 +3,119 @@ import React, { PureComponent } from 'react'; // Components import { HorizontalGroup, Select } from '@grafana/ui'; -import { SelectableValue, DataSourceSelectItem } from '@grafana/data'; +import { SelectableValue, DataSourceInstanceSettings } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { isUnsignedPluginSignature, PluginSignatureBadge } from '../../../features/plugins/PluginSignatureBadge'; +import { getDataSourceSrv } from '@grafana/runtime'; export interface Props { - onChange: (ds: DataSourceSelectItem) => void; - datasources: DataSourceSelectItem[]; - current?: DataSourceSelectItem | null; + onChange: (ds: DataSourceInstanceSettings) => void; + current: string | null; hideTextValue?: boolean; onBlur?: () => void; autoFocus?: boolean; openMenuOnFocus?: boolean; - showLoading?: boolean; placeholder?: string; - invalid?: boolean; + tracing?: boolean; + mixed?: boolean; + dashboard?: boolean; + metrics?: boolean; + annotations?: boolean; + variables?: boolean; + pluginId?: string; + noDefault?: boolean; } -export class DataSourcePicker extends PureComponent { +export interface State { + error?: string; +} + +export class DataSourcePicker extends PureComponent { + dataSourceSrv = getDataSourceSrv(); + static defaultProps: Partial = { autoFocus: false, openMenuOnFocus: false, placeholder: 'Select datasource', }; - searchInput: HTMLElement; + state: State = {}; constructor(props: Props) { super(props); } - onChange = (item: SelectableValue) => { - const ds = this.props.datasources.find(ds => ds.name === item.value); + componentDidMount() { + const { current } = this.props; + const dsSettings = this.dataSourceSrv.getInstanceSettings(current); + if (!dsSettings) { + this.setState({ error: 'Could not find data source ' + current }); + } + } - if (ds) { - this.props.onChange(ds); + onChange = (item: SelectableValue) => { + const dsSettings = this.dataSourceSrv.getInstanceSettings(item.value); + + if (dsSettings) { + this.props.onChange(dsSettings); + this.setState({ error: undefined }); } }; - render() { - const { - datasources, - current, - autoFocus, - hideTextValue, - onBlur, - openMenuOnFocus, - showLoading, - placeholder, - invalid, - } = this.props; + private getCurrentValue() { + const { current, hideTextValue, noDefault } = this.props; - const options = datasources.map(ds => ({ - value: ds.name, - label: ds.name, - imgUrl: ds.meta.info.logos.small, - meta: ds.meta, - })); + if (!current && noDefault) { + return null; + } - const value = current && { - label: current.name.substr(0, 37), - value: current.name, - imgUrl: current.meta.info.logos.small, - loading: showLoading, + const ds = this.dataSourceSrv.getInstanceSettings(current); + + if (ds) { + return { + label: ds.name.substr(0, 37), + value: ds.name, + imgUrl: ds.meta.info.logos.small, + hideText: hideTextValue, + meta: ds.meta, + }; + } + + return { + label: (current ?? 'no name') + ' - not found', + value: current, + imgUrl: '', hideText: hideTextValue, - meta: current.meta, }; + } + + getDataSourceOptions() { + const { tracing, metrics, mixed, dashboard, variables, annotations, pluginId } = this.props; + const options = this.dataSourceSrv + .getList({ + tracing, + metrics, + dashboard, + mixed, + variables, + annotations, + pluginId, + }) + .map(ds => ({ + value: ds.name, + label: `${ds.name}${ds.isDefault ? ' (default)' : ''}`, + imgUrl: ds.meta.info.logos.small, + meta: ds.meta, + })); + + return options; + } + + render() { + const { autoFocus, onBlur, openMenuOnFocus, placeholder } = this.props; + const { error } = this.state; + const options = this.getDataSourceOptions(); + const value = this.getCurrentValue(); return (
@@ -87,9 +134,9 @@ export class DataSourcePicker extends PureComponent { placeholder={placeholder} noOptionsMessage="No datasources found" value={value} - invalid={invalid} + invalid={!!error} getOptionLabel={o => { - if (isUnsignedPluginSignature(o.meta.signature) && o !== value) { + if (o.meta && isUnsignedPluginSignature(o.meta.signature) && o !== value) { return ( {o.label} @@ -103,5 +150,3 @@ export class DataSourcePicker extends PureComponent { ); } } - -export default DataSourcePicker; diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx index 66072475c78..60ea0e6953a 100644 --- a/public/app/core/components/TagFilter/TagFilter.tsx +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -79,7 +79,7 @@ export const TagFilter: FC = ({ MultiValueLabel: (): any => { return null; // We want the whole tag to be clickable so we use MultiValueRemove instead }, - MultiValueRemove: (props: any) => { + MultiValueRemove(props: any) { const { data } = props; return ( diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueFilterEditor.tsx b/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueFilterEditor.tsx new file mode 100644 index 00000000000..0b14657bf49 --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueFilterEditor.tsx @@ -0,0 +1,171 @@ +import React, { useCallback } from 'react'; +import { Button, Select } from '@grafana/ui'; +import { Field, SelectableValue, valueMatchers } from '@grafana/data'; +import { FilterByValueFilter } from '@grafana/data/src/transformations/transformers/filterByValue'; +import { valueMatchersUI } from './ValueMatchers/valueMatchersUI'; + +interface Props { + onDelete: () => void; + onChange: (filter: FilterByValueFilter) => void; + filter: FilterByValueFilter; + fieldsInfo: DataFrameFieldsInfo; +} + +export interface DataFrameFieldsInfo { + fieldsAsOptions: Array>; + fieldByDisplayName: Record; +} + +export const FilterByValueFilterEditor: React.FC = props => { + const { onDelete, onChange, filter, fieldsInfo } = props; + const { fieldsAsOptions, fieldByDisplayName } = fieldsInfo; + const fieldName = getFieldName(filter, fieldsAsOptions) ?? ''; + const field = fieldByDisplayName[fieldName]; + + if (!field) { + return null; + } + + const matcherOptions = getMatcherOptions(field); + const matcherId = getSelectedMatcherId(filter, matcherOptions); + const editor = valueMatchersUI.getIfExists(matcherId); + + if (!editor || !editor.component) { + return null; + } + + const onChangeField = useCallback( + (selectable?: SelectableValue) => { + if (!selectable?.value) { + return; + } + onChange({ + ...filter, + fieldName: selectable.value, + }); + }, + [onChange, filter] + ); + + const onChangeMatcher = useCallback( + (selectable?: SelectableValue) => { + if (!selectable?.value) { + return; + } + + const id = selectable.value; + const options = valueMatchers.get(id).getDefaultOptions(field); + + onChange({ + ...filter, + config: { id, options }, + }); + }, + [onChange, filter, field] + ); + + const onChangeMatcherOptions = useCallback( + options => { + onChange({ + ...filter, + config: { + ...filter.config, + options, + }, + }); + }, + [onChange, filter] + ); + + return ( +
+
+
Field
+ +
+
+
Value
+ +
+
+
+
+ ); +}; + +const getMatcherOptions = (field: Field): Array> => { + const options = []; + + for (const matcher of valueMatchers.list()) { + if (!matcher.isApplicable(field)) { + continue; + } + + const editor = valueMatchersUI.getIfExists(matcher.id); + + if (!editor) { + continue; + } + + options.push({ + value: matcher.id, + label: matcher.name, + description: matcher.description, + }); + } + + return options; +}; + +const getSelectedMatcherId = ( + filter: FilterByValueFilter, + matcherOptions: Array> +): string | undefined => { + const matcher = matcherOptions.find(m => m.value === filter.config.id); + + if (matcher && matcher.value) { + return matcher.value; + } + + if (matcherOptions[0]?.value) { + return matcherOptions[0]?.value; + } + + return; +}; + +const getFieldName = ( + filter: FilterByValueFilter, + fieldOptions: Array> +): string | undefined => { + const fieldName = fieldOptions.find(m => m.value === filter.fieldName); + + if (fieldName && fieldName.value) { + return fieldName.value; + } + + if (fieldOptions[0]?.value) { + return fieldOptions[0]?.value; + } + + return; +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor.tsx b/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor.tsx new file mode 100644 index 00000000000..2c0dcf22881 --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor.tsx @@ -0,0 +1,180 @@ +import React, { useMemo, useCallback } from 'react'; +import { css } from 'emotion'; +import { + DataTransformerID, + standardTransformers, + TransformerRegistyItem, + TransformerUIProps, + getFieldDisplayName, + DataFrame, + SelectableValue, + FieldType, + ValueMatcherID, + valueMatchers, +} from '@grafana/data'; +import { Button, RadioButtonGroup, stylesFactory } from '@grafana/ui'; +import cloneDeep from 'lodash/cloneDeep'; +import { + FilterByValueFilter, + FilterByValueMatch, + FilterByValueTransformerOptions, + FilterByValueType, +} from '@grafana/data/src/transformations/transformers/filterByValue'; + +import { DataFrameFieldsInfo, FilterByValueFilterEditor } from './FilterByValueFilterEditor'; + +const filterTypes: Array> = [ + { label: 'Include', value: FilterByValueType.include }, + { label: 'Exclude', value: FilterByValueType.exclude }, +]; + +const filterMatch: Array> = [ + { label: 'Match all', value: FilterByValueMatch.all }, + { label: 'Match any', value: FilterByValueMatch.any }, +]; + +export const FilterByValueTransformerEditor: React.FC> = props => { + const { input, options, onChange } = props; + const styles = getEditorStyles(); + const fieldsInfo = useFieldsInfo(input); + + const onAddFilter = useCallback(() => { + const frame = input[0]; + const field = frame.fields.find(f => f.type !== FieldType.time); + + if (!field) { + return; + } + + const filters = cloneDeep(options.filters); + const matcher = valueMatchers.get(ValueMatcherID.greater); + + filters.push({ + fieldName: getFieldDisplayName(field, frame, input), + config: { + id: matcher.id, + options: matcher.getDefaultOptions(field), + }, + }); + onChange({ ...options, filters }); + }, [onChange, options, valueMatchers, input]); + + const onDeleteFilter = useCallback( + (index: number) => { + let filters = cloneDeep(options.filters); + filters.splice(index, 1); + onChange({ ...options, filters }); + }, + [options, onChange] + ); + + const onChangeFilter = useCallback( + (filter: FilterByValueFilter, index: number) => { + let filters = cloneDeep(options.filters); + filters[index] = filter; + onChange({ ...options, filters }); + }, + [options, onChange] + ); + + const onChangeType = useCallback( + (type?: FilterByValueType) => { + onChange({ + ...options, + type: type ?? FilterByValueType.include, + }); + }, + [options, onChange] + ); + + const onChangeMatch = useCallback( + (match?: FilterByValueMatch) => { + onChange({ + ...options, + match: match ?? FilterByValueMatch.all, + }); + }, + [options, onChange] + ); + + return ( +
+
+
Filter type
+
+ +
+
+
+
Conditions
+
+ +
+
+
+ {options.filters.map((filter, idx) => ( + onChangeFilter(filter, idx)} + onDelete={() => onDeleteFilter(idx)} + /> + ))} +
+ +
+
+
+ ); +}; + +export const filterByValueTransformRegistryItem: TransformerRegistyItem = { + id: DataTransformerID.filterByValue, + editor: FilterByValueTransformerEditor, + transformation: standardTransformers.filterByValueTransformer, + name: standardTransformers.filterByValueTransformer.name, + description: + 'Removes rows of the query results using user definied filters. This is useful if you can not filter your data in the data source.', +}; + +const getEditorStyles = stylesFactory(() => ({ + conditions: css` + padding-left: 16px; + `, +})); + +const useFieldsInfo = (data: DataFrame[]): DataFrameFieldsInfo => { + return useMemo(() => { + const meta = { + fieldsAsOptions: [], + fieldByDisplayName: {}, + }; + + if (!Array.isArray(data)) { + return meta; + } + + return data.reduce((meta: DataFrameFieldsInfo, frame) => { + return frame.fields.reduce((meta, field) => { + const fieldName = getFieldDisplayName(field, frame, data); + + if (meta.fieldByDisplayName[fieldName]) { + return meta; + } + + meta.fieldsAsOptions.push({ + label: fieldName, + value: fieldName, + type: field.type, + }); + + meta.fieldByDisplayName[fieldName] = field; + + return meta; + }, meta); + }, meta); + }, [data]); +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx new file mode 100644 index 00000000000..8294c257014 --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx @@ -0,0 +1,104 @@ +import React, { useCallback, useState } from 'react'; +import { Input } from '@grafana/ui'; +import { ValueMatcherID, BasicValueMatcherOptions } from '@grafana/data'; +import { ValueMatcherEditorConfig, ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types'; +import { convertToType } from './utils'; + +export function basicMatcherEditor( + config: ValueMatcherEditorConfig +): React.FC>> { + return function render({ options, onChange, field }) { + const { validator, converter = convertToType } = config; + const { value } = options; + const [isInvalid, setInvalid] = useState(!validator(value)); + + const onChangeValue = useCallback( + (event: React.FormEvent) => { + setInvalid(!validator(event.currentTarget.value)); + }, + [setInvalid, validator] + ); + + const onChangeOptions = useCallback( + (event: React.FocusEvent) => { + if (isInvalid) { + return; + } + + const { value } = event.currentTarget; + + onChange({ + ...options, + value: converter(value, field), + }); + }, + [options, onChange, isInvalid, field, converter] + ); + + return ( + + ); + }; +} + +export const getBasicValueMatchersUI = (): Array> => { + return [ + { + name: 'Is greater', + id: ValueMatcherID.greater, + component: basicMatcherEditor({ + validator: value => !isNaN(value), + }), + }, + { + name: 'Is greater or equal', + id: ValueMatcherID.greaterOrEqual, + component: basicMatcherEditor({ + validator: value => !isNaN(value), + }), + }, + { + name: 'Is lower', + id: ValueMatcherID.lower, + component: basicMatcherEditor({ + validator: value => !isNaN(value), + }), + }, + { + name: 'Is lower or equal', + id: ValueMatcherID.lowerOrEqual, + component: basicMatcherEditor({ + validator: value => !isNaN(value), + }), + }, + { + name: 'Is equal', + id: ValueMatcherID.equal, + component: basicMatcherEditor({ + validator: () => true, + }), + }, + { + name: 'Is not equal', + id: ValueMatcherID.notEqual, + component: basicMatcherEditor({ + validator: () => true, + }), + }, + { + name: 'Regex', + id: ValueMatcherID.regex, + component: basicMatcherEditor({ + validator: () => true, + converter: (value: any) => String(value), + }), + }, + ]; +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx new file mode 100644 index 00000000000..84119754b0b --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx @@ -0,0 +1,22 @@ +import { ValueMatcherID } from '@grafana/data'; +import React from 'react'; +import { ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types'; + +export const NoopMatcherEditor: React.FC> = () => { + return null; +}; + +export const getNoopValueMatchersUI = (): Array> => { + return [ + { + name: 'Is null', + id: ValueMatcherID.isNull, + component: NoopMatcherEditor, + }, + { + name: 'Is not null', + id: ValueMatcherID.isNotNull, + component: NoopMatcherEditor, + }, + ]; +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx new file mode 100644 index 00000000000..55bc8284a1d --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx @@ -0,0 +1,81 @@ +import React, { useCallback, useState } from 'react'; +import { Input } from '@grafana/ui'; +import { ValueMatcherID, RangeValueMatcherOptions } from '@grafana/data'; +import { ValueMatcherEditorConfig, ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types'; +import { convertToType } from './utils'; + +type PropNames = 'from' | 'to'; + +export function rangeMatcherEditor( + config: ValueMatcherEditorConfig +): React.FC>> { + return ({ options, onChange, field }) => { + const { validator } = config; + const [isInvalid, setInvalid] = useState({ + from: !validator(options.from), + to: !validator(options.to), + }); + + const onChangeValue = useCallback( + (event: React.FormEvent, prop: PropNames) => { + setInvalid({ + ...isInvalid, + [prop]: !validator(event.currentTarget.value), + }); + }, + [setInvalid, validator, isInvalid] + ); + + const onChangeOptions = useCallback( + (event: React.FocusEvent, prop: PropNames) => { + if (isInvalid[prop]) { + return; + } + + const { value } = event.currentTarget; + + onChange({ + ...options, + [prop]: convertToType(value, field), + }); + }, + [options, onChange, isInvalid, field] + ); + + return ( + <> + onChangeValue(event, 'from')} + onBlur={event => onChangeOptions(event, 'from')} + /> +
and
+ onChangeValue(event, 'to')} + onBlur={event => onChangeOptions(event, 'to')} + /> + + ); + }; +} + +export const getRangeValueMatchersUI = (): Array> => { + return [ + { + name: 'Is between', + id: ValueMatcherID.between, + component: rangeMatcherEditor({ + validator: value => { + return !isNaN(value); + }, + }), + }, + ]; +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/types.ts b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/types.ts new file mode 100644 index 00000000000..56a7b799a9d --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/types.ts @@ -0,0 +1,14 @@ +import { Field, RegistryItem } from '@grafana/data'; +export interface ValueMatcherUIRegistryItem extends RegistryItem { + component: React.ComponentType>; +} + +export interface ValueMatcherUIProps { + options: TOptions; + onChange: (options: TOptions) => void; + field: Field; +} +export interface ValueMatcherEditorConfig { + validator: (value: any) => boolean; + converter?: (value: any, field: Field) => any; +} diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/utils.ts b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/utils.ts new file mode 100644 index 00000000000..f30f590a476 --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/utils.ts @@ -0,0 +1,34 @@ +import { Field, FieldType } from '@grafana/data'; +import { isString, isUndefined } from 'lodash'; + +export function convertToType(value: any, field: Field): any { + switch (field.type) { + case FieldType.boolean: + if (isUndefined(value)) { + return false; + } + return convertToBool(value); + + case FieldType.number: + if (isNaN(value)) { + return 0; + } + return parseFloat(value); + + case FieldType.string: + if (!value) { + return ''; + } + return String(value); + + default: + return value; + } +} + +const convertToBool = (value: any): boolean => { + if (isString(value)) { + return !(value[0] === 'F' || value[0] === 'f' || value[0] === '0'); + } + return !!value; +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts new file mode 100644 index 00000000000..0517333aca6 --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts @@ -0,0 +1,9 @@ +import { Registry } from '@grafana/data'; +import { getBasicValueMatchersUI } from './BasicMatcherEditor'; +import { getNoopValueMatchersUI } from './NoopMatcherEditor'; +import { getRangeValueMatchersUI } from './RangeMatcherEditor'; +import { ValueMatcherUIRegistryItem } from './types'; + +export const valueMatchersUI = new Registry>(() => { + return [...getBasicValueMatchersUI(), ...getNoopValueMatchersUI(), ...getRangeValueMatchersUI()]; +}); diff --git a/public/app/core/components/sidemenu/SignIn.tsx b/public/app/core/components/sidemenu/SignIn.tsx index 5400c653417..4de185439b7 100644 --- a/public/app/core/components/sidemenu/SignIn.tsx +++ b/public/app/core/components/sidemenu/SignIn.tsx @@ -12,7 +12,7 @@ export const SignIn: FC = ({ url }) => {
- + diff --git a/public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap index 69fc93a90c7..7c3c353d89a 100644 --- a/public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap +++ b/public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap @@ -13,7 +13,7 @@ exports[`Render should render component 1`] = ` className="icon-circle sidemenu-icon" > diff --git a/public/app/core/services/echo/backends/sentry/transports/CustomEndpointTransport.test.ts b/public/app/core/services/echo/backends/sentry/transports/CustomEndpointTransport.test.ts index 299bdb62f87..d1f7eb5de45 100644 --- a/public/app/core/services/echo/backends/sentry/transports/CustomEndpointTransport.test.ts +++ b/public/app/core/services/echo/backends/sentry/transports/CustomEndpointTransport.test.ts @@ -57,7 +57,7 @@ describe('CustomEndpointTransport', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); // wait out the retry-after and call again - great success - await new Promise(resolve => setTimeout(() => resolve(), 1001)); + await new Promise(resolve => setTimeout(() => resolve(null), 1001)); await expect(transport.sendEvent(event)).resolves.toBeTruthy(); expect(fetchSpy).toHaveBeenCalledTimes(2); }); diff --git a/public/app/core/services/util_srv.ts b/public/app/core/services/util_srv.ts index 345410bed97..71e8c46e224 100644 --- a/public/app/core/services/util_srv.ts +++ b/public/app/core/services/util_srv.ts @@ -39,7 +39,7 @@ export class UtilSrv { const elem = React.createElement(provideTheme(AngularModalProxy), modalProps); this.reactModalRoot.appendChild(this.reactModalNode); - return ReactDOM.render(elem, this.reactModalNode); + ReactDOM.render(elem, this.reactModalNode); } onReactModalDismiss = () => { diff --git a/public/app/core/utils/UserProvider.tsx b/public/app/core/utils/UserProvider.tsx index adc88c50773..2a0575e164b 100644 --- a/public/app/core/utils/UserProvider.tsx +++ b/public/app/core/utils/UserProvider.tsx @@ -73,7 +73,7 @@ export class UserProvider extends PureComponent { }, }; - componentWillMount() { + UNSAFE_componentWillMount() { if (this.props.userId) { this.loadUser(); } diff --git a/public/app/core/utils/connectWithReduxStore.tsx b/public/app/core/utils/connectWithReduxStore.tsx index cff598d3a10..c7dc4591b9b 100644 --- a/public/app/core/utils/connectWithReduxStore.tsx +++ b/public/app/core/utils/connectWithReduxStore.tsx @@ -5,6 +5,7 @@ import { store } from '../../store/store'; export function connectWithStore(WrappedComponent: any, ...args: any[]) { const ConnectedWrappedComponent = (connect as any)(...args)(WrappedComponent); + // eslint-disable-next-line react/display-name return (props: any) => { return ; }; @@ -12,6 +13,8 @@ export function connectWithStore(WrappedComponent: any, ...args: any[]) { export function connectWithProvider(WrappedComponent: any, ...args: any[]) { const ConnectedWrappedComponent = (connect as any)(...args)(WrappedComponent); + + // eslint-disable-next-line react/display-name return (props: any) => { return ( diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index 2ad5fb00f98..9ca3e60a699 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -5,11 +5,11 @@ import _ from 'lodash'; import { DataQuery, DataSourceApi, dateTimeFormat, AppEvents, urlUtil, ExploreUrlState } from '@grafana/data'; import appEvents from 'app/core/app_events'; import store from 'app/core/store'; -import { getExploreDatasources } from '../../features/explore/state/selectors'; // Types import { RichHistoryQuery } from 'app/types/explore'; import { serializeStateToUrlParam } from '@grafana/data/src/utils/url'; +import { getDataSourceSrv } from '@grafana/runtime'; const RICH_HISTORY_KEY = 'grafana.explore.richHistory'; @@ -275,22 +275,21 @@ export function mapQueriesToHeadings(query: RichHistoryQuery[], sortOrder: SortO * exploreDatasources add generic datasource image and add property isRemoved = true. */ export function createDatasourcesList(queriesDatasources: string[]) { - const exploreDatasources = getExploreDatasources(); const datasources: Array<{ label: string; value: string; imgUrl: string; isRemoved: boolean }> = []; - queriesDatasources.forEach(queryDsName => { - const index = exploreDatasources.findIndex(exploreDs => exploreDs.name === queryDsName); - if (index !== -1) { + queriesDatasources.forEach(dsName => { + const dsSettings = getDataSourceSrv().getInstanceSettings(dsName); + if (dsSettings) { datasources.push({ - label: queryDsName, - value: queryDsName, - imgUrl: exploreDatasources[index].meta.info.logos.small, + label: dsSettings.name, + value: dsSettings.name, + imgUrl: dsSettings.meta.info.logos.small, isRemoved: false, }); } else { datasources.push({ - label: queryDsName, - value: queryDsName, + label: dsName, + value: dsName, imgUrl: 'public/img/icn-datasource.svg', isRemoved: true, }); diff --git a/public/app/core/utils/standardTransformers.ts b/public/app/core/utils/standardTransformers.ts index 63773a37ab6..25d81984164 100644 --- a/public/app/core/utils/standardTransformers.ts +++ b/public/app/core/utils/standardTransformers.ts @@ -2,6 +2,7 @@ import { TransformerRegistyItem } from '@grafana/data'; import { reduceTransformRegistryItem } from '../components/TransformersUI/ReduceTransformerEditor'; import { filterFieldsByNameTransformRegistryItem } from '../components/TransformersUI/FilterByNameTransformerEditor'; import { filterFramesByRefIdTransformRegistryItem } from '../components/TransformersUI/FilterByRefIdTransformerEditor'; +import { filterByValueTransformRegistryItem } from '../components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor'; import { organizeFieldsTransformRegistryItem } from '../components/TransformersUI/OrganizeFieldsTransformerEditor'; import { seriesToFieldsTransformerRegistryItem } from '../components/TransformersUI/SeriesToFieldsTransformerEditor'; import { calculateFieldTransformRegistryItem } from '../components/TransformersUI/CalculateFieldTransformerEditor'; @@ -16,6 +17,7 @@ export const getStandardTransformers = (): Array> => reduceTransformRegistryItem, filterFieldsByNameTransformRegistryItem, filterFramesByRefIdTransformRegistryItem, + filterByValueTransformRegistryItem, organizeFieldsTransformRegistryItem, seriesToFieldsTransformerRegistryItem, seriesToRowsTransformerRegistryItem, diff --git a/public/app/features/admin/AdminOrgsTable.tsx b/public/app/features/admin/AdminOrgsTable.tsx index e581478a5db..77e8d3fe986 100644 --- a/public/app/features/admin/AdminOrgsTable.tsx +++ b/public/app/features/admin/AdminOrgsTable.tsx @@ -40,7 +40,7 @@ export const AdminOrgsTable: FC = ({ orgs, onDelete }) => { title="Delete" body={
- Are you sure you want to delete '{deleteOrg.name}'? + Are you sure you want to delete '{deleteOrg.name}'?
All dashboards for this organization will be removed!
} diff --git a/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap b/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap index 635471e0d48..1417adbade2 100644 --- a/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap +++ b/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap @@ -13,7 +13,7 @@ exports[`Render should render alert rules 1`] = `
- - `; diff --git a/public/app/features/alerting/getAlertingValidationMessage.test.ts b/public/app/features/alerting/getAlertingValidationMessage.test.ts index f98a765975c..ed505319381 100644 --- a/public/app/features/alerting/getAlertingValidationMessage.test.ts +++ b/public/app/features/alerting/getAlertingValidationMessage.test.ts @@ -22,13 +22,10 @@ describe('getAlertingValidationMessage', () => { const getMock = jest.fn().mockResolvedValue(datasource); const datasourceSrv: DataSourceSrv = { get: getMock, - getDataSourceSettingsByUid(): any {}, - getExternal(): DataSourceInstanceSettings[] { - return []; - }, - getAll(): DataSourceInstanceSettings[] { + getList(): DataSourceInstanceSettings[] { return []; }, + getInstanceSettings: (() => {}) as any, }; const targets: ElasticsearchQuery[] = [ { refId: 'A', query: '@hostname:$hostname', isLogsQuery: false }, @@ -65,11 +62,8 @@ describe('getAlertingValidationMessage', () => { return Promise.resolve(alertingDatasource); }, - getDataSourceSettingsByUid(): any {}, - getExternal(): DataSourceInstanceSettings[] { - return []; - }, - getAll(): DataSourceInstanceSettings[] { + getInstanceSettings: (() => {}) as any, + getList(): DataSourceInstanceSettings[] { return []; }, }; @@ -95,11 +89,8 @@ describe('getAlertingValidationMessage', () => { const getMock = jest.fn().mockResolvedValue(datasource); const datasourceSrv: DataSourceSrv = { get: getMock, - getDataSourceSettingsByUid(): any {}, - getExternal(): DataSourceInstanceSettings[] { - return []; - }, - getAll(): DataSourceInstanceSettings[] { + getInstanceSettings: (() => {}) as any, + getList(): DataSourceInstanceSettings[] { return []; }, }; @@ -127,11 +118,8 @@ describe('getAlertingValidationMessage', () => { const getMock = jest.fn().mockResolvedValue(datasource); const datasourceSrv: DataSourceSrv = { get: getMock, - getDataSourceSettingsByUid(): any {}, - getExternal(): DataSourceInstanceSettings[] { - return []; - }, - getAll(): DataSourceInstanceSettings[] { + getInstanceSettings: (() => {}) as any, + getList(): DataSourceInstanceSettings[] { return []; }, }; @@ -159,11 +147,8 @@ describe('getAlertingValidationMessage', () => { const getMock = jest.fn().mockResolvedValue(datasource); const datasourceSrv: DataSourceSrv = { get: getMock, - getDataSourceSettingsByUid(): any {}, - getExternal(): DataSourceInstanceSettings[] { - return []; - }, - getAll(): DataSourceInstanceSettings[] { + getInstanceSettings: (() => {}) as any, + getList(): DataSourceInstanceSettings[] { return []; }, }; diff --git a/public/app/features/alerting/state/alertDef.ts b/public/app/features/alerting/state/alertDef.ts index f41065365ee..489ad3bfae2 100644 --- a/public/app/features/alerting/state/alertDef.ts +++ b/public/app/features/alerting/state/alertDef.ts @@ -8,7 +8,7 @@ const alertQueryDef = new QueryPartDef({ { name: 'from', type: 'string', - options: ['10s', '1m', '5m', '10m', '15m', '1h', '24h', '48h'], + options: ['10s', '1m', '5m', '10m', '15m', '1h', '2h', '6h', '12h', '24h', '48h'], }, { name: 'to', type: 'string', options: ['now', 'now-1m', 'now-5m', 'now-10m', 'now-1h'] }, ], diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index 6540a5aca93..3a8588503ac 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -25,7 +25,7 @@ import { Observable, of } from 'rxjs'; import { map, mergeMap } from 'rxjs/operators'; import { AnnotationQueryOptions, AnnotationQueryResponse } from './types'; import { standardAnnotationSupport } from './standardAnnotationSupport'; -import { runRequest } from '../dashboard/state/runRequest'; +import { runRequest } from '../query/state/runRequest'; let counter = 100; function getNextRequestId() { diff --git a/public/app/features/api-keys/ApiKeysAddedModal.tsx b/public/app/features/api-keys/ApiKeysAddedModal.tsx index 3503555dc6a..0cb207311cb 100644 --- a/public/app/features/api-keys/ApiKeysAddedModal.tsx +++ b/public/app/features/api-keys/ApiKeysAddedModal.tsx @@ -36,7 +36,7 @@ export const ApiKeysAddedModal = (props: Props) => {

-            curl -H "Authorization: Bearer {props.apiKey}" {props.rootPath}/api/dashboards/home
+            curl -H "Authorization: Bearer {props.apiKey}" {props.rootPath}/api/dashboards/home
           
diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap index 6b83de8b8cc..586d1c01cb5 100644 --- a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap +++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap @@ -43,7 +43,7 @@ exports[`Render should render CTA if there are no API keys 1`] = ` proTip="Remember you can provide view-only API access to other applications." title="You haven't added any API Keys yet." /> -
- Time to live - +
-
+ `; diff --git a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap index 16e27a47d2a..6e7cc12cdb7 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap +++ b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap @@ -15,7 +15,7 @@ exports[`Render should render component 1`] = ` onPasteCopiedPanel={[Function]} />
- - +
diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts index 3aa4143d45e..8954c5c50e4 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -56,6 +56,7 @@ describe('given dashboard with repeated panels', () => { type: 'constant', current: { value: 'collectd', text: 'collectd' }, options: [], + query: 'collectd', }, { name: 'ds', diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts index 83298d2ccce..7ea3d38d31c 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts @@ -182,16 +182,17 @@ export class DashboardExporter { name: refName, type: 'constant', label: variable.label || variable.name, - value: variable.current.value, + value: variable.query, description: '', }); // update current and option variable.query = '${' + refName + '}'; - variable.options[0] = variable.current = { + variable.current = { value: variable.query, text: variable.query, selected: false, }; + variable.options = [variable.current]; } } diff --git a/public/app/features/dashboard/components/DashboardSettings/template.html b/public/app/features/dashboard/components/DashboardSettings/template.html index 6fb87f257f5..b951945faba 100644 --- a/public/app/features/dashboard/components/DashboardSettings/template.html +++ b/public/app/features/dashboard/components/DashboardSettings/template.html @@ -10,10 +10,10 @@
- +
- +
diff --git a/public/app/features/dashboard/components/Inspector/InspectContent.tsx b/public/app/features/dashboard/components/Inspector/InspectContent.tsx index 7e51f31d29f..5edf0e725f0 100644 --- a/public/app/features/dashboard/components/Inspector/InspectContent.tsx +++ b/public/app/features/dashboard/components/Inspector/InspectContent.tsx @@ -12,7 +12,7 @@ import { InspectStatsTab } from './InspectStatsTab'; import { QueryInspector } from './QueryInspector'; import { InspectTab } from './types'; import { DashboardModel, PanelModel } from '../../state'; -import { GetDataOptions } from '../../state/PanelQueryRunner'; +import { GetDataOptions } from '../../../query/state/PanelQueryRunner'; interface Props { dashboard: DashboardModel; diff --git a/public/app/features/dashboard/components/Inspector/InspectDataTab.tsx b/public/app/features/dashboard/components/Inspector/InspectDataTab.tsx index 7156006f499..ec3979d0563 100644 --- a/public/app/features/dashboard/components/Inspector/InspectDataTab.tsx +++ b/public/app/features/dashboard/components/Inspector/InspectDataTab.tsx @@ -19,11 +19,10 @@ import { getPanelInspectorStyles } from './styles'; import { config } from 'app/core/config'; import { saveAs } from 'file-saver'; import { css } from 'emotion'; -import { GetDataOptions } from '../../state/PanelQueryRunner'; +import { GetDataOptions } from '../../../query/state/PanelQueryRunner'; import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; import { PanelModel } from 'app/features/dashboard/state'; import { DetailText } from './DetailText'; -import { getDatasourceSrv } from '../../../plugins/datasource_srv'; interface Props { panel: PanelModel; @@ -125,7 +124,6 @@ export class InspectDataTab extends PureComponent { replaceVariables: (value: string) => { return value; }, - getDataSourceSettingsByUid: getDatasourceSrv().getDataSourceSettingsByUid, }); } diff --git a/public/app/features/dashboard/components/Inspector/PanelInspector.tsx b/public/app/features/dashboard/components/Inspector/PanelInspector.tsx index 188423fe87c..e5f63922b35 100644 --- a/public/app/features/dashboard/components/Inspector/PanelInspector.tsx +++ b/public/app/features/dashboard/components/Inspector/PanelInspector.tsx @@ -5,7 +5,7 @@ import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { PanelPlugin } from '@grafana/data'; import { StoreState } from 'app/types'; -import { GetDataOptions } from '../../state/PanelQueryRunner'; +import { GetDataOptions } from '../../../query/state/PanelQueryRunner'; import { usePanelLatestData } from '../PanelEditor/usePanelLatestData'; import { InspectContent } from './InspectContent'; import { useDatasourceMetadata, useInspectTabs } from './hooks'; diff --git a/public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx b/public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx index 846062db360..28b207d83b3 100644 --- a/public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx @@ -30,9 +30,10 @@ export const DynamicConfigValueEditor: React.FC = } let editor; + // eslint-disable-next-line react/display-name const renderLabel = (includeDescription = true, includeCounter = false) => (isExpanded = false) => ( -
`; @@ -452,7 +452,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti ], "refresh": undefined, "revision": undefined, - "schemaVersion": 26, + "schemaVersion": 27, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -579,7 +579,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti ], "refresh": undefined, "revision": undefined, - "schemaVersion": 26, + "schemaVersion": 27, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -674,7 +674,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti ], "refresh": undefined, "revision": undefined, - "schemaVersion": 26, + "schemaVersion": 27, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -774,7 +774,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti ], "refresh": undefined, "revision": undefined, - "schemaVersion": 26, + "schemaVersion": 27, "snapshot": undefined, "style": "dark", "tags": Array [], diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index a3773b4ce03..973d44de5a0 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -1,6 +1,5 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; -import { isEqual } from 'lodash'; import { DataLink, LoadingState, PanelData, PanelMenuItem, QueryResultMetaNotice, ScopedVars } from '@grafana/data'; import { AngularComponent, config, getTemplateSrv } from '@grafana/runtime'; import { ClickOutsideWrapper, Icon, IconName, Tooltip, stylesFactory } from '@grafana/ui'; @@ -52,8 +51,8 @@ export class PanelHeader extends PureComponent { eventToClickCoordinates = (event: React.MouseEvent) => { return { - x: event.clientX, - y: event.clientY, + x: Math.floor(event.clientX), + y: Math.floor(event.clientY), }; }; @@ -62,7 +61,7 @@ export class PanelHeader extends PureComponent { }; isClick = (clickCoordinates: ClickCoordinates) => { - return isEqual(clickCoordinates, this.clickCoordinates); + return clickCoordinates.x === this.clickCoordinates.x && clickCoordinates.y === this.clickCoordinates.y; }; onMenuToggle = (event: React.MouseEvent) => { diff --git a/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap index 88313ad2513..c1150ebde3f 100644 --- a/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap +++ b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap @@ -215,7 +215,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 26, + "schemaVersion": 27, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -437,7 +437,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 26, + "schemaVersion": 27, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -659,7 +659,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 26, + "schemaVersion": 27, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -881,7 +881,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 26, + "schemaVersion": 27, "snapshot": undefined, "style": "dark", "tags": Array [], diff --git a/public/app/features/dashboard/panel_editor/QueriesTab.tsx b/public/app/features/dashboard/panel_editor/QueriesTab.tsx deleted file mode 100644 index 99434812939..00000000000 --- a/public/app/features/dashboard/panel_editor/QueriesTab.tsx +++ /dev/null @@ -1,383 +0,0 @@ -// Libraries -import React, { PureComponent } from 'react'; -// Components -import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; -import { QueryOptions } from './QueryOptions'; -import { Button, CustomScrollbar, HorizontalGroup, Modal, stylesFactory, Field } from '@grafana/ui'; -import { getLocationSrv, getDataSourceSrv } from '@grafana/runtime'; -import { QueryEditorRows } from './QueryEditorRows'; -// Services -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { backendSrv } from 'app/core/services/backend_srv'; -import config from 'app/core/config'; -// Types -import { PanelModel } from '../state/PanelModel'; -import { DashboardModel } from '../state/DashboardModel'; -import { - DataQuery, - DataSourceSelectItem, - DefaultTimeRange, - LoadingState, - PanelData, - DataSourceApi, -} from '@grafana/data'; -import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; -import { addQuery } from 'app/core/utils/query'; -import { Unsubscribable } from 'rxjs'; -import { DashboardQueryEditor, isSharedDashboardQuery } from 'app/plugins/datasource/dashboard'; -import { expressionDatasource, ExpressionDatasourceID } from 'app/features/expressions/ExpressionDatasource'; -import { css } from 'emotion'; -import { selectors } from '@grafana/e2e-selectors'; - -interface Props { - panel: PanelModel; - dashboard: DashboardModel; -} - -interface State { - dataSource?: DataSourceApi; - dataSourceItem: DataSourceSelectItem; - dataSourceError?: string; - helpContent: React.ReactNode; - isLoadingHelp: boolean; - isPickerOpen: boolean; - isAddingMixed: boolean; - scrollTop: number; - data: PanelData; - isHelpOpen: boolean; -} - -export class QueriesTab extends PureComponent { - datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources(); - backendSrv = backendSrv; - querySubscription: Unsubscribable | null; - - state: State = { - isLoadingHelp: false, - dataSourceItem: this.findCurrentDataSource(), - helpContent: null, - isPickerOpen: false, - isAddingMixed: false, - isHelpOpen: false, - scrollTop: 0, - data: { - state: LoadingState.NotStarted, - series: [], - timeRange: DefaultTimeRange, - }, - }; - - async componentDidMount() { - const { panel } = this.props; - const queryRunner = panel.getQueryRunner(); - - this.querySubscription = queryRunner.getData({ withTransforms: false, withFieldConfig: false }).subscribe({ - next: (data: PanelData) => this.onPanelDataUpdate(data), - }); - - try { - const ds = await getDataSourceSrv().get(panel.datasource); - this.setState({ dataSource: ds }); - } catch (error) { - const ds = await getDataSourceSrv().get(); - const dataSourceItem = this.findCurrentDataSource(ds.name); - this.setState({ dataSource: ds, dataSourceError: error?.message, dataSourceItem }); - } - } - - componentWillUnmount() { - if (this.querySubscription) { - this.querySubscription.unsubscribe(); - this.querySubscription = null; - } - } - - onPanelDataUpdate(data: PanelData) { - this.setState({ data }); - } - - findCurrentDataSource(dataSourceName: string | null = this.props.panel.datasource): DataSourceSelectItem { - return this.datasources.find(datasource => datasource.value === dataSourceName) || this.datasources[0]; - } - - onChangeDataSource = async (newDsItem: DataSourceSelectItem) => { - const { panel } = this.props; - const { dataSourceItem } = this.state; - - // switching to mixed - if (newDsItem.meta.mixed) { - // Set the datasource on all targets - panel.targets.forEach(target => { - if (target.datasource !== ExpressionDatasourceID) { - target.datasource = panel.datasource; - if (!target.datasource) { - target.datasource = config.defaultDatasource; - } - } - }); - } else if (dataSourceItem) { - // if switching from mixed - if (dataSourceItem.meta.mixed) { - // Remove the explicit datasource - for (const target of panel.targets) { - if (target.datasource !== ExpressionDatasourceID) { - delete target.datasource; - } - } - } else if (dataSourceItem.meta.id !== newDsItem.meta.id) { - // we are changing data source type, clear queries - panel.targets = [{ refId: 'A' }]; - } - } - - const dataSource = await getDataSourceSrv().get(newDsItem.value); - - panel.datasource = newDsItem.value; - - this.setState( - { - dataSourceItem: newDsItem, - dataSource: dataSource, - dataSourceError: undefined, - }, - () => panel.refresh() - ); - }; - - openQueryInspector = () => { - const { panel } = this.props; - - getLocationSrv().update({ - query: { inspect: panel.id, inspectTab: 'query' }, - partial: true, - }); - }; - - renderHelp = () => { - return; - }; - - /** - * Sets the queries for the panel - */ - onUpdateQueries = (queries: DataQuery[]) => { - this.props.panel.updateQueries(queries); - - // Need to force update to rerender query rows. - this.forceUpdate(); - }; - - onAddQueryClick = () => { - if (this.state.dataSourceItem.meta.mixed) { - this.setState({ isAddingMixed: true }); - return; - } - - this.onUpdateQueries(addQuery(this.props.panel.targets)); - this.onScrollBottom(); - }; - - onAddExpressionClick = () => { - this.onUpdateQueries(addQuery(this.props.panel.targets, expressionDatasource.newQuery())); - this.onScrollBottom(); - }; - - onScrollBottom = () => { - this.setState({ scrollTop: 1000 }); - }; - - renderTopSection(styles: QueriesTabStyls) { - const { panel } = this.props; - const { dataSourceItem, data, dataSource, dataSourceError } = this.state; - - if (!dataSource) { - return null; - } - - return ( -
-
-
- - - -
-
-
-
- -
-
- -
-
-
- ); - } - - onOpenHelp = () => { - this.setState({ isHelpOpen: true }); - }; - - onCloseHelp = () => { - this.setState({ isHelpOpen: false }); - }; - - renderMixedPicker = () => { - // We cannot filter on mixed flag as some mixed data sources like external plugin - // meta queries data source is mixed but also supports it's own queries - const filteredDsList = this.datasources.filter(ds => ds.meta.id !== 'mixed'); - - return ( - - ); - }; - - onAddMixedQuery = (datasource: any) => { - this.props.panel.targets = addQuery(this.props.panel.targets, { datasource: datasource.name }); - this.setState({ isAddingMixed: false, scrollTop: this.state.scrollTop + 10000 }); - this.forceUpdate(); - }; - - onMixedPickerBlur = () => { - this.setState({ isAddingMixed: false }); - }; - - onQueryChange = (query: DataQuery, index: number) => { - this.props.panel.changeQuery(query, index); - this.forceUpdate(); - }; - - setScrollTop = (event: React.MouseEvent) => { - const target = event.target as HTMLElement; - this.setState({ scrollTop: target.scrollTop }); - }; - - renderQueries() { - const { panel, dashboard } = this.props; - const { dataSourceItem, data } = this.state; - - if (isSharedDashboardQuery(dataSourceItem.name)) { - return this.onUpdateQueries([query])} />; - } - - return ( -
- -
- ); - } - - renderAddQueryRow() { - const { dataSourceItem, isAddingMixed } = this.state; - const showAddButton = !(isAddingMixed || isSharedDashboardQuery(dataSourceItem.name)); - - return ( - - {showAddButton && ( - - )} - {isAddingMixed && this.renderMixedPicker()} - {config.featureToggles.expressions && ( - - )} - - ); - } - - render() { - const { scrollTop, isHelpOpen } = this.state; - const styles = getStyles(); - - return ( - -
- {this.renderTopSection(styles)} -
{this.renderQueries()}
- {this.renderAddQueryRow()} - - {isHelpOpen && ( - - - - )} -
-
- ); - } -} - -const getStyles = stylesFactory(() => { - const { theme } = config; - - return { - innerWrapper: css` - display: flex; - flex-direction: column; - height: 100%; - padding: ${theme.spacing.md}; - `, - dataSourceRow: css` - display: flex; - margin-bottom: ${theme.spacing.md}; - `, - dataSourceRowItem: css` - margin-right: ${theme.spacing.inlineFormMargin}; - `, - dataSourceRowItemOptions: css` - flex-grow: 1; - margin-right: ${theme.spacing.inlineFormMargin}; - `, - queriesWrapper: css` - padding-bottom: 16px; - `, - }; -}); - -type QueriesTabStyls = ReturnType; diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index 18307305f96..bdea27b2765 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -4,6 +4,7 @@ import { PanelModel } from '../state/PanelModel'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; import { expect } from 'test/lib/common'; import { DataLinkBuiltInVars } from '@grafana/data'; +import { VariableHide } from '../../variables/types'; jest.mock('app/core/services/context_srv', () => ({})); @@ -132,7 +133,7 @@ describe('DashboardModel', () => { }); it('dashboard schema version should be set to latest', () => { - expect(model.schemaVersion).toBe(26); + expect(model.schemaVersion).toBe(27); }); it('graph thresholds should be migrated', () => { @@ -798,6 +799,107 @@ describe('DashboardModel', () => { expect(reactPanel.options.angular).toBeUndefined(); }); }); + + describe('when migrating constant variables so they are always hidden', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + templating: { + list: [ + { + type: 'query', + hide: VariableHide.dontHide, + datasource: null, + allFormat: '', + }, + { + type: 'query', + hide: VariableHide.hideLabel, + datasource: null, + allFormat: '', + }, + { + type: 'query', + hide: VariableHide.hideVariable, + datasource: null, + allFormat: '', + }, + { + type: 'constant', + hide: VariableHide.dontHide, + query: 'default value', + current: { selected: true, text: 'A', value: 'B' }, + options: [{ selected: true, text: 'A', value: 'B' }], + datasource: null, + allFormat: '', + }, + { + type: 'constant', + hide: VariableHide.hideLabel, + query: 'default value', + current: { selected: true, text: 'A', value: 'B' }, + options: [{ selected: true, text: 'A', value: 'B' }], + datasource: null, + allFormat: '', + }, + { + type: 'constant', + hide: VariableHide.hideVariable, + query: 'default value', + current: { selected: true, text: 'A', value: 'B' }, + options: [{ selected: true, text: 'A', value: 'B' }], + datasource: null, + allFormat: '', + }, + ], + }, + }); + }); + + it('should have six variables after migration', () => { + expect(model.templating.list.length).toBe(6); + }); + + it('should not touch other variable types', () => { + expect(model.templating.list[0].hide).toEqual(VariableHide.dontHide); + expect(model.templating.list[1].hide).toEqual(VariableHide.hideLabel); + expect(model.templating.list[2].hide).toEqual(VariableHide.hideVariable); + }); + + it('should migrate visible constant variables to textbox variables', () => { + expect(model.templating.list[3]).toEqual({ + type: 'textbox', + hide: VariableHide.dontHide, + query: 'default value', + current: { selected: true, text: 'default value', value: 'default value' }, + options: [{ selected: true, text: 'default value', value: 'default value' }], + datasource: null, + allFormat: '', + }); + expect(model.templating.list[4]).toEqual({ + type: 'textbox', + hide: VariableHide.hideLabel, + query: 'default value', + current: { selected: true, text: 'default value', value: 'default value' }, + options: [{ selected: true, text: 'default value', value: 'default value' }], + datasource: null, + allFormat: '', + }); + }); + + it('should change current and options for hidden constant variables', () => { + expect(model.templating.list[5]).toEqual({ + type: 'constant', + hide: VariableHide.hideVariable, + query: 'default value', + current: { selected: true, text: 'default value', value: 'default value' }, + options: [{ selected: true, text: 'default value', value: 'default value' }], + datasource: null, + allFormat: '', + }); + }); + }); }); function createRow(options: any, panelDescriptions: any[]) { diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts index 5517ae5bcc3..dee3bac16f3 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -6,7 +6,7 @@ import kbn from 'app/core/utils/kbn'; // Types import { PanelModel } from './PanelModel'; import { DashboardModel } from './DashboardModel'; -import { DataLinkBuiltInVars, DataLink, urlUtil } from '@grafana/data'; +import { DataLink, DataLinkBuiltInVars, urlUtil } from '@grafana/data'; // Constants import { DEFAULT_PANEL_SPAN, @@ -16,9 +16,9 @@ import { GRID_COLUMN_COUNT, MIN_PANEL_HEIGHT, } from 'app/core/constants'; -import { isMulti, isQuery } from 'app/features/variables/guard'; +import { isConstant, isMulti, isQuery } from 'app/features/variables/guard'; import { alignCurrentWithMulti } from 'app/features/variables/shared/multiOptions'; -import { VariableTag } from '../../variables/types'; +import { VariableHide, VariableTag } from '../../variables/types'; export class DashboardMigrator { dashboard: DashboardModel; @@ -31,7 +31,7 @@ export class DashboardMigrator { let i, j, k, n; const oldVersion = this.dashboard.schemaVersion; const panelUpgrades = []; - this.dashboard.schemaVersion = 26; + this.dashboard.schemaVersion = 27; if (oldVersion === this.dashboard.schemaVersion) { return; @@ -575,6 +575,21 @@ export class DashboardMigrator { }); } + if (oldVersion < 27) { + for (const variable of this.dashboard.templating.list) { + if (!isConstant(variable)) { + continue; + } + + if (variable.hide === VariableHide.dontHide || variable.hide === VariableHide.hideLabel) { + variable.type = 'textbox'; + } + + variable.current = { selected: true, text: variable.query ?? '', value: variable.query ?? '' }; + variable.options = [variable.current]; + } + } + if (panelUpgrades.length === 0) { return; } diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 429c4a5ea8b..947933bb9bd 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -14,12 +14,12 @@ import { dateTimeFormat, dateTimeFormatTimeAgo, DateTimeInput, + EventBusExtended, + EventBusSrv, PanelEvents, TimeRange, TimeZone, UrlQueryValue, - EventBusSrv, - EventBusExtended, } from '@grafana/data'; import { CoreEvents, DashboardMeta, KIOSK_MODE_TV } from 'app/types'; import { GetVariables, getVariables } from 'app/features/variables/state/selectors'; @@ -236,7 +236,9 @@ export class DashboardModel { const currentVariables = this.getVariablesFromState(); copy.templating = { - list: currentVariables.map(variable => variableAdapters.get(variable.type).getSaveModel(variable)), + list: currentVariables.map(variable => + variableAdapters.get(variable.type).getSaveModel(variable, defaults.saveVariables) + ), }; if (!defaults.saveVariables) { diff --git a/public/app/features/dashboard/state/PanelModel.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts index a3f10f210ae..8087ac5bb2f 100644 --- a/public/app/features/dashboard/state/PanelModel.test.ts +++ b/public/app/features/dashboard/state/PanelModel.test.ts @@ -7,13 +7,11 @@ import { standardEditorsRegistry, standardFieldConfigEditorRegistry, PanelData, - DataSourceInstanceSettings, FieldColorModeId, FieldColorConfigSettings, } from '@grafana/data'; import { ComponentClass } from 'react'; -import { PanelQueryRunner } from './PanelQueryRunner'; -import { setDataSourceSrv } from '@grafana/runtime'; +import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; class TablePanelCtrl {} @@ -192,11 +190,6 @@ describe('PanelModel', () => { }); it('should apply field config defaults', () => { - setDataSourceSrv({ - getDataSourceSettingsByUid(uid: string): DataSourceInstanceSettings | undefined { - return undefined; - }, - } as any); // default unit is overriden by model expect(model.getFieldOverrideOptions().fieldConfig.defaults.unit).toBe('mpg'); // default decimals are aplied diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 074fdfaac03..da160c4ac97 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -25,8 +25,7 @@ import { } from '@grafana/data'; import { EDIT_PANEL_ID } from 'app/core/constants'; import config from 'app/core/config'; -import { PanelQueryRunner } from './PanelQueryRunner'; -import { getDatasourceSrv } from '../../plugins/datasource_srv'; +import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; import { PanelOptionsChangedEvent, PanelQueriesChangedEvent, PanelTransformationsChangedEvent } from 'app/types/events'; export interface GridPos { @@ -133,8 +132,8 @@ export class PanelModel implements DataConfigSource { }; fieldConfig: FieldConfigSource; - maxDataPoints?: number; - interval?: string; + maxDataPoints?: number | null; + interval?: string | null; description?: string; links?: DataLink[]; transparent: boolean; @@ -462,7 +461,6 @@ export class PanelModel implements DataConfigSource { return { fieldConfig: this.fieldConfig, replaceVariables: this.replaceVariables, - getDataSourceSettingsByUid: getDatasourceSrv().getDataSourceSettingsByUid.bind(getDatasourceSrv()), fieldConfigRegistry: this.plugin.fieldConfigRegistry, theme: config.theme, }; diff --git a/public/app/features/dashboard/state/analyticsProcessor.ts b/public/app/features/dashboard/state/analyticsProcessor.ts index 9fe1caf6049..9834d86f149 100644 --- a/public/app/features/dashboard/state/analyticsProcessor.ts +++ b/public/app/features/dashboard/state/analyticsProcessor.ts @@ -1,67 +1,5 @@ -import { getDashboardSrv } from '../services/DashboardSrv'; import { DashboardModel } from './DashboardModel'; - -import { PanelData, LoadingState, DataSourceApi, CoreApp, urlUtil } from '@grafana/data'; - -import { - reportMetaAnalytics, - MetaAnalyticsEventName, - DataRequestEventPayload, - DashboardViewEventPayload, -} from '@grafana/runtime'; - -export function emitDataRequestEvent(datasource: DataSourceApi) { - let done = false; - - return (data: PanelData) => { - if (!data.request || done || data.request.app === CoreApp.Explore) { - return; - } - - const params = urlUtil.getUrlSearchParams(); - if (params.editPanel != null) { - return; - } - - if (data.state !== LoadingState.Done && data.state !== LoadingState.Error) { - return; - } - - const eventData: DataRequestEventPayload = { - eventName: MetaAnalyticsEventName.DataRequest, - datasourceName: datasource.name, - datasourceId: datasource.id, - panelId: data.request.panelId, - dashboardId: data.request.dashboardId, - dataSize: 0, - duration: data.request.endTime! - data.request.startTime, - }; - - // enrich with dashboard info - const dashboard = getDashboardSrv().getCurrent(); - if (dashboard) { - eventData.dashboardId = dashboard.id; - eventData.dashboardName = dashboard.title; - eventData.dashboardUid = dashboard.uid; - eventData.folderName = dashboard.meta.folderTitle; - } - - if (data.series && data.series.length > 0) { - // estimate size - eventData.dataSize = data.series.length; - } - - if (data.error) { - eventData.error = data.error.message; - } - - reportMetaAnalytics(eventData); - - // this done check is to make sure we do not double emit events in case - // there are multiple responses with done state - done = true; - }; -} +import { reportMetaAnalytics, MetaAnalyticsEventName, DashboardViewEventPayload } from '@grafana/runtime'; export function emitDashboardViewEvent(dashboard: DashboardModel) { const eventData: DashboardViewEventPayload = { diff --git a/public/app/features/dashboard/utils/loadSnapshotData.ts b/public/app/features/dashboard/utils/loadSnapshotData.ts index d81802272f0..0168a64ef4e 100644 --- a/public/app/features/dashboard/utils/loadSnapshotData.ts +++ b/public/app/features/dashboard/utils/loadSnapshotData.ts @@ -1,8 +1,7 @@ import { applyFieldOverrides, DefaultTimeRange, LoadingState, PanelData } from '@grafana/data'; import { config } from 'app/core/config'; -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { DashboardModel, PanelModel } from '../state'; -import { getProcessedDataFrames } from '../state/runRequest'; +import { getProcessedDataFrames } from '../../query/state/runRequest'; export function loadSnapshotData(panel: PanelModel, dashboard: DashboardModel): PanelData { const data = getProcessedDataFrames(panel.snapshotData); @@ -16,9 +15,7 @@ export function loadSnapshotData(panel: PanelModel, dashboard: DashboardModel): defaults: {}, overrides: [], }, - autoMinMax: true, replaceVariables: panel.replaceVariables, - getDataSourceSettingsByUid: getDatasourceSrv().getDataSourceSettingsByUid.bind(getDatasourceSrv()), fieldConfigRegistry: panel.plugin!.fieldConfigRegistry, theme: config.theme, timeZone: dashboard.getTimezone(), diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx index 04234e7ad04..a2bb87ee604 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx @@ -2,8 +2,6 @@ import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import isString from 'lodash/isString'; -import { Icon } from '@grafana/ui'; -import { selectors } from '@grafana/e2e-selectors'; // Components import Page from 'app/core/components/Page/Page'; import { GenericDataSourcePlugin, PluginSettings } from './PluginSettings'; @@ -25,10 +23,12 @@ import { getRouteParamsId } from 'app/core/selectors/location'; // Types import { CoreEvents, StoreState } from 'app/types/'; import { DataSourcePluginMeta, DataSourceSettings, NavModel, UrlQueryMap } from '@grafana/data'; +import { Alert } from '@grafana/ui'; import { getDataSourceLoadingNav } from '../state/navModel'; import PluginStateinfo from 'app/features/plugins/PluginStateInfo'; import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers'; import { connectWithCleanUp } from 'app/core/components/connectWithCleanUp'; +import { selectors } from '@grafana/e2e-selectors'; export interface Props { navModel: NavModel; @@ -172,7 +172,7 @@ export class DataSourceSettingsPage extends PureComponent { } renderSettings() { - const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource, testingStatus, plugin } = this.props; + const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource, plugin, testingStatus } = this.props; return (
@@ -204,16 +204,11 @@ export class DataSourceSettingsPage extends PureComponent {
{testingStatus && testingStatus.message && ( -
-
- {testingStatus.status === 'error' ? : } -
-
-
- {testingStatus.message} -
-
-
+ )}
diff --git a/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap index 7fd90bb03c5..e9ee2a6f705 100644 --- a/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap +++ b/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap @@ -16,11 +16,11 @@ exports[`Render should render component 1`] = ` } } > - Name - + { config.datasources = settings.datasources; config.defaultDatasource = settings.defaultDatasource; - getDatasourceSrv().init(); + getDatasourceSrv().init(config.datasources, settings.defaultDatasource); }); } diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 2250c6de878..7d1726b5596 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -7,7 +7,7 @@ import { css } from 'emotion'; import { ExploreId, ExploreItemState } from 'app/types/explore'; import { Icon, IconButton, LegacyForms, SetInterval, Tooltip } from '@grafana/ui'; -import { DataQuery, RawTimeRange, TimeRange, TimeZone } from '@grafana/data'; +import { DataQuery, DataSourceInstanceSettings, RawTimeRange, TimeRange, TimeZone } from '@grafana/data'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { StoreState } from 'app/types/store'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; @@ -24,7 +24,6 @@ import { LiveTailButton } from './LiveTailButton'; import { ResponsiveButton } from './ResponsiveButton'; import { RunButton } from './RunButton'; import { LiveTailControls } from './useLiveTailControls'; -import { getExploreDatasources } from './state/selectors'; import { setDashboardQueriesToUpdateOnLoad } from '../dashboard/state/reducers'; import { cancelQueries, clearQueries, runQueries } from './state/query'; @@ -81,8 +80,8 @@ interface DispatchProps { type Props = StateProps & DispatchProps & OwnProps; export class UnConnectedExploreToolbar extends PureComponent { - onChangeDatasource = async (option: { value: any }) => { - this.props.changeDatasource(this.props.exploreId, option.value, { importQueries: true }); + onChangeDatasource = async (dsSettings: DataSourceInstanceSettings) => { + this.props.changeDatasource(this.props.exploreId, dsSettings.name, { importQueries: true }); }; onClearAll = () => { @@ -141,12 +140,6 @@ export class UnConnectedExploreToolbar extends PureComponent { }); } - getSelectedDatasource = () => { - const { datasourceName } = this.props; - const exploreDatasources = getExploreDatasources(); - return datasourceName ? exploreDatasources.find(datasource => datasource.name === datasourceName) : undefined; - }; - render() { const { datasourceMissing, @@ -214,8 +207,7 @@ export class UnConnectedExploreToolbar extends PureComponent { >
diff --git a/public/app/features/explore/JSONViewer.tsx b/public/app/features/explore/JSONViewer.tsx index d185babfa6a..58e4b485f4f 100644 --- a/public/app/features/explore/JSONViewer.tsx +++ b/public/app/features/explore/JSONViewer.tsx @@ -1,6 +1,6 @@ import React from 'react'; -export default function({ value }: any) { +export default function JSONViewer({ value }: any) { return (
{JSON.stringify(value, undefined, 2)}
diff --git a/public/app/features/explore/ResponsiveButton.tsx b/public/app/features/explore/ResponsiveButton.tsx index 16b628808d1..86c721c2ec1 100644 --- a/public/app/features/explore/ResponsiveButton.tsx +++ b/public/app/features/explore/ResponsiveButton.tsx @@ -54,3 +54,5 @@ export const ResponsiveButton = forwardRef((props, ref
); }); + +ResponsiveButton.displayName = 'ResponsiveButton'; diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index 23e1db961ac..b7e7dcd8083 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -83,6 +83,7 @@ export function TraceView(props: Props) { ); const createSpanLink = useMemo(() => createSpanLinkFactory(props.splitOpenFn), [props.splitOpenFn]); + const scrollElement = document.getElementsByClassName('scroll-canvas')[0]; if (!traceProp) { return null; @@ -148,6 +149,7 @@ export function TraceView(props: Props) { )} uiFind={search} createSpanLink={createSpanLink} + scrollElement={scrollElement} /> diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index 40da9256870..7ff974bb9b1 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -13,14 +13,8 @@ describe('createSpanLinkFactory', () => { it('returns undefined if there is no loki data source', () => { setDataSourceSrv({ - getExternal() { - return [ - { - meta: { - id: 'not loki', - }, - } as DataSourceInstanceSettings, - ]; + getList() { + return []; }, } as any); const splitOpenFn = jest.fn(); @@ -30,7 +24,7 @@ describe('createSpanLinkFactory', () => { it('creates correct link', () => { setDataSourceSrv({ - getExternal() { + getList() { return [ { name: 'loki1', @@ -41,10 +35,10 @@ describe('createSpanLinkFactory', () => { } as DataSourceInstanceSettings, ]; }, - getDataSourceSettingsByUid(uid: string): DataSourceInstanceSettings | undefined { + getInstanceSettings(uid: string): DataSourceInstanceSettings | undefined { if (uid === 'lokiUid') { return { - name: 'Loki1', + name: 'loki1', } as any; } return undefined; @@ -82,7 +76,7 @@ describe('createSpanLinkFactory', () => { } as any); expect(linkDef.href).toBe( - `/explore?left={"range":{"from":"20201014T000000","to":"20201014T010006"},"datasource":"Loki1","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"}","refId":""}]}` + `/explore?left={"range":{"from":"20201014T000000","to":"20201014T010006"},"datasource":"loki1","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"}","refId":""}]}` ); }); }); diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index 2a85d45d0cf..e71976c7e58 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -15,9 +15,7 @@ export function createSpanLinkFactory(splitOpenFn: (options: { datasourceUid: st } // Right now just hardcoded for first loki DS we can find - const lokiDs = getDataSourceSrv() - .getExternal() - .find(ds => ds.meta.id === 'loki'); + const lokiDs = getDataSourceSrv().getList({ pluginId: 'loki' })[0]; if (!lokiDs) { return undefined; @@ -34,17 +32,24 @@ export function createSpanLinkFactory(splitOpenFn: (options: { datasourceUid: st url: '', internal: { datasourceUid: lokiDs.uid, + datasourceName: lokiDs.name, query: { expr: getLokiQueryFromSpan(span), refId: '', }, }, }; - const link = mapInternalLinkToExplore(dataLink, {}, getTimeRangeFromSpan(span), {} as Field, { + + const link = mapInternalLinkToExplore({ + link: dataLink, + internalLink: dataLink.internal!, + scopedVars: {}, + range: getTimeRangeFromSpan(span), + field: {} as Field, onClickFn: splitOpenFn, replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), - getDataSourceSettingsByUid: getDataSourceSrv().getDataSourceSettingsByUid.bind(getDataSourceSrv()), }); + return { href: link.href, onClick: link.onClick, diff --git a/public/app/features/explore/TraceView/uiElements.tsx b/public/app/features/explore/TraceView/uiElements.tsx index d2ac0750723..4297994bf87 100644 --- a/public/app/features/explore/TraceView/uiElements.tsx +++ b/public/app/features/explore/TraceView/uiElements.tsx @@ -18,18 +18,24 @@ export const UIElements: Elements = { Dropdown: (() => null as any) as any, Menu: (() => null as any) as any, MenuItem: (() => null as any) as any, - Button: ({ onClick, children, className }: ButtonProps) => ( - - ), + Button({ onClick, children, className }: ButtonProps) { + return ( + + ); + }, Divider, - Input: props => , - InputGroup: ({ children, className, style }) => ( - - {children} - - ), + Input(props) { + return ; + }, + InputGroup({ children, className, style }) { + return ( + + {children} + + ); + }, }; const getStyles = stylesFactory((theme: GrafanaTheme) => { diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 34a26ec7dfd..ba1e8f1cfc3 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -28,7 +28,9 @@ type Mock = jest.Mock; jest.mock('react-virtualized-auto-sizer', () => { return { __esModule: true, - default: (props: any) =>
{props.children({ width: 1000 })}
, + default(props: any) { + return
{props.children({ width: 1000 })}
; + }, }; }); @@ -218,10 +220,12 @@ function setup(options?: SetupOptions): { datasources: { [name: string]: DataSou const dsSettings = options?.datasources || defaultDatasources; setDataSourceSrv({ - getExternal(): DataSourceInstanceSettings[] { + getList(): DataSourceInstanceSettings[] { return dsSettings.map(d => d.settings); }, - + getInstanceSettings(name: string) { + return dsSettings.map(d => d.settings).find(x => x.name === name); + }, get(name?: string | null, scopedVars?: ScopedVars): Promise { return Promise.resolve((name ? dsSettings.find(d => d.api.name === name) : dsSettings[0])!.api); }, @@ -273,11 +277,13 @@ function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: nu }, api: { components: { - QueryEditor: (props: QueryEditorProps) => ( -
- {name} Editor input: {props.query.expr} -
- ), + QueryEditor(props: QueryEditorProps) { + return ( +
+ {name} Editor input: {props.query.expr} +
+ ); + }, }, name: name, query: jest.fn(), diff --git a/public/app/features/explore/__snapshots__/ErrorContainer.test.tsx.snap b/public/app/features/explore/__snapshots__/ErrorContainer.test.tsx.snap index 2d34f709e05..a7e0ccfff27 100644 --- a/public/app/features/explore/__snapshots__/ErrorContainer.test.tsx.snap +++ b/public/app/features/explore/__snapshots__/ErrorContainer.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ErrorContainer should render component 1`] = ` - @@ -29,5 +29,5 @@ exports[`ErrorContainer should render component 1`] = `
- + `; diff --git a/public/app/features/explore/__snapshots__/Explore.test.tsx.snap b/public/app/features/explore/__snapshots__/Explore.test.tsx.snap index 3b9641966c5..1b67a99f918 100644 --- a/public/app/features/explore/__snapshots__/Explore.test.tsx.snap +++ b/public/app/features/explore/__snapshots__/Explore.test.tsx.snap @@ -37,7 +37,7 @@ exports[`Explore should render component 1`] = ` richHistoryButtonActive={false} />
- ; - -beforeEach(() => { - getDatasourceSrvMock.mockClear(); - getDatasourceSrvMock.mockImplementation( - () => - ({ - getExternal: jest.fn().mockReturnValue([]), - get: jest.fn().mockReturnValue({ - testDatasource: jest.fn(), - init: jest.fn(), - }), - } as any) - ); -}); +import { setDataSourceSrv } from '@grafana/runtime'; jest.mock('../../dashboard/services/TimeSrv', () => ({ getTimeSrv: jest.fn().mockReturnValue({ @@ -47,6 +30,21 @@ const testRange = { }, }; +setDataSourceSrv({ + getList() { + return []; + }, + getInstanceSettings(name: string) { + return { name: 'hello' }; + }, + get() { + return Promise.resolve({ + testDatasource: jest.fn(), + init: jest.fn(), + }); + }, +} as any); + const setup = (updateOverides?: Partial) => { const exploreId = ExploreId.left; const containerWidth = 1920; diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index 17e2f5dafa0..980d3cfb0c9 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -32,7 +32,7 @@ import { serializeStateToUrlParam } from '@grafana/data/src/utils/url'; import { runQueries, setQueriesAction } from './query'; import { updateTime } from './time'; import { toRawTimeRange } from '../utils/time'; -import { getExploreDatasources } from './selectors'; +import { getDataSourceSrv } from '@grafana/runtime'; // // Actions and Payloads @@ -131,7 +131,7 @@ export function initializeExplore( originPanelId?: number | null ): ThunkResult { return async (dispatch, getState) => { - const exploreDatasources = getExploreDatasources(); + const exploreDatasources = getDataSourceSrv().getList(); let instance = undefined; let history: HistoryItem[] = []; diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index 4ee5c1281ac..5e2bf7a5351 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -109,7 +109,7 @@ export function splitOpen(options?: { } as DataQuery, ]; - const dataSourceSettings = getDatasourceSrv().getDataSourceSettingsByUid(options.datasourceUid); + const dataSourceSettings = getDatasourceSrv().getInstanceSettings(options.datasourceUid); await dispatch(changeDatasource(ExploreId.right, dataSourceSettings!.name)); await dispatch(setQueriesAction({ exploreId: ExploreId.right, queries })); diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 896c8edb716..f7c01368006 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -27,7 +27,7 @@ import { ExploreId, QueryOptions } from 'app/types/explore'; import { getTimeZone } from 'app/features/profile/state/selectors'; import { getShiftedTimeRange } from 'app/core/utils/timePicker'; import { notifyApp } from '../../../core/actions'; -import { preProcessPanelData, runRequest } from '../../dashboard/state/runRequest'; +import { preProcessPanelData, runRequest } from '../../query/state/runRequest'; import { decorateWithGraphLogsTraceAndTable, decorateWithGraphResult, diff --git a/public/app/features/explore/state/selectors.ts b/public/app/features/explore/state/selectors.ts index f14c6ad7e7d..32e097b4f62 100644 --- a/public/app/features/explore/state/selectors.ts +++ b/public/app/features/explore/state/selectors.ts @@ -1,8 +1,6 @@ import { createSelector } from 'reselect'; import { ExploreItemState } from 'app/types'; import { filterLogLevels, dedupLogRows } from 'app/core/logs_model'; -import { getDatasourceSrv } from '../../plugins/datasource_srv'; -import { DataSourceSelectItem } from '@grafana/data'; const logsRowsSelector = (state: ExploreItemState) => state.logsResult && state.logsResult.rows; const hiddenLogLevelsSelector = (state: ExploreItemState) => state.hiddenLogLevels; @@ -19,16 +17,3 @@ export const deduplicatedRowsSelector = createSelector( return dedupLogRows(filteredRows, dedupStrategy); } ); - -export const getExploreDatasources = (): DataSourceSelectItem[] => { - return getDatasourceSrv() - .getExternal() - .map( - (ds: any) => - ({ - value: ds.name, - name: ds.name, - meta: ds.meta, - } as DataSourceSelectItem) - ); -}; diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index 8d1c66c96be..57b275dcf1a 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -1,17 +1,6 @@ import { getFieldLinksForExplore } from './links'; -import { - ArrayVector, - DataLink, - DataSourceInstanceSettings, - dateTime, - Field, - FieldType, - LinkModel, - ScopedVars, - TimeRange, -} from '@grafana/data'; +import { ArrayVector, DataLink, dateTime, Field, FieldType, LinkModel, ScopedVars, TimeRange } from '@grafana/data'; import { setLinkSrv } from '../../panel/panellinks/link_srv'; -import { setDataSourceSrv } from '@grafana/runtime'; describe('getFieldLinksForExplore', () => { it('returns correct link model for external link', () => { @@ -43,6 +32,7 @@ describe('getFieldLinksForExplore', () => { internal: { query: { query: 'query_1' }, datasourceUid: 'uid_1', + datasourceName: 'test_ds', }, }); const splitfn = jest.fn(); @@ -82,18 +72,7 @@ function setup(link: DataLink) { return link.url; }, }); - setDataSourceSrv({ - getDataSourceSettingsByUid(uid: string) { - return { - id: 1, - uid: 'uid_1', - type: 'metrics', - name: 'test_ds', - meta: {}, - jsonData: {}, - } as DataSourceInstanceSettings; - }, - } as any); + const field: Field = { name: 'flux-dimensions', type: FieldType.string, diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index eb601f4b550..fc1a7c54842 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -1,6 +1,6 @@ import { Field, LinkModel, TimeRange, mapInternalLinkToExplore } from '@grafana/data'; import { getLinkSrv } from '../../panel/panellinks/link_srv'; -import { getDataSourceSrv, getTemplateSrv } from '@grafana/runtime'; +import { getTemplateSrv } from '@grafana/runtime'; import { splitOpen } from '../state/main'; /** @@ -33,10 +33,14 @@ export const getFieldLinksForExplore = ( } return linkModel; } else { - return mapInternalLinkToExplore(link, scopedVars, range, field, { + return mapInternalLinkToExplore({ + link, + internalLink: link.internal, + scopedVars: scopedVars, + range, + field, onClickFn: splitOpenFn, replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), - getDataSourceSettingsByUid: getDataSourceSrv().getDataSourceSettingsByUid.bind(getDataSourceSrv()), }); } }) diff --git a/public/app/features/live/dashboard/dashboardWatcher.ts b/public/app/features/live/dashboard/dashboardWatcher.ts index 0dcac078f1d..8e3f71a7528 100644 --- a/public/app/features/live/dashboard/dashboardWatcher.ts +++ b/public/app/features/live/dashboard/dashboardWatcher.ts @@ -61,13 +61,11 @@ class DashboardWatcher { this.channel = live.getChannel({ scope: LiveChannelScope.Grafana, namespace: 'dashboard', - path: uid, + path: `uid/${uid}`, }); this.channel.getStream().subscribe(this.observer); this.uid = uid; } - - console.log('Watch', uid); } leave() { diff --git a/public/app/features/live/scopes.ts b/public/app/features/live/scopes.ts index 2097db5c3bd..658ef3973a1 100644 --- a/public/app/features/live/scopes.ts +++ b/public/app/features/live/scopes.ts @@ -9,12 +9,12 @@ export abstract class GrafanaLiveScope { /** * Load the real namespaces */ - abstract async getChannelSupport(namespace: string): Promise; + abstract getChannelSupport(namespace: string): Promise; /** * List the possible values within this scope */ - abstract async listNamespaces(): Promise>>; + abstract listNamespaces(): Promise>>; } export interface CoreGrafanaLiveFeature { @@ -82,7 +82,9 @@ export class GrafanaLiveDataSourceScope extends GrafanaLiveScope { if (this.names) { return Promise.resolve(this.names); } + const names: Array> = []; + for (const [key, ds] of Object.entries(config.datasources)) { if (ds.meta.live) { try { @@ -99,6 +101,7 @@ export class GrafanaLiveDataSourceScope extends GrafanaLiveScope { } } } + return (this.names = names); } } diff --git a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx index 0262ab2b186..dc93b5effb0 100644 --- a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx +++ b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx @@ -11,11 +11,11 @@ import { Legend, } from '@grafana/ui'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; -import DataSourcePicker from 'app/core/components/Select/DataSourcePicker'; +import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { DashboardInput, DashboardInputs, DataSourceInput, ImportDashboardDTO } from '../state/reducers'; import { validateTitle, validateUid } from '../utils/validation'; -interface Props extends Omit, 'formState' | 'watch'> { +interface Props extends Omit, 'formState'> { uidReset: boolean; inputs: DashboardInputs; initialFolderId: number; @@ -36,8 +36,10 @@ export const ImportDashboardForm: FC = ({ onUidReset, onCancel, onSubmit, + watch, }) => { const [isSubmitted, setSubmitted] = useState(false); + const watchDataSources = watch('dataSources'); /* This useEffect is needed for overwriting a dashboard. It @@ -96,6 +98,7 @@ export const ImportDashboardForm: FC = ({ {inputs.dataSources && inputs.dataSources.map((input: DataSourceInput, index: number) => { const dataSourceOption = `dataSources[${index}]`; + const current = watchDataSources ?? []; return ( = ({ > { validateFieldsOnMount={['title', 'uid']} validateOn="onChange" > - {({ register, errors, control, getValues }) => ( + {({ register, errors, control, watch, getValues }) => ( { onCancel={this.onCancel} onUidReset={this.onUidReset} onSubmit={this.onSubmit} + watch={watch} initialFolderId={folder.id} /> )} diff --git a/public/app/features/manage-dashboards/components/SnapshotListTable.tsx b/public/app/features/manage-dashboards/components/SnapshotListTable.tsx index 2aa5d13f622..744f4c890b5 100644 --- a/public/app/features/manage-dashboards/components/SnapshotListTable.tsx +++ b/public/app/features/manage-dashboards/components/SnapshotListTable.tsx @@ -1,7 +1,6 @@ import React, { FC, useState, useCallback, useEffect } from 'react'; import { ConfirmModal, Button, LinkButton } from '@grafana/ui'; import { getBackendSrv } from '@grafana/runtime'; -import { noop } from 'rxjs'; import { Snapshot } from '../types'; interface Props { @@ -28,11 +27,12 @@ export const SnapshotListTable: FC = ({ url }) => { const doRemoveSnapshot = useCallback( async (snapshot: Snapshot) => { - setSnapshots(snapshots.filter(ss => ss.key !== snapshot.key)); + const filteredSnapshots = snapshots.filter(ss => ss.key !== snapshot.key); + setSnapshots(filteredSnapshots); await getBackendSrv() .delete(`/api/snapshots/${snapshot.key}`) - .then(noop, () => { - setSnapshots(snapshots.concat(snapshot)); + .catch(() => { + setSnapshots(snapshots); }); }, [snapshots] @@ -59,9 +59,9 @@ export const SnapshotListTable: FC = ({ url }) => { - {snapshots.map((snapshot, key) => { + {snapshots.map(snapshot => { return ( - + {snapshot.name} diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index 1033ea18ffe..fd71b3582d6 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -1,6 +1,5 @@ -import { AppEvents, DataSourceInstanceSettings, DataSourceSelectItem, locationUtil } from '@grafana/data'; +import { AppEvents, DataSourceInstanceSettings, locationUtil } from '@grafana/data'; import { getBackendSrv } from 'app/core/services/backend_srv'; -import config from 'app/core/config'; import { clearDashboard, setInputs, @@ -13,6 +12,7 @@ import { updateLocation } from 'app/core/actions'; import { ThunkResult, FolderInfo, DashboardDTO, DashboardDataDTO } from 'app/types'; import { appEvents } from '../../../core/core'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; +import { getDataSourceSrv } from '@grafana/runtime'; export function fetchGcomDashboard(id: string): ThunkResult { return async dispatch => { @@ -73,13 +73,13 @@ export function importDashboard(importDashboardForm: ImportDashboardDTO): ThunkR const inputs = getState().importDashboard.inputs; let inputsToPersist = [] as any[]; - importDashboardForm.dataSources?.forEach((dataSource: DataSourceSelectItem, index: number) => { + importDashboardForm.dataSources?.forEach((dataSource: DataSourceInstanceSettings, index: number) => { const input = inputs.dataSources[index]; inputsToPersist.push({ name: input.name, type: input.type, pluginId: input.pluginId, - value: dataSource.value, + value: dataSource.name, }); }); @@ -105,19 +105,13 @@ export function importDashboard(importDashboardForm: ImportDashboardDTO): ThunkR } const getDataSourceOptions = (input: { pluginId: string; pluginName: string }, inputModel: any) => { - const sources = Object.values(config.datasources).filter( - (val: DataSourceInstanceSettings) => val.type === input.pluginId - ); + const sources = getDataSourceSrv().getList({ pluginId: input.pluginId }); if (sources.length === 0) { inputModel.info = 'No data sources of type ' + input.pluginName + ' found'; } else if (!inputModel.info) { inputModel.info = 'Select a ' + input.pluginName + ' data source'; } - - inputModel.options = sources.map(val => { - return { name: val.name, value: val.name, meta: val.meta }; - }); }; export function moveDashboards(dashboardUids: string[], toFolder: FolderInfo) { diff --git a/public/app/features/manage-dashboards/state/reducers.ts b/public/app/features/manage-dashboards/state/reducers.ts index ee23a8bf380..724847f00a1 100644 --- a/public/app/features/manage-dashboards/state/reducers.ts +++ b/public/app/features/manage-dashboards/state/reducers.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { DataSourceSelectItem } from '@grafana/data'; +import { DataSourceInstanceSettings } from '@grafana/data'; export enum DashboardSource { Gcom = 0, @@ -11,7 +11,7 @@ export interface ImportDashboardDTO { uid: string; gnetId: string; constants: string[]; - dataSources: DataSourceSelectItem[]; + dataSources: DataSourceInstanceSettings[]; folder: { id: number; title?: string }; } @@ -30,7 +30,6 @@ export interface DashboardInput { export interface DataSourceInput extends DashboardInput { pluginId: string; - options: DataSourceSelectItem[]; } export interface DashboardInputs { diff --git a/public/app/features/manage-dashboards/state/selectors.ts b/public/app/features/manage-dashboards/state/selectors.ts index 7113dc88913..4f9223b289d 100644 --- a/public/app/features/manage-dashboards/state/selectors.ts +++ b/public/app/features/manage-dashboards/state/selectors.ts @@ -14,7 +14,7 @@ export const getDashboardNavModel = (state: StoreState): NavModel => { } // This needs to be copied to avoid mutating the store in a selector - nav.main.children = [...navModel.main.children]; + nav.main.children = [...(navModel.main.children ?? [])]; for (const item of nav.main.children) { item.active = false; diff --git a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap index 95b79ec31ad..682c28d0482 100644 --- a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap +++ b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap @@ -35,7 +35,7 @@ exports[`Render should render organization and preferences 1`] = ` - + - + `; diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index 4bcf3bdf465..f5b157bdd3a 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -16,7 +16,7 @@ import { } from '@grafana/data'; import { Unsubscribable } from 'rxjs'; import { PanelModel } from 'app/features/dashboard/state'; -import { PanelQueryRunner } from '../dashboard/state/PanelQueryRunner'; +import { PanelQueryRunner } from '../query/state/PanelQueryRunner'; class MetricsPanelCtrl extends PanelCtrl { scope: any; diff --git a/public/app/features/panel/panellinks/linkSuppliers.test.ts b/public/app/features/panel/panellinks/linkSuppliers.test.ts index 941b83b320a..4abf5a4a3aa 100644 --- a/public/app/features/panel/panellinks/linkSuppliers.test.ts +++ b/public/app/features/panel/panellinks/linkSuppliers.test.ts @@ -90,10 +90,8 @@ describe('getFieldLinksSupplier', () => { overrides: [], }, replaceVariables: (val: string) => val, - getDataSourceSettingsByUid: (val: string) => ({} as any), timeZone: 'utc', theme: getTheme(), - autoMinMax: true, })[0]; const rowIndex = 0; diff --git a/public/app/features/playlist/partials/playlists.html b/public/app/features/playlist/partials/playlists.html index 291fb1822f2..fccba3e0f9c 100644 --- a/public/app/features/playlist/partials/playlists.html +++ b/public/app/features/playlist/partials/playlists.html @@ -2,7 +2,7 @@
-
+
New playlist @@ -13,11 +13,12 @@ Name - + - - {{playlist.name}} + + {{playlist.name}} + {{playlist.name}}
- +
+ +
+
+
There are no playlists created yet
+

Unfortunately you don't have permission to create playlists.

+
+
diff --git a/public/app/features/playlist/playlists_ctrl.ts b/public/app/features/playlist/playlists_ctrl.ts index cf83a85b8e2..e42e0e262ca 100644 --- a/public/app/features/playlist/playlists_ctrl.ts +++ b/public/app/features/playlist/playlists_ctrl.ts @@ -1,9 +1,11 @@ import { IScope } from 'angular'; import _ from 'lodash'; import { AppEvents } from '@grafana/data'; +import { OrgRole } from 'app/types'; import { getBackendSrv } from '@grafana/runtime'; import coreModule from '../../core/core_module'; +import config from '../../core/config'; import { NavModelSrv } from 'app/core/nav_model_srv'; import { AppEventEmitter, CoreEvents } from 'app/types'; import { promiseToDigest } from '../../core/utils/promiseToDigest'; @@ -11,10 +13,13 @@ import { promiseToDigest } from '../../core/utils/promiseToDigest'; export class PlaylistsCtrl { playlists: any; navModel: any; + canEditPlaylists: boolean; /** @ngInject */ constructor(private $scope: IScope & AppEventEmitter, navModelSrv: NavModelSrv) { this.navModel = navModelSrv.getNav('dashboards', 'playlists', 0); + this.canEditPlaylists = config.bootData.user.orgRole !== OrgRole.Viewer; + promiseToDigest($scope)( getBackendSrv() .get('/api/playlists') diff --git a/public/app/features/plugins/PluginDashboards.tsx b/public/app/features/plugins/PluginDashboards.tsx index 5dbebc5c7a6..ee5461f1866 100644 --- a/public/app/features/plugins/PluginDashboards.tsx +++ b/public/app/features/plugins/PluginDashboards.tsx @@ -44,7 +44,7 @@ export class PluginDashboards extends PureComponent { const { dashboards } = this.state; return this.import(dashboards[index], true).then(() => { if (index + 1 < dashboards.length) { - return new Promise(resolve => { + return new Promise(resolve => { setTimeout(() => { this.importNext(index + 1).then(() => { resolve(); diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index d892d171cbd..e82ed4f9313 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -1,10 +1,9 @@ // Libraries -import sortBy from 'lodash/sortBy'; import coreModule from 'app/core/core_module'; // Services & Utils -import config from 'app/core/config'; import { importDataSourcePlugin } from './plugin_loader'; import { + GetDataSourceListFilters, DataSourceSrv as DataSourceService, getDataSourceSrv as getDataSourceService, TemplateSrv, @@ -16,49 +15,86 @@ import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; // Pretend Datasource import { expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; import { DataSourceVariableModel } from '../variables/types'; +import { cloneDeep } from 'lodash'; export class DatasourceSrv implements DataSourceService { - datasources: Record = {}; + private datasources: Record = {}; + private settingsMapByName: Record = {}; + private settingsMapByUid: Record = {}; + private defaultName = ''; /** @ngInject */ constructor( private $injector: auto.IInjectorService, private $rootScope: GrafanaRootScope, private templateSrv: TemplateSrv - ) { - this.init(); - } + ) {} - init() { + init(settingsMapByName: Record, defaultName: string) { this.datasources = {}; + this.settingsMapByUid = {}; + this.settingsMapByName = settingsMapByName; + this.defaultName = defaultName; + + for (const dsSettings of Object.values(settingsMapByName)) { + this.settingsMapByUid[dsSettings.uid] = dsSettings; + } } getDataSourceSettingsByUid(uid: string): DataSourceInstanceSettings | undefined { - return Object.values(config.datasources).find(ds => ds.uid === uid); + return this.settingsMapByUid[uid]; } - get(name?: string | null, scopedVars?: ScopedVars): Promise { - if (!name) { - return this.get(config.defaultDatasource); + getInstanceSettings(nameOrUid: string | null | undefined): DataSourceInstanceSettings | undefined { + if (nameOrUid === 'default' || nameOrUid === null || nameOrUid === undefined) { + return this.settingsMapByName[this.defaultName]; + } + + // Complex logic to support template variable data source names + // For this we just pick the current or first data source in the variable + if (nameOrUid[0] === '$') { + const interpolatedName = this.templateSrv.replace(nameOrUid, {}, variableInterpolation); + const dsSettings = this.settingsMapByUid[interpolatedName] ?? this.settingsMapByName[interpolatedName]; + if (!dsSettings) { + return undefined; + } + // The return name or uid needs preservet string containing the variable + const clone = cloneDeep(dsSettings); + clone.name = nameOrUid; + return clone; + } + + return this.settingsMapByUid[nameOrUid] ?? this.settingsMapByName[nameOrUid]; + } + + get(nameOrUid?: string | null, scopedVars?: ScopedVars): Promise { + if (!nameOrUid) { + return this.get(this.defaultName); + } + + // Check if nameOrUid matches a uid and then get the name + const byUid = this.settingsMapByUid[nameOrUid]; + if (byUid) { + nameOrUid = byUid.name; + } + + // This check is duplicated below, this is here mainly as performance optimization to skip interpolation + if (this.datasources[nameOrUid]) { + return Promise.resolve(this.datasources[nameOrUid]); } // Interpolation here is to support template variable in data source selection - name = this.templateSrv.replace(name, scopedVars, (value: any[]) => { - if (Array.isArray(value)) { - return value[0]; - } - return value; - }); + nameOrUid = this.templateSrv.replace(nameOrUid, scopedVars, variableInterpolation); - if (name === 'default') { - return this.get(config.defaultDatasource); + if (nameOrUid === 'default') { + return this.get(this.defaultName); } - if (this.datasources[name]) { - return Promise.resolve(this.datasources[name]); + if (this.datasources[nameOrUid]) { + return Promise.resolve(this.datasources[nameOrUid]); } - return this.loadDatasource(name); + return this.loadDatasource(nameOrUid); } async loadDatasource(name: string): Promise> { @@ -68,7 +104,7 @@ export class DatasourceSrv implements DataSourceService { return Promise.resolve(expressionDatasource); } - const dsConfig = config.datasources[name]; + const dsConfig = this.settingsMapByName[name]; if (!dsConfig) { return Promise.reject({ message: `Datasource named ${name} was not found` }); } @@ -101,92 +137,112 @@ export class DatasourceSrv implements DataSourceService { } getAll(): DataSourceInstanceSettings[] { - const { datasources } = config; - return Object.keys(datasources).map(name => datasources[name]); + return Object.values(this.settingsMapByName); } - getExternal(): DataSourceInstanceSettings[] { - const datasources = this.getAll().filter(ds => !ds.meta.builtIn); - return sortBy(datasources, ['name']); - } - - getAnnotationSources() { - const sources: any[] = []; - - this.addDataSourceVariables(sources); - - Object.values(config.datasources).forEach(value => { - if (value.meta?.annotations) { - sources.push(value); + getList(filters: GetDataSourceListFilters = {}): DataSourceInstanceSettings[] { + const base = Object.values(this.settingsMapByName).filter(x => { + if (x.meta.id === 'grafana' || x.meta.id === 'mixed' || x.meta.id === 'dashboard') { + return false; } + if (filters.metrics && !x.meta.metrics) { + return false; + } + if (filters.tracing && !x.meta.tracing) { + return false; + } + if (filters.annotations && !x.meta.annotations) { + return false; + } + if (filters.pluginId && x.meta.id !== filters.pluginId) { + return false; + } + return true; }); - return sources; - } + if (filters.variables) { + for (const variable of this.templateSrv.getVariables().filter(variable => variable.type === 'datasource')) { + const dsVar = variable as DataSourceVariableModel; + const first = dsVar.current.value === 'default' ? this.defaultName : dsVar.current.value; + const dsName = (first as unknown) as string; + const dsSettings = this.settingsMapByName[dsName]; - getMetricSources(options?: { skipVariables?: boolean }) { - const metricSources: DataSourceSelectItem[] = []; - - Object.entries(config.datasources).forEach(([key, value]) => { - if (value.meta?.metrics) { - let metricSource: DataSourceSelectItem = { value: key, name: key, meta: value.meta, sort: key }; - - //Make sure grafana and mixed are sorted at the bottom - if (value.meta.id === 'grafana') { - metricSource.sort = String.fromCharCode(253); - } else if (value.meta.id === 'dashboard') { - metricSource.sort = String.fromCharCode(254); - } else if (value.meta.id === 'mixed') { - metricSource.sort = String.fromCharCode(255); - } - - metricSources.push(metricSource); - - if (key === config.defaultDatasource) { - metricSource = { value: null, name: 'default', meta: value.meta, sort: key }; - metricSources.push(metricSource); + if (dsSettings) { + const key = `$\{${variable.name}\}`; + base.push({ + ...dsSettings, + name: key, + }); } } - }); - - if (!options || !options.skipVariables) { - this.addDataSourceVariables(metricSources); } - metricSources.sort((a, b) => { - if (a.sort.toLowerCase() > b.sort.toLowerCase()) { + const sorted = base.sort((a, b) => { + if (a.name.toLowerCase() > b.name.toLowerCase()) { return 1; } - if (a.sort.toLowerCase() < b.sort.toLowerCase()) { + if (a.name.toLowerCase() < b.name.toLowerCase()) { return -1; } return 0; }); - return metricSources; + if (!filters.pluginId) { + if (filters.mixed) { + base.push(this.getInstanceSettings('-- Mixed --')!); + } + + if (filters.dashboard) { + base.push(this.getInstanceSettings('-- Dashboard --')!); + } + + if (!filters.tracing) { + base.push(this.getInstanceSettings('-- Grafana --')!); + } + } + + return sorted; } - addDataSourceVariables(list: any[]) { - // look for data source variables - this.templateSrv - .getVariables() - .filter(variable => variable.type === 'datasource') - .forEach((variable: DataSourceVariableModel) => { - const first = variable.current.value === 'default' ? config.defaultDatasource : variable.current.value; - const index = (first as unknown) as string; - const ds = config.datasources[index]; - - if (ds) { - const key = `$${variable.name}`; - list.push({ - name: key, - value: key, - meta: ds.meta, - sort: key, - }); - } - }); + /** + * @deprecated use getList + * */ + getExternal(): DataSourceInstanceSettings[] { + return this.getList(); } + + /** + * @deprecated use getList + * */ + getAnnotationSources() { + return this.getList({ annotations: true, variables: true }).map(x => { + return { + name: x.name, + value: x.isDefault ? null : x.name, + meta: x.meta, + }; + }); + } + + /** + * @deprecated use getList + * */ + getMetricSources(options?: { skipVariables?: boolean }): DataSourceSelectItem[] { + return this.getList({ metrics: true, variables: !options?.skipVariables }).map(x => { + return { + name: x.name, + value: x.isDefault ? null : x.name, + meta: x.meta, + }; + }); + } +} + +export function variableInterpolation(value: any[]) { + if (Array.isArray(value)) { + return value[0]; + } + return value; } export const getDatasourceSrv = (): DatasourceSrv => { diff --git a/public/app/features/plugins/specs/datasource_srv.test.ts b/public/app/features/plugins/specs/datasource_srv.test.ts index 36b59e9f6e9..8817efa358f 100644 --- a/public/app/features/plugins/specs/datasource_srv.test.ts +++ b/public/app/features/plugins/specs/datasource_srv.test.ts @@ -1,7 +1,6 @@ -import config from 'app/core/config'; import 'app/features/plugins/datasource_srv'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { DataSourcePluginMeta, PluginMeta } from '@grafana/data'; +import { DataSourceInstanceSettings, DataSourcePlugin } from '@grafana/data'; // Datasource variable $datasource with current value 'BBB' const templateSrv: any = { @@ -14,100 +13,200 @@ const templateSrv: any = { }, }, ], + replace: (v: string) => { + return v.replace('${datasource}', 'BBB'); + }, }; +class TestDataSource { + constructor(public instanceSettings: DataSourceInstanceSettings) {} +} + +jest.mock('../plugin_loader', () => ({ + importDataSourcePlugin: () => { + return Promise.resolve(new DataSourcePlugin(TestDataSource as any)); + }, +})); + describe('datasource_srv', () => { - const _datasourceSrv = new DatasourceSrv({} as any, {} as any, templateSrv); + const dataSourceSrv = new DatasourceSrv({} as any, {} as any, templateSrv); + const dataSourceInit = { + mmm: { + type: 'test-db', + name: 'mmm', + uid: 'uid-code-mmm', + meta: { metrics: true, annotations: true } as any, + }, + '-- Grafana --': { + type: 'grafana', + name: '-- Grafana --', + meta: { builtIn: true, metrics: true, id: 'grafana' }, + }, + '-- Dashboard --': { + type: 'dashboard', + name: '-- Dashboard --', + meta: { builtIn: true, metrics: true, id: 'dashboard' }, + }, + '-- Mixed --': { + type: 'test-db', + name: '-- Mixed --', + meta: { builtIn: true, metrics: true, id: 'mixed' }, + }, + ZZZ: { + type: 'test-db', + name: 'ZZZ', + uid: 'uid-code-ZZZ', + meta: { metrics: true }, + }, + aaa: { + type: 'test-db', + name: 'aaa', + uid: 'uid-code-aaa', + meta: { metrics: true }, + }, + BBB: { + type: 'test-db', + name: 'BBB', + uid: 'uid-code-BBB', + meta: { metrics: true }, + }, + Jaeger: { + type: 'jaeger-db', + name: 'Jaeger', + uid: 'uid-code-Jaeger', + meta: { tracing: true, id: 'jaeger' }, + }, + }; - describe('when loading external datasources', () => { + describe('Given a list of data sources', () => { beforeEach(() => { - config.datasources = { - buildInDs: { - id: 1, - uid: '1', - type: 'b', - name: 'buildIn', - meta: { builtIn: true } as DataSourcePluginMeta, - jsonData: {}, - }, - nonBuildIn: { - id: 2, - uid: '2', - type: 'e', - name: 'external1', - meta: { builtIn: false } as DataSourcePluginMeta, - jsonData: {}, - }, - nonExplore: { - id: 3, - uid: '3', - type: 'e2', - name: 'external2', - meta: {} as PluginMeta, - jsonData: {}, - }, - }; + dataSourceSrv.init(dataSourceInit as any, 'BBB'); }); - it('should return list of explore sources', () => { - const externalSources = _datasourceSrv.getExternal(); - expect(externalSources.length).toBe(2); - expect(externalSources[0].name).toBe('external1'); - expect(externalSources[1].name).toBe('external2'); - }); - }); + describe('when getting data source class instance', () => { + it('should load plugin and create instance and set meta', async () => { + const ds = (await dataSourceSrv.get('mmm')) as any; + expect(ds.meta).toBe(dataSourceInit.mmm.meta); + expect(ds.instanceSettings).toBe(dataSourceInit.mmm); - describe('when loading metric sources', () => { - let metricSources: any; - const unsortedDatasources = { - mmm: { - type: 'test-db', - meta: { metrics: { m: 1 } }, - }, - '--Grafana--': { - type: 'grafana', - meta: { builtIn: true, metrics: { m: 1 }, id: 'grafana' }, - }, - '--Mixed--': { - type: 'test-db', - meta: { builtIn: true, metrics: { m: 1 }, id: 'mixed' }, - }, - ZZZ: { - type: 'test-db', - meta: { metrics: { m: 1 } }, - }, - aaa: { - type: 'test-db', - meta: { metrics: { m: 1 } }, - }, - BBB: { - type: 'test-db', - meta: { metrics: { m: 1 } }, - }, - }; - beforeEach(() => { - config.datasources = unsortedDatasources as any; - metricSources = _datasourceSrv.getMetricSources({}); - config.defaultDatasource = 'BBB'; + // validate that it caches instance + const ds2 = await dataSourceSrv.get('mmm'); + expect(ds).toBe(ds2); + }); + + it('should be able to load data source using uid as well', async () => { + const dsByUid = await dataSourceSrv.get('uid-code-mmm'); + const dsByName = await dataSourceSrv.get('mmm'); + expect(dsByUid.meta).toBe(dsByName.meta); + expect(dsByUid).toBe(dsByName); + }); }); - it('should return a list of sources sorted case insensitively with builtin sources last', () => { - expect(metricSources[1].name).toBe('aaa'); - expect(metricSources[2].name).toBe('BBB'); - expect(metricSources[3].name).toBe('mmm'); - expect(metricSources[4].name).toBe('ZZZ'); - expect(metricSources[5].name).toBe('--Grafana--'); - expect(metricSources[6].name).toBe('--Mixed--'); + describe('when getting instance settings', () => { + it('should work by name or uid', () => { + expect(dataSourceSrv.getInstanceSettings('mmm')).toBe(dataSourceSrv.getInstanceSettings('uid-code-mmm')); + }); + + it('should work with variable', () => { + const ds = dataSourceSrv.getInstanceSettings('${datasource}'); + expect(ds?.name).toBe('${datasource}'); + expect(ds?.uid).toBe('uid-code-BBB'); + }); }); - it('should set default data source', () => { - expect(metricSources[3].name).toBe('default'); - expect(metricSources[3].sort).toBe('BBB'); + describe('when getting external metric sources', () => { + it('should return list of explore sources', () => { + const externalSources = dataSourceSrv.getExternal(); + expect(externalSources.length).toBe(6); + }); }); - it('should set default inject the variable datasources', () => { - expect(metricSources[0].name).toBe('$datasource'); - expect(metricSources[0].sort).toBe('$datasource'); + it('Can get list of data sources with variables: true', () => { + const list = dataSourceSrv.getList({ metrics: true, variables: true }); + expect(list[0].name).toBe('${datasource}'); + }); + + it('Can get list of data sources with tracing: true', () => { + const list = dataSourceSrv.getList({ tracing: true }); + expect(list[0].name).toBe('Jaeger'); + }); + + it('Can get list of data sources with annotation: true', () => { + const list = dataSourceSrv.getList({ annotations: true }); + expect(list[0].name).toBe('mmm'); + }); + + it('Can get get list and filter by pluginId', () => { + const list = dataSourceSrv.getList({ pluginId: 'jaeger' }); + expect(list[0].name).toBe('Jaeger'); + expect(list.length).toBe(1); + }); + + it('Can get list of data sources with metrics: true, builtIn: true, mixed: true', () => { + expect(dataSourceSrv.getList({ metrics: true, dashboard: true, mixed: true })).toMatchInlineSnapshot(` + Array [ + Object { + "meta": Object { + "metrics": true, + }, + "name": "aaa", + "type": "test-db", + "uid": "uid-code-aaa", + }, + Object { + "meta": Object { + "metrics": true, + }, + "name": "BBB", + "type": "test-db", + "uid": "uid-code-BBB", + }, + Object { + "meta": Object { + "annotations": true, + "metrics": true, + }, + "name": "mmm", + "type": "test-db", + "uid": "uid-code-mmm", + }, + Object { + "meta": Object { + "metrics": true, + }, + "name": "ZZZ", + "type": "test-db", + "uid": "uid-code-ZZZ", + }, + Object { + "meta": Object { + "builtIn": true, + "id": "mixed", + "metrics": true, + }, + "name": "-- Mixed --", + "type": "test-db", + }, + Object { + "meta": Object { + "builtIn": true, + "id": "dashboard", + "metrics": true, + }, + "name": "-- Dashboard --", + "type": "dashboard", + }, + Object { + "meta": Object { + "builtIn": true, + "id": "grafana", + "metrics": true, + }, + "name": "-- Grafana --", + "type": "grafana", + }, + ] + `); }); }); }); diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.test.ts b/public/app/features/query/components/QueryEditorRow.test.ts similarity index 100% rename from public/app/features/dashboard/panel_editor/QueryEditorRow.test.ts rename to public/app/features/query/components/QueryEditorRow.test.ts diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx similarity index 86% rename from public/app/features/dashboard/panel_editor/QueryEditorRow.tsx rename to public/app/features/query/components/QueryEditorRow.tsx index 626cdb45eda..e0722709db3 100644 --- a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -6,9 +6,6 @@ import _ from 'lodash'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { AngularComponent, getAngularLoader } from '@grafana/runtime'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; -// Types -import { PanelModel } from '../state/PanelModel'; - import { ErrorBoundaryAlert, HorizontalGroup } from '@grafana/ui'; import { DataQuery, @@ -19,29 +16,29 @@ import { TimeRange, toLegacyResponseData, EventBusExtended, + DataSourceInstanceSettings, } from '@grafana/data'; import { QueryEditorRowTitle } from './QueryEditorRowTitle'; import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction'; -import { DashboardModel } from '../state/DashboardModel'; +import { DashboardModel } from '../../dashboard/state/DashboardModel'; import { selectors } from '@grafana/e2e-selectors'; +import { PanelModel } from 'app/features/dashboard/state'; interface Props { - panel: PanelModel; data: PanelData; query: DataQuery; - dashboard: DashboardModel; - dataSourceValue: string | null; - inMixedMode?: boolean; + dsSettings: DataSourceInstanceSettings; id: string; index: number; onAddQuery: (query?: DataQuery) => void; onRemoveQuery: (query: DataQuery) => void; onChange: (query: DataQuery) => void; + onRunQuery: () => void; } interface State { - loadedDataSourceValue: string | null | undefined; + loadedDataSourceIdentifier?: string | null; datasource: DataSourceApi | null; hasTextEditMode: boolean; data?: PanelData; @@ -55,7 +52,6 @@ export class QueryEditorRow extends PureComponent { state: State = { datasource: null, - loadedDataSourceValue: undefined, hasTextEditMode: false, data: undefined, isOpen: true, @@ -72,42 +68,52 @@ export class QueryEditorRow extends PureComponent { } getAngularQueryComponentScope(): AngularQueryComponentScope { - const { panel, query, dashboard } = this.props; + const { query, onChange } = this.props; const { datasource } = this.state; + const panel = new PanelModel({}); + const dashboard = {} as DashboardModel; return { datasource: datasource, target: query, panel: panel, dashboard: dashboard, - refresh: () => panel.refresh(), - render: () => panel.render(), + refresh: () => { + // Old angular editors modify the query model and just call refresh + onChange(query); + }, + render: () => () => console.log('legacy render function called, it does nothing'), events: panel.events, range: getTimeSrv().timeRange(), }; } + getQueryDataSourceIdentifier(): string | null | undefined { + const { query, dsSettings } = this.props; + return query.datasource ?? dsSettings.uid; + } + async loadDatasource() { - const { query, panel } = this.props; const dataSourceSrv = getDatasourceSrv(); - let datasource; + let datasource: DataSourceApi; + const dataSourceIdentifier = this.getQueryDataSourceIdentifier(); try { - datasource = await dataSourceSrv.get(query.datasource || panel.datasource); + datasource = await dataSourceSrv.get(dataSourceIdentifier); } catch (error) { datasource = await dataSourceSrv.get(); } this.setState({ datasource, - loadedDataSourceValue: this.props.dataSourceValue, + loadedDataSourceIdentifier: dataSourceIdentifier, hasTextEditMode: _.has(datasource, 'components.QueryCtrl.prototype.toggleEditorMode'), }); } componentDidUpdate(prevProps: Props) { - const { loadedDataSourceValue } = this.state; - const { data, query, panel } = this.props; + const { datasource, loadedDataSourceIdentifier } = this.state; + const { data, query } = this.props; if (data !== prevProps.data) { this.setState({ data: filterPanelDataToQuery(data, query.refId) }); @@ -117,12 +123,12 @@ export class QueryEditorRow extends PureComponent { } if (this.angularQueryEditor) { - notifyAngularQueryEditorsOfData(panel, data, this.angularQueryEditor); + notifyAngularQueryEditorsOfData(this.angularScope?.panel!, data, this.angularQueryEditor); } } // check if we need to load another datasource - if (loadedDataSourceValue !== this.props.dataSourceValue) { + if (datasource && loadedDataSourceIdentifier !== this.getQueryDataSourceIdentifier()) { if (this.angularQueryEditor) { this.angularQueryEditor.destroy(); this.angularQueryEditor = null; @@ -134,6 +140,7 @@ export class QueryEditorRow extends PureComponent { if (!this.element || this.angularQueryEditor) { return; } + this.renderAngularQueryEditor(); } @@ -141,13 +148,16 @@ export class QueryEditorRow extends PureComponent { if (!this.element) { return; } + if (this.angularQueryEditor) { this.angularQueryEditor.destroy(); this.angularQueryEditor = null; } + const loader = getAngularLoader(); const template = ''; const scopeProps = { ctrl: this.getAngularQueryComponentScope() }; + this.angularQueryEditor = loader.load(this.element, scopeProps, template); this.angularScope = scopeProps.ctrl; }; @@ -157,7 +167,7 @@ export class QueryEditorRow extends PureComponent { }; onRunQuery = () => { - this.props.panel.refresh(); + this.props.onRunQuery(); }; renderPluginEditor = () => { @@ -253,14 +263,14 @@ export class QueryEditorRow extends PureComponent { }; renderTitle = (props: { isOpen: boolean; openRow: () => void }) => { - const { query, inMixedMode } = this.props; + const { query, dsSettings } = this.props; const { datasource } = this.state; const isDisabled = query.hide; return ( this.onToggleEditMode(e, props)} diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRowTitle.tsx b/public/app/features/query/components/QueryEditorRowTitle.tsx similarity index 100% rename from public/app/features/dashboard/panel_editor/QueryEditorRowTitle.tsx rename to public/app/features/query/components/QueryEditorRowTitle.tsx diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRows.tsx b/public/app/features/query/components/QueryEditorRows.tsx similarity index 59% rename from public/app/features/dashboard/panel_editor/QueryEditorRows.tsx rename to public/app/features/query/components/QueryEditorRows.tsx index 9a45c9bcc1d..55d9e9805b4 100644 --- a/public/app/features/dashboard/panel_editor/QueryEditorRows.tsx +++ b/public/app/features/query/components/QueryEditorRows.tsx @@ -2,48 +2,31 @@ import React, { PureComponent } from 'react'; // Types -import { PanelModel } from '../state/PanelModel'; -import { DataQuery, PanelData, DataSourceSelectItem } from '@grafana/data'; -import { DashboardModel } from '../state/DashboardModel'; +import { DataQuery, DataSourceInstanceSettings, PanelData } from '@grafana/data'; import { QueryEditorRow } from './QueryEditorRow'; -import { addQuery } from 'app/core/utils/query'; import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd'; interface Props { // The query configuration queries: DataQuery[]; - datasource: DataSourceSelectItem; + dsSettings: DataSourceInstanceSettings; // Query editing - onChangeQueries: (queries: DataQuery[]) => void; - onScrollBottom: () => void; - - // Dashboard Configs - panel: PanelModel; - dashboard: DashboardModel; + onQueriesChange: (queries: DataQuery[]) => void; + onAddQuery: (query: DataQuery) => void; + onRunQueries: () => void; // Query Response Data data: PanelData; } export class QueryEditorRows extends PureComponent { - onAddQuery = (query?: Partial) => { - const { queries, onChangeQueries } = this.props; - onChangeQueries(addQuery(queries, query)); - this.props.onScrollBottom(); - }; - onRemoveQuery = (query: DataQuery) => { - const { queries, onChangeQueries, panel } = this.props; - const removed = queries.filter(q => { - return q !== query; - }); - onChangeQueries(removed); - panel.refresh(); + this.props.onQueriesChange(this.props.queries.filter(item => item !== query)); }; onChangeQuery(query: DataQuery, index: number) { - const { queries, onChangeQueries } = this.props; + const { queries, onQueriesChange } = this.props; const old = queries[index]; @@ -54,7 +37,7 @@ export class QueryEditorRows extends PureComponent { } // update query in array - onChangeQueries( + onQueriesChange( queries.map((item, itemIndex) => { if (itemIndex === index) { return query; @@ -65,7 +48,7 @@ export class QueryEditorRows extends PureComponent { } onDragEnd = (result: DropResult) => { - const { queries, onChangeQueries, panel } = this.props; + const { queries, onQueriesChange } = this.props; if (!result || !result.destination) { return; @@ -80,32 +63,30 @@ export class QueryEditorRows extends PureComponent { const update = Array.from(queries); const [removed] = update.splice(startIndex, 1); update.splice(endIndex, 0, removed); - onChangeQueries(update); - panel.refresh(); + onQueriesChange(update); }; render() { - const { props } = this; + const { dsSettings, data, queries } = this.props; + return ( {provided => { return (
- {props.queries.map((query, index) => ( + {queries.map((query, index) => ( this.onChangeQuery(query, index)} onRemoveQuery={this.onRemoveQuery} - onAddQuery={this.onAddQuery} - inMixedMode={props.datasource.meta.mixed} + onAddQuery={this.props.onAddQuery} + onRunQuery={this.props.onRunQueries} /> ))} {provided.placeholder} diff --git a/public/app/features/query/components/QueryGroup.tsx b/public/app/features/query/components/QueryGroup.tsx new file mode 100644 index 00000000000..8c4c5b65089 --- /dev/null +++ b/public/app/features/query/components/QueryGroup.tsx @@ -0,0 +1,374 @@ +// Libraries +import React, { PureComponent } from 'react'; +// Components +import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; +import { Button, CustomScrollbar, HorizontalGroup, Modal, stylesFactory } from '@grafana/ui'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { QueryEditorRows } from './QueryEditorRows'; +// Services +import { backendSrv } from 'app/core/services/backend_srv'; +import config from 'app/core/config'; +// Types +import { + DataQuery, + DefaultTimeRange, + LoadingState, + PanelData, + DataSourceApi, + DataSourceInstanceSettings, +} from '@grafana/data'; +import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; +import { addQuery } from 'app/core/utils/query'; +import { Unsubscribable } from 'rxjs'; +import { expressionDatasource, ExpressionDatasourceID } from 'app/features/expressions/ExpressionDatasource'; +import { selectors } from '@grafana/e2e-selectors'; +import { PanelQueryRunner } from '../state/PanelQueryRunner'; +import { QueryGroupOptions, QueryGroupOptionsEditor } from './QueryGroupOptions'; +import { DashboardQueryEditor, isSharedDashboardQuery } from 'app/plugins/datasource/dashboard'; +import { css } from 'emotion'; + +interface Props { + queryRunner: PanelQueryRunner; + options: QueryGroupOptions; + onOpenQueryInspector?: () => void; + onRunQueries: () => void; + onOptionsChange: (options: QueryGroupOptions) => void; +} + +interface State { + dataSource?: DataSourceApi; + dsSettings?: DataSourceInstanceSettings; + helpContent: React.ReactNode; + isLoadingHelp: boolean; + isPickerOpen: boolean; + isAddingMixed: boolean; + scrollTop: number; + data: PanelData; + isHelpOpen: boolean; +} + +export class QueryGroup extends PureComponent { + backendSrv = backendSrv; + dataSourceSrv = getDataSourceSrv(); + querySubscription: Unsubscribable | null; + + state: State = { + isLoadingHelp: false, + helpContent: null, + isPickerOpen: false, + isAddingMixed: false, + isHelpOpen: false, + scrollTop: 0, + data: { + state: LoadingState.NotStarted, + series: [], + timeRange: DefaultTimeRange, + }, + }; + + async componentDidMount() { + const { queryRunner, options } = this.props; + + this.querySubscription = queryRunner.getData({ withTransforms: false, withFieldConfig: false }).subscribe({ + next: (data: PanelData) => this.onPanelDataUpdate(data), + }); + + try { + const ds = await this.dataSourceSrv.get(options.dataSource.name); + const dsSettings = this.dataSourceSrv.getInstanceSettings(options.dataSource.name); + this.setState({ dataSource: ds, dsSettings }); + } catch (error) { + console.log('failed to load data source', error); + } + } + + componentWillUnmount() { + if (this.querySubscription) { + this.querySubscription.unsubscribe(); + this.querySubscription = null; + } + } + + onPanelDataUpdate(data: PanelData) { + this.setState({ data }); + } + + onChangeDataSource = async (newSettings: DataSourceInstanceSettings) => { + let { queries } = this.props.options; + const { dsSettings } = this.state; + + // switching to mixed + if (newSettings.meta.mixed) { + for (const query of queries) { + if (query.datasource !== ExpressionDatasourceID) { + query.datasource = dsSettings?.name; + if (!query.datasource) { + query.datasource = config.defaultDatasource; + } + } + } + } else if (dsSettings) { + // if switching from mixed + if (dsSettings.meta.mixed) { + // Remove the explicit datasource + for (const query of queries) { + if (query.datasource !== ExpressionDatasourceID) { + delete query.datasource; + } + } + } else if (dsSettings.meta.id !== newSettings.meta.id) { + // we are changing data source type, clear queries + queries = [{ refId: 'A' }]; + } + } + + const dataSource = await this.dataSourceSrv.get(newSettings.name); + + this.onChange({ + queries, + dataSource: { + name: newSettings.name, + uid: newSettings.uid, + default: newSettings.isDefault, + }, + }); + + this.setState({ + dataSource: dataSource, + dsSettings: newSettings, + }); + }; + + onAddQueryClick = () => { + if (this.state.dsSettings?.meta.mixed) { + this.setState({ isAddingMixed: true }); + return; + } + + this.onChange({ queries: addQuery(this.props.options.queries) }); + this.onScrollBottom(); + }; + + onChange(changedProps: Partial) { + this.props.onOptionsChange({ + ...this.props.options, + ...changedProps, + }); + } + + onAddExpressionClick = () => { + this.onChange({ + queries: addQuery(this.props.options.queries, expressionDatasource.newQuery()), + }); + this.onScrollBottom(); + }; + + onScrollBottom = () => { + this.setState({ scrollTop: 1000 }); + }; + + renderTopSection(styles: QueriesTabStyls) { + const { onOpenQueryInspector, options, onOptionsChange } = this.props; + const { dataSource, data } = this.state; + + return ( +
+
+
+ +
+ {dataSource && ( + <> +
+
+
+ +
+ {onOpenQueryInspector && ( +
+ +
+ )} + + )} +
+
+ ); + } + + onOpenHelp = () => { + this.setState({ isHelpOpen: true }); + }; + + onCloseHelp = () => { + this.setState({ isHelpOpen: false }); + }; + + renderMixedPicker = () => { + return ( + + ); + }; + + onAddMixedQuery = (datasource: any) => { + this.onAddQuery({ datasource: datasource.name }); + this.setState({ isAddingMixed: false, scrollTop: this.state.scrollTop + 10000 }); + }; + + onMixedPickerBlur = () => { + this.setState({ isAddingMixed: false }); + }; + + onAddQuery = (query: Partial) => { + const { queries } = this.props.options; + this.onChange({ queries: addQuery(queries, query) }); + this.onScrollBottom(); + }; + + setScrollTop = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + this.setState({ scrollTop: target.scrollTop }); + }; + + onQueriesChange = (queries: DataQuery[]) => { + this.onChange({ queries }); + }; + + renderQueries(dsSettings: DataSourceInstanceSettings) { + const { options, onRunQueries } = this.props; + const { data } = this.state; + + if (isSharedDashboardQuery(dsSettings.name)) { + return ; + } + + return ( +
+ +
+ ); + } + + renderAddQueryRow(dsSettings: DataSourceInstanceSettings) { + const { isAddingMixed } = this.state; + const showAddButton = !(isAddingMixed || isSharedDashboardQuery(dsSettings.name)); + + return ( + + {showAddButton && ( + + )} + {isAddingMixed && this.renderMixedPicker()} + {config.featureToggles.expressions && ( + + )} + + ); + } + + render() { + const { scrollTop, isHelpOpen, dsSettings } = this.state; + const styles = getStyles(); + + return ( + +
+ {this.renderTopSection(styles)} + {dsSettings && ( + <> +
{this.renderQueries(dsSettings)}
+ {this.renderAddQueryRow(dsSettings)} + {isHelpOpen && ( + + + + )} + + )} +
+
+ ); + } +} + +const getStyles = stylesFactory(() => { + const { theme } = config; + + return { + innerWrapper: css` + display: flex; + flex-direction: column; + height: 100%; + padding: ${theme.spacing.md}; + `, + dataSourceRow: css` + display: flex; + margin-bottom: ${theme.spacing.md}; + `, + dataSourceRowItem: css` + margin-right: ${theme.spacing.inlineFormMargin}; + `, + dataSourceRowItemOptions: css` + flex-grow: 1; + margin-right: ${theme.spacing.inlineFormMargin}; + `, + queriesWrapper: css` + padding-bottom: 16px; + `, + }; +}); + +type QueriesTabStyls = ReturnType; diff --git a/public/app/features/dashboard/panel_editor/QueryOptions.tsx b/public/app/features/query/components/QueryGroupOptions.tsx similarity index 66% rename from public/app/features/dashboard/panel_editor/QueryOptions.tsx rename to public/app/features/query/components/QueryGroupOptions.tsx index 89421689d4c..ca31fef73e2 100644 --- a/public/app/features/dashboard/panel_editor/QueryOptions.tsx +++ b/public/app/features/query/components/QueryGroupOptions.tsx @@ -2,57 +2,61 @@ import React, { PureComponent, ChangeEvent, FocusEvent } from 'react'; // Utils -import { rangeUtil, PanelData, DataSourceApi } from '@grafana/data'; +import { rangeUtil, PanelData, DataSourceApi, DataQuery } from '@grafana/data'; // Components import { Switch, Input, InlineField, InlineFormLabel, stylesFactory } from '@grafana/ui'; // Types -import { PanelModel } from '../state'; import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; import { config } from 'app/core/config'; import { css } from 'emotion'; -const timeRangeValidation = (value: string) => { - if (!value) { - return true; - } - return rangeUtil.isValidTimeSpan(value); -}; +export interface QueryGroupOptions { + queries: DataQuery[]; + dataSource: QueryGroupDataSource; + maxDataPoints?: number | null; + minInterval?: string | null; + cacheTimeout?: string | null; + timeRange?: { + from?: string | null; + shift?: string | null; + hide?: boolean; + }; +} -const emptyToNull = (value: string) => { - return value === '' ? null : value; -}; +interface QueryGroupDataSource { + name?: string | null; + uid?: string; + default?: boolean; +} interface Props { - panel: PanelModel; + options: QueryGroupOptions; dataSource: DataSourceApi; data: PanelData; + onChange: (options: QueryGroupOptions) => void; } interface State { - relativeTime: string; - timeShift: string; - cacheTimeout: string; - maxDataPoints: number | string; - interval: string; - hideTimeOverride: boolean; + timeRangeFrom: string; + timeRangeShift: string; + timeRangeHide: boolean; isOpen: boolean; relativeTimeIsValid: boolean; timeShiftIsValid: boolean; } -export class QueryOptions extends PureComponent { +export class QueryGroupOptionsEditor extends PureComponent { constructor(props: Props) { super(props); + const { options } = props; + this.state = { - relativeTime: props.panel.timeFrom || '', - timeShift: props.panel.timeShift || '', - cacheTimeout: props.panel.cacheTimeout || '', - maxDataPoints: props.panel.maxDataPoints ?? '', - interval: props.panel.interval || '', - hideTimeOverride: props.panel.hideTimeOverride || false, + timeRangeFrom: options.timeRange?.from || '', + timeRangeShift: options.timeRange?.shift || '', + timeRangeHide: options.timeRange?.hide ?? false, isOpen: false, relativeTimeIsValid: true, timeShiftIsValid: true, @@ -61,79 +65,103 @@ export class QueryOptions extends PureComponent { onRelativeTimeChange = (event: ChangeEvent) => { this.setState({ - relativeTime: event.target.value, + timeRangeFrom: event.target.value, }); }; onTimeShiftChange = (event: ChangeEvent) => { this.setState({ - timeShift: event.target.value, + timeRangeShift: event.target.value, }); }; onOverrideTime = (event: FocusEvent) => { - const { value } = event.target; - const { panel } = this.props; - const emptyToNullValue = emptyToNull(value); - const isValid = timeRangeValidation(value); + const { options, onChange } = this.props; - if (isValid && panel.timeFrom !== emptyToNullValue) { - panel.timeFrom = emptyToNullValue; - panel.refresh(); + const newValue = emptyToNull(event.target.value); + const isValid = timeRangeValidation(newValue); + + if (isValid && options.timeRange?.from !== newValue) { + onChange({ + ...options, + timeRange: { + ...(options.timeRange ?? {}), + from: newValue, + }, + }); } + this.setState({ relativeTimeIsValid: isValid }); }; onTimeShift = (event: FocusEvent) => { - const { value } = event.target; - const { panel } = this.props; - const emptyToNullValue = emptyToNull(value); - const isValid = timeRangeValidation(value); + const { options, onChange } = this.props; - if (isValid && panel.timeShift !== emptyToNullValue) { - panel.timeShift = emptyToNullValue; - panel.refresh(); + const newValue = emptyToNull(event.target.value); + const isValid = timeRangeValidation(newValue); + + if (isValid && options.timeRange?.shift !== newValue) { + onChange({ + ...options, + timeRange: { + ...(options.timeRange ?? {}), + shift: newValue, + }, + }); } + this.setState({ timeShiftIsValid: isValid }); }; onToggleTimeOverride = () => { - const { panel } = this.props; - this.setState({ hideTimeOverride: !this.state.hideTimeOverride }, () => { - panel.hideTimeOverride = this.state.hideTimeOverride; - panel.refresh(); + const { onChange, options } = this.props; + + this.setState({ timeRangeHide: !this.state.timeRangeHide }, () => { + onChange({ + ...options, + timeRange: { + ...(options.timeRange ?? {}), + hide: this.state.timeRangeHide, + }, + }); }); }; - onDataSourceOptionBlur = (panelKey: string) => () => { - const { panel } = this.props; - - // @ts-ignore - panel[panelKey] = this.state[panelKey]; - panel.refresh(); + onCacheTimeoutBlur = (event: ChangeEvent) => { + const { options, onChange } = this.props; + onChange({ + ...options, + cacheTimeout: emptyToNull(event.target.value), + }); }; - onDataSourceOptionChange = (panelKey: string) => (event: ChangeEvent) => { - this.setState({ ...this.state, [panelKey]: event.target.value }); - }; + onMaxDataPointsBlur = (event: ChangeEvent) => { + const { options, onChange } = this.props; - onMaxDataPointsBlur = () => { - const { panel } = this.props; + let maxDataPoints: number | null = parseInt(event.target.value as string, 10); - const maxDataPoints = parseInt(this.state.maxDataPoints as string, 10); - - if (isNaN(maxDataPoints)) { - delete panel.maxDataPoints; - } else { - panel.maxDataPoints = maxDataPoints; + if (isNaN(maxDataPoints) || maxDataPoints === 0) { + maxDataPoints = null; } - panel.refresh(); + onChange({ + ...options, + maxDataPoints: maxDataPoints, + }); + }; + + onMinIntervalBlur = (event: ChangeEvent) => { + const { options, onChange } = this.props; + + onChange({ + ...options, + minInterval: emptyToNull(event.target.value), + }); }; renderCacheTimeoutOption() { - const { dataSource } = this.props; - const { cacheTimeout } = this.state; + const { dataSource, options } = this.props; + const tooltip = `If your time series store has a query cache this option can override the default cache timeout. Specify a numeric value in seconds.`; @@ -151,11 +179,9 @@ export class QueryOptions extends PureComponent { type="text" className="width-6" placeholder="60" - name={name} spellCheck={false} - onBlur={this.onDataSourceOptionBlur('cacheTimeout')} - onChange={this.onDataSourceOptionChange('cacheTimeout')} - value={cacheTimeout} + onBlur={this.onCacheTimeoutBlur} + defaultValue={options.cacheTimeout ?? ''} />
@@ -163,10 +189,10 @@ export class QueryOptions extends PureComponent { } renderMaxDataPointsOption() { - const { data } = this.props; - const { maxDataPoints } = this.state; + const { data, options } = this.props; const realMd = data.request?.maxDataPoints; - const isAuto = maxDataPoints === ''; + const value = options.maxDataPoints ?? ''; + const isAuto = value === ''; return (
@@ -186,11 +212,9 @@ export class QueryOptions extends PureComponent { type="number" className="width-6" placeholder={`${realMd}`} - name={name} spellCheck={false} onBlur={this.onMaxDataPointsBlur} - onChange={this.onDataSourceOptionChange('maxDataPoints')} - value={maxDataPoints} + defaultValue={value} /> {isAuto && ( <> @@ -204,8 +228,7 @@ export class QueryOptions extends PureComponent { } renderIntervalOption() { - const { data, dataSource } = this.props; - const { interval } = this.state; + const { data, dataSource, options } = this.props; const realInterval = data.request?.interval; const minIntervalOnDs = dataSource.interval ?? 'No limit'; @@ -229,11 +252,9 @@ export class QueryOptions extends PureComponent { type="text" className="width-6" placeholder={`${minIntervalOnDs}`} - name={name} spellCheck={false} - onBlur={this.onDataSourceOptionBlur('interval')} - onChange={this.onDataSourceOptionChange('interval')} - value={interval} + onBlur={this.onMinIntervalBlur} + defaultValue={options.minInterval ?? ''} />
@@ -268,19 +289,19 @@ export class QueryOptions extends PureComponent { }; renderCollapsedText(styles: StylesType): React.ReactNode | undefined { - const { data } = this.props; - const { isOpen, maxDataPoints, interval } = this.state; + const { data, options } = this.props; + const { isOpen } = this.state; if (isOpen) { return undefined; } - let mdDesc = maxDataPoints; - if (maxDataPoints === '' && data.request) { + let mdDesc = options.maxDataPoints ?? ''; + if (mdDesc === '' && data.request) { mdDesc = `auto = ${data.request.maxDataPoints}`; } - let intervalDesc = interval; + let intervalDesc = options.minInterval; if (data.request) { intervalDesc = `${data.request.interval}`; } @@ -294,8 +315,8 @@ export class QueryOptions extends PureComponent { } render() { - const { hideTimeOverride, relativeTimeIsValid, timeShiftIsValid } = this.state; - const { relativeTime, timeShift, isOpen } = this.state; + const { timeRangeHide: hideTimeOverride, relativeTimeIsValid, timeShiftIsValid } = this.state; + const { timeRangeFrom: relativeTime, timeRangeShift: timeShift, isOpen } = this.state; const styles = getStyles(); return ( @@ -349,6 +370,18 @@ export class QueryOptions extends PureComponent { } } +const timeRangeValidation = (value: string | null) => { + if (!value) { + return true; + } + + return rangeUtil.isValidTimeSpan(value); +}; + +const emptyToNull = (value: string) => { + return value === '' ? null : value; +}; + const getStyles = stylesFactory(() => { const { theme } = config; diff --git a/public/app/features/dashboard/state/PanelQueryRunner.test.ts b/public/app/features/query/state/PanelQueryRunner.test.ts similarity index 96% rename from public/app/features/dashboard/state/PanelQueryRunner.test.ts rename to public/app/features/query/state/PanelQueryRunner.test.ts index 86808b2d035..f0362a8eecf 100644 --- a/public/app/features/dashboard/state/PanelQueryRunner.test.ts +++ b/public/app/features/query/state/PanelQueryRunner.test.ts @@ -17,7 +17,7 @@ import { PanelData, ScopedVars, } from '@grafana/data'; -import { DashboardModel } from './index'; +import { DashboardModel } from '../../dashboard/state/index'; import { setDataSourceSrv, setEchoSrv } from '@grafana/runtime'; import { Echo } from '../../../core/services/echo/Echo'; @@ -88,11 +88,8 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn }, ], }; - setDataSourceSrv({ - getDataSourceSettingsByUid() { - return {} as any; - }, - } as any); + + setDataSourceSrv({} as any); beforeEach(async () => { setEchoSrv(new Echo()); @@ -241,7 +238,6 @@ describe('PanelQueryRunner', () => { overrides: [], }, replaceVariables: v => v, - getDataSourceSettingsByUid: undefined as any, theme: {} as GrafanaTheme, }), getTransformations: () => undefined, @@ -306,7 +302,6 @@ describe('PanelQueryRunner', () => { overrides: [], }, replaceVariables: v => v, - getDataSourceSettingsByUid: undefined as any, theme: {} as GrafanaTheme, }), // @ts-ignore @@ -349,7 +344,6 @@ describe('PanelQueryRunner', () => { overrides: [], }, replaceVariables: v => v, - getDataSourceSettingsByUid: undefined as any, theme: {} as GrafanaTheme, }), // @ts-ignore diff --git a/public/app/features/dashboard/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts similarity index 99% rename from public/app/features/dashboard/state/PanelQueryRunner.ts rename to public/app/features/query/state/PanelQueryRunner.ts index cb94ce09b8e..ddc050d7f00 100644 --- a/public/app/features/dashboard/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -34,7 +34,7 @@ export interface QueryRunnerOptions< > { datasource: string | DataSourceApi | null; queries: TQuery[]; - panelId: number; + panelId?: number; dashboardId?: number; timezone: TimeZone; timeRange: TimeRange; @@ -89,7 +89,6 @@ export class PanelQueryRunner { ...processedData, series: applyFieldOverrides({ timeZone: timeZone, - autoMinMax: true, data: processedData.series, ...fieldConfig, }), diff --git a/public/app/features/dashboard/state/analyticsProcessor.test.ts b/public/app/features/query/state/queryAnalytics.test.ts similarity index 95% rename from public/app/features/dashboard/state/analyticsProcessor.test.ts rename to public/app/features/query/state/queryAnalytics.test.ts index 5cd1590d1bf..7af84e4fcb8 100644 --- a/public/app/features/dashboard/state/analyticsProcessor.test.ts +++ b/public/app/features/query/state/queryAnalytics.test.ts @@ -1,7 +1,7 @@ import { MetaAnalyticsEventName, reportMetaAnalytics } from '@grafana/runtime'; import { CoreApp, DataQueryRequest, DataSourceApi, dateTime, LoadingState, PanelData } from '@grafana/data'; -import { emitDataRequestEvent } from './analyticsProcessor'; -import { DashboardModel } from './DashboardModel'; +import { emitDataRequestEvent } from './queryAnalytics'; +import { DashboardModel } from '../../dashboard/state/DashboardModel'; beforeEach(() => { jest.clearAllMocks(); diff --git a/public/app/features/query/state/queryAnalytics.ts b/public/app/features/query/state/queryAnalytics.ts new file mode 100644 index 00000000000..90b288724eb --- /dev/null +++ b/public/app/features/query/state/queryAnalytics.ts @@ -0,0 +1,56 @@ +import { getDashboardSrv } from '../../dashboard/services/DashboardSrv'; +import { PanelData, LoadingState, DataSourceApi, CoreApp, urlUtil } from '@grafana/data'; +import { reportMetaAnalytics, MetaAnalyticsEventName, DataRequestEventPayload } from '@grafana/runtime'; + +export function emitDataRequestEvent(datasource: DataSourceApi) { + let done = false; + + return (data: PanelData) => { + if (!data.request || done || data.request.app === CoreApp.Explore) { + return; + } + + const params = urlUtil.getUrlSearchParams(); + if (params.editPanel != null) { + return; + } + + if (data.state !== LoadingState.Done && data.state !== LoadingState.Error) { + return; + } + + const eventData: DataRequestEventPayload = { + eventName: MetaAnalyticsEventName.DataRequest, + datasourceName: datasource.name, + datasourceId: datasource.id, + panelId: data.request.panelId, + dashboardId: data.request.dashboardId, + dataSize: 0, + duration: data.request.endTime! - data.request.startTime, + }; + + // enrich with dashboard info + const dashboard = getDashboardSrv().getCurrent(); + if (dashboard) { + eventData.dashboardId = dashboard.id; + eventData.dashboardName = dashboard.title; + eventData.dashboardUid = dashboard.uid; + eventData.folderName = dashboard.meta.folderTitle; + } + + if (data.series && data.series.length > 0) { + // estimate size + eventData.dataSize = data.series.length; + } + + if (data.error) { + eventData.error = data.error.message; + } + + reportMetaAnalytics(eventData); + + // this done check is to make sure we do not double emit events in case + // there are multiple responses with done state + done = true; + }; +} diff --git a/public/app/features/dashboard/state/runRequest.test.ts b/public/app/features/query/state/runRequest.test.ts similarity index 99% rename from public/app/features/dashboard/state/runRequest.test.ts rename to public/app/features/query/state/runRequest.test.ts index 76ce4aa2141..d2a25396d81 100644 --- a/public/app/features/dashboard/state/runRequest.test.ts +++ b/public/app/features/query/state/runRequest.test.ts @@ -11,7 +11,7 @@ import { import { Observable, Subscriber, Subscription } from 'rxjs'; import { runRequest } from './runRequest'; import { deepFreeze } from '../../../../test/core/redux/reducerTester'; -import { DashboardModel } from './DashboardModel'; +import { DashboardModel } from '../../dashboard/state/DashboardModel'; import { setEchoSrv } from '@grafana/runtime'; import { Echo } from '../../../core/services/echo/Echo'; diff --git a/public/app/features/dashboard/state/runRequest.ts b/public/app/features/query/state/runRequest.ts similarity index 99% rename from public/app/features/dashboard/state/runRequest.ts rename to public/app/features/query/state/runRequest.ts index 327ba76190a..605c6261334 100644 --- a/public/app/features/dashboard/state/runRequest.ts +++ b/public/app/features/query/state/runRequest.ts @@ -20,7 +20,7 @@ import { toDataFrame, } from '@grafana/data'; import { toDataQueryError } from '@grafana/runtime'; -import { emitDataRequestEvent } from './analyticsProcessor'; +import { emitDataRequestEvent } from './queryAnalytics'; import { expressionDatasource, ExpressionDatasourceID } from 'app/features/expressions/ExpressionDatasource'; import { ExpressionQuery } from 'app/features/expressions/types'; diff --git a/public/app/features/query/state/types.ts b/public/app/features/query/state/types.ts new file mode 100644 index 00000000000..009688dc2c8 --- /dev/null +++ b/public/app/features/query/state/types.ts @@ -0,0 +1,5 @@ +import { EventBusExtended } from '@grafana/data'; + +export interface PanelModelForLegacyQueryEditors { + events: EventBusExtended; +} diff --git a/public/app/features/sandbox/TestStuffPage.tsx b/public/app/features/sandbox/TestStuffPage.tsx new file mode 100644 index 00000000000..ecb8184013b --- /dev/null +++ b/public/app/features/sandbox/TestStuffPage.tsx @@ -0,0 +1,97 @@ +import { ApplyFieldOverrideOptions, DataTransformerConfig, dateMath, FieldColorModeId, PanelData } from '@grafana/data'; +import { GraphNG, Table } from '@grafana/ui'; +import { config } from 'app/core/config'; +import React, { FC, useMemo, useState } from 'react'; +import { useObservable } from 'react-use'; +import { QueryGroup } from '../query/components/QueryGroup'; +import { QueryGroupOptions } from '../query/components/QueryGroupOptions'; +import { PanelQueryRunner } from '../query/state/PanelQueryRunner'; + +interface State { + queryRunner: PanelQueryRunner; + queryOptions: QueryGroupOptions; + data?: PanelData; +} + +export const TestStuffPage: FC = () => { + const [state, setState] = useState(getDefaultState()); + const { queryOptions, queryRunner } = state; + + const onRunQueries = () => { + const timeRange = { from: 'now-1h', to: 'now' }; + + queryRunner.run({ + queries: queryOptions.queries, + datasource: queryOptions.dataSource.name!, + timezone: 'browser', + timeRange: { from: dateMath.parse(timeRange.from)!, to: dateMath.parse(timeRange.to)!, raw: timeRange }, + maxDataPoints: queryOptions.maxDataPoints ?? 100, + minInterval: queryOptions.minInterval, + }); + }; + + const onOptionsChange = (queryOptions: QueryGroupOptions) => { + setState({ ...state, queryOptions }); + }; + + /** + * Subscribe to data + */ + const observable = useMemo(() => queryRunner.getData({ withFieldConfig: true, withTransforms: true }), []); + const data = useObservable(observable); + + return ( +
+

New page

+
+ +
+ + {data && ( +
+ +
+ + + )} + + ); +}; + +export function getDefaultState(): State { + const options: ApplyFieldOverrideOptions = { + fieldConfig: { + defaults: { + color: { + mode: FieldColorModeId.PaletteClassic, + }, + }, + overrides: [], + }, + replaceVariables: (v: string) => v, + theme: config.theme, + }; + + const dataConfig = { + getTransformations: () => [] as DataTransformerConfig[], + getFieldOverrideOptions: () => options, + }; + + return { + queryRunner: new PanelQueryRunner(dataConfig), + queryOptions: { + queries: [], + dataSource: { + name: 'gdev-testdata', + }, + maxDataPoints: 100, + }, + }; +} + +export default TestStuffPage; diff --git a/public/app/features/search/components/DashboardListPage.tsx b/public/app/features/search/components/DashboardListPage.tsx index 78fff6e26d6..742859d3755 100644 --- a/public/app/features/search/components/DashboardListPage.tsx +++ b/public/app/features/search/components/DashboardListPage.tsx @@ -41,6 +41,7 @@ export const DashboardListPage: FC = memo(({ navModel, uid, url }) => { ); }); +DashboardListPage.displayName = 'DashboardListPage'; const mapStateToProps: MapStateToProps = state => { return { diff --git a/public/app/features/search/components/DashboardSearch.tsx b/public/app/features/search/components/DashboardSearch.tsx index ca7aeb50bcb..ed92d860ec0 100644 --- a/public/app/features/search/components/DashboardSearch.tsx +++ b/public/app/features/search/components/DashboardSearch.tsx @@ -57,6 +57,7 @@ export const DashboardSearch: FC = memo(({ onCloseSearch, params, updateL ); }); +DashboardSearch.displayName = 'DashboardSearch'; export default connectWithRouteParams(DashboardSearch); diff --git a/public/app/features/search/components/SearchCheckbox.tsx b/public/app/features/search/components/SearchCheckbox.tsx index 205fa66bcaf..1877ac9d2d5 100644 --- a/public/app/features/search/components/SearchCheckbox.tsx +++ b/public/app/features/search/components/SearchCheckbox.tsx @@ -28,3 +28,5 @@ const getStyles = stylesFactory(() => ({ } `, })); + +SearchCheckbox.displayName = 'SearchCheckbox'; diff --git a/public/app/features/search/components/SearchWrapper.tsx b/public/app/features/search/components/SearchWrapper.tsx index 1cab70f02a0..d69d43ee887 100644 --- a/public/app/features/search/components/SearchWrapper.tsx +++ b/public/app/features/search/components/SearchWrapper.tsx @@ -40,6 +40,8 @@ export const SearchWrapper: FC = memo(({ search, folder, updateLocation } return isOpen ? : null; }); +SearchWrapper.displayName = 'SearchWrapper'; + const mapStateToProps: MapStateToProps<{}, OwnProps, StoreState> = (state: StoreState) => { const { search, folder } = getLocationQuery(state.location); return { search, folder }; diff --git a/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap index 631f895ccb9..666898ba725 100644 --- a/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap @@ -10,7 +10,7 @@ exports[`Render should render component 1`] = ` > External group sync - @@ -18,12 +18,12 @@ exports[`Render should render component 1`] = ` className="icon--has-hover page-sub-heading-icon" name="question-circle" /> - +
-
-
+ External group sync - @@ -100,7 +100,7 @@ exports[`Render should render groups table 1`] = ` className="icon--has-hover page-sub-heading-icon" name="question-circle" /> - +
@@ -114,7 +114,7 @@ exports[`Render should render groups table 1`] = ` Add group
-
-
+
diff --git a/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap index 5f9fdbbbf11..8c12eb61fd2 100644 --- a/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap @@ -41,7 +41,7 @@ exports[`Render should render teams table 1`] = `
- - - - - - - - - - testName -
- + - - + - - + - - + - - + - - +
- testName - testName - testName - - -
-
+
@@ -72,13 +72,13 @@ exports[`Render should render component 1`] = `
Name Permission - -
-
+
@@ -166,13 +166,13 @@ exports[`Render should render team members 1`] = `
Name Permission - + `; @@ -66,7 +66,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render s - + `; diff --git a/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap index 1967d04e1ca..a8e44c8514d 100644 --- a/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Render should render component 1`] = ` - - +
-
+
-
+ `; diff --git a/public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap b/public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap index 484356e654d..81da5fe74d5 100644 --- a/public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap +++ b/public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap @@ -7,7 +7,7 @@ exports[`Render should render component 1`] = `
- - - - - @@ -126,7 +126,7 @@ exports[`Render should render users table 1`] = ` size="sm" variant="destructive" /> - - @@ -195,7 +195,7 @@ exports[`Render should render users table 1`] = ` size="sm" variant="destructive" /> - - @@ -264,7 +264,7 @@ exports[`Render should render users table 1`] = ` size="sm" variant="destructive" /> - - @@ -333,7 +333,7 @@ exports[`Render should render users table 1`] = ` size="sm" variant="destructive" /> - - @@ -402,7 +402,7 @@ exports[`Render should render users table 1`] = ` size="sm" variant="destructive" /> - - @@ -471,7 +471,7 @@ exports[`Render should render users table 1`] = ` size="sm" variant="destructive" /> - { setValue: (variable: Model, option: VariableOption, emitChanges?: boolean) => Promise; setValueFromUrl: (variable: Model, urlValue: UrlQueryValue) => Promise; updateOptions: (variable: Model, searchFilter?: string) => Promise; - getSaveModel: (variable: Model) => Partial; + getSaveModel: (variable: Model, saveCurrentAsDefault?: boolean) => Partial; getValueForUrl: (variable: Model) => string | string[]; picker: ComponentType; editor: ComponentType; reducer: Reducer; + beforeAdding?: (model: any) => any; } export type VariableModels = diff --git a/public/app/features/variables/adhoc/actions.test.ts b/public/app/features/variables/adhoc/actions.test.ts index a612e3fafb6..92deb2bd09b 100644 --- a/public/app/features/variables/adhoc/actions.test.ts +++ b/public/app/features/variables/adhoc/actions.test.ts @@ -478,6 +478,5 @@ function createDatasource(name: string, selectable = true): DataSourceSelectItem meta: { mixed: !selectable, } as DataSourcePluginMeta, - sort: '', }; } diff --git a/public/app/features/variables/constant/adapter.ts b/public/app/features/variables/constant/adapter.ts index 57b89df4f25..78a6d629db5 100644 --- a/public/app/features/variables/constant/adapter.ts +++ b/public/app/features/variables/constant/adapter.ts @@ -31,11 +31,17 @@ export const createConstantVariableAdapter = (): VariableAdapter { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, current, options, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: variable => { return variable.current.value; }, + beforeAdding: model => { + const { current, options, query, ...rest } = cloneDeep(model); + const option = { selected: true, text: query, value: query }; + + return { ...rest, current: option, options: [option], query }; + }, }; }; diff --git a/public/app/features/variables/datasource/actions.test.ts b/public/app/features/variables/datasource/actions.test.ts index f652545aefe..93e3a6cfdfa 100644 --- a/public/app/features/variables/datasource/actions.test.ts +++ b/public/app/features/variables/datasource/actions.test.ts @@ -27,13 +27,11 @@ describe('data source actions', () => { name: 'first-name', value: 'first-value', meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }), - sort: '', }, { name: 'second-name', value: 'second-value', meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }), - sort: '', }, ]; @@ -80,13 +78,11 @@ describe('data source actions', () => { name: 'first-name', value: 'first-value', meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }), - sort: '', }, { name: 'second-name', value: 'second-value', meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }), - sort: '', }, ]; @@ -134,13 +130,11 @@ describe('data source actions', () => { name: 'first-name', value: 'first-value', meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }), - sort: '', }, { name: 'second-name', value: 'second-value', meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }), - sort: '', }, { name: 'mixed-name', @@ -150,7 +144,6 @@ describe('data source actions', () => { id: 'mixed-data-id', mixed: true, } as unknown) as DataSourcePluginMeta), - sort: '', }, ]; diff --git a/public/app/features/variables/editor/VariableEditorEditor.tsx b/public/app/features/variables/editor/VariableEditorEditor.tsx index c96af34846f..2d281499dcf 100644 --- a/public/app/features/variables/editor/VariableEditorEditor.tsx +++ b/public/app/features/variables/editor/VariableEditorEditor.tsx @@ -152,7 +152,11 @@ export class VariableEditorEditorUnConnected extends PureComponent { placeholder="optional display name" ariaLabel={selectors.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput} /> - + ) => void; hide: VariableHide; + type: VariableType; } const HIDE_OPTIONS = [ @@ -16,9 +17,13 @@ const HIDE_OPTIONS = [ { label: 'Variable', value: VariableHide.hideVariable }, ]; -export function VariableHideSelect({ onChange, hide }: PropsWithChildren) { +export function VariableHideSelect({ onChange, hide, type }: PropsWithChildren) { const value = useMemo(() => HIDE_OPTIONS.find(o => o.value === hide) ?? HIDE_OPTIONS[0], [hide]); + if (type === 'constant') { + return null; + } + return ( - - +